mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 18:32:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37df67256e | ||
|
|
110448afb8 | ||
|
|
f1410f8bb1 | ||
|
|
6a4fb178cd | ||
|
|
e6f44460ac | ||
|
|
fffe5a9fca | ||
|
|
7189864c7c | ||
|
|
173ef5abbb | ||
|
|
0ba3ea6069 | ||
|
|
06dd46f94f | ||
|
|
42a9826603 | ||
|
|
da0c79ff0d | ||
|
|
a6e0cb8995 | ||
|
|
bb2fc729b8 | ||
|
|
c29fc95531 | ||
|
|
b0386a5769 | ||
|
|
dc45d03c7b | ||
|
|
9290debe91 | ||
|
|
6cf931ac4d | ||
|
|
c4433d4961 | ||
|
|
4213d8e672 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
59721f9717 | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 | ||
|
|
ab8764a317 | ||
|
|
7e72ae65ad | ||
|
|
f2e878858f | ||
|
|
2ff86c2217 | ||
|
|
5e6278cbc7 | ||
|
|
83952aac0d | ||
|
|
7ec6473112 | ||
|
|
17e6834f74 | ||
|
|
9ed8bf76dd | ||
|
|
1e5e77fb9c | ||
|
|
d0e2c905cb | ||
|
|
c11cde9bb2 |
No files matched your search
@@ -53,6 +53,7 @@ jobs:
|
||||
run: |
|
||||
npm run check:plugin-contract
|
||||
npm run test:plugin-runtime
|
||||
npm run test:plugin-examples
|
||||
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
|
||||
|
||||
- name: Test
|
||||
|
||||
@@ -26,6 +26,7 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
|
||||
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
|
||||
import { useConnectionLogsStore } from '../state/connectionLogsStore';
|
||||
import {
|
||||
@@ -856,6 +857,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
|
||||
</div>
|
||||
|
||||
{/* Global "quick add / edit snippet" modal, triggered by the
|
||||
|
||||
@@ -1298,6 +1298,9 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Last recorded error",
|
||||
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
|
||||
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
|
||||
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
|
||||
"settings.plugins.manager.details": "View details",
|
||||
"settings.plugins.manager.noDescription": "No description provided.",
|
||||
@@ -1328,6 +1331,25 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
|
||||
"settings.plugins.openView": "Open",
|
||||
"settings.plugins.openManager": "Open plugin manager",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Manage your plugins and explore the catalog.",
|
||||
'settings.plugins.loading': 'Loading plugin settings…',
|
||||
|
||||
@@ -1298,6 +1298,9 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
|
||||
"settings.plugins.market.results": "{count} plugins",
|
||||
"settings.plugins.manager.lastError": "Último error registrado",
|
||||
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
|
||||
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
|
||||
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
|
||||
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
|
||||
"settings.plugins.manager.details": "Ver detalles",
|
||||
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
|
||||
@@ -1328,6 +1331,25 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
|
||||
"settings.plugins.openView": "Abrir",
|
||||
"settings.plugins.openManager": "Abrir gestor de plugins",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
|
||||
'settings.plugins.loading': 'Cargando ajustes del plugin…',
|
||||
|
||||
@@ -1193,6 +1193,9 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
|
||||
"settings.plugins.market.results": "Плагинов: {count}",
|
||||
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
|
||||
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
|
||||
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
|
||||
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
|
||||
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
|
||||
"settings.plugins.manager.details": "Подробнее",
|
||||
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
|
||||
@@ -1223,6 +1226,25 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
|
||||
"settings.plugins.openView": "Открыть",
|
||||
"settings.plugins.openManager": "Открыть управление плагинами",
|
||||
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
|
||||
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
|
||||
"settings.plugins.sources.official": "Official marketplace",
|
||||
"settings.plugins.sources.thirdparty": "Third-party marketplace",
|
||||
"settings.plugins.sources.manage": "Manage sources",
|
||||
"settings.plugins.sources.select": "Select a source",
|
||||
"settings.plugins.sources.empty": "Choose or add a third-party source.",
|
||||
"settings.plugins.sources.add": "Add source",
|
||||
"settings.plugins.sources.remove": "Remove source",
|
||||
"settings.plugins.sources.name": "Source name",
|
||||
"settings.plugins.sources.address": "HTTPS source address",
|
||||
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
|
||||
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
|
||||
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
|
||||
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
|
||||
"settings.plugins.sources.installConfirm": "Review third-party installation",
|
||||
"settings.plugins.sources.source": "Source",
|
||||
"settings.plugins.sources.local": "Local import",
|
||||
"settings.plugins.sources.all": "All sources",
|
||||
"settings.plugins.title": "Плагины",
|
||||
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
|
||||
'settings.plugins.loading': 'Загрузка настроек плагинов…',
|
||||
|
||||
@@ -1213,6 +1213,9 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
|
||||
"settings.plugins.market.results": "{count} 个插件",
|
||||
"settings.plugins.manager.lastError": "上次记录的错误",
|
||||
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
|
||||
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
|
||||
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
|
||||
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
|
||||
"settings.plugins.manager.details": "查看详情",
|
||||
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
|
||||
@@ -1243,6 +1246,25 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
|
||||
"settings.plugins.openView": "打开",
|
||||
"settings.plugins.openManager": "打开插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
"settings.plugins.sources.select": "选择来源",
|
||||
"settings.plugins.sources.empty": "选择或添加第三方来源。",
|
||||
"settings.plugins.sources.add": "添加来源",
|
||||
"settings.plugins.sources.remove": "移除来源",
|
||||
"settings.plugins.sources.name": "来源名称",
|
||||
"settings.plugins.sources.address": "HTTPS 来源地址",
|
||||
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
|
||||
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
|
||||
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
|
||||
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
|
||||
"settings.plugins.sources.installConfirm": "确认第三方安装",
|
||||
"settings.plugins.sources.source": "来源",
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
|
||||
'settings.plugins.loading': '正在加载插件设置…',
|
||||
|
||||
@@ -1217,11 +1217,33 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
|
||||
"settings.plugins.openView": "開啟",
|
||||
"settings.plugins.openManager": "開啟插件管理",
|
||||
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
|
||||
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
|
||||
"settings.plugins.sources.official": "官方市场",
|
||||
"settings.plugins.sources.thirdparty": "第三方市场",
|
||||
"settings.plugins.sources.manage": "管理来源",
|
||||
"settings.plugins.sources.select": "选择来源",
|
||||
"settings.plugins.sources.empty": "选择或添加第三方来源。",
|
||||
"settings.plugins.sources.add": "添加来源",
|
||||
"settings.plugins.sources.remove": "移除来源",
|
||||
"settings.plugins.sources.name": "来源名称",
|
||||
"settings.plugins.sources.address": "HTTPS 来源地址",
|
||||
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
|
||||
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
|
||||
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
|
||||
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
|
||||
"settings.plugins.sources.installConfirm": "确认第三方安装",
|
||||
"settings.plugins.sources.source": "来源",
|
||||
"settings.plugins.sources.local": "本地导入",
|
||||
"settings.plugins.sources.all": "全部来源",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
|
||||
'settings.plugins.loading': '正在載入外掛程式設定…',
|
||||
"settings.plugins.market.results": "{count} 個插件",
|
||||
"settings.plugins.manager.lastError": "上次記錄的錯誤",
|
||||
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
|
||||
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
|
||||
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
|
||||
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
|
||||
"settings.plugins.manager.details": "查看詳情",
|
||||
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
|
||||
|
||||
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
|
||||
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
|
||||
try {
|
||||
const { document } = dom.window;
|
||||
const calls: boolean[] = [];
|
||||
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
|
||||
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
|
||||
const chooser = document.createElement('div');
|
||||
chooser.dataset.hostSecuritySurface = '';
|
||||
document.body.append(chooser);
|
||||
await flush(); assert.deepEqual(calls, [false]);
|
||||
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
|
||||
document.body.append(chooser); await flush();
|
||||
assert.deepEqual(calls, [false, true, false]);
|
||||
stop(); chooser.remove(); await flush();
|
||||
assert.deepEqual(calls, [false, true, false]);
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
|
||||
test('hidden security surfaces do not occlude a native view', async () => {
|
||||
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
|
||||
try {
|
||||
const calls: boolean[] = [];
|
||||
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
|
||||
assert.deepEqual(calls, []);
|
||||
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
|
||||
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
|
||||
assert.deepEqual(calls, [false]); stop();
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Native plugin views must yield while a host-owned security dialog is open. */
|
||||
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
|
||||
const document = mount.ownerDocument;
|
||||
let visible = true;
|
||||
const update = () => {
|
||||
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
|
||||
const style = document.defaultView!.getComputedStyle(surface);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden'
|
||||
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
|
||||
});
|
||||
if (visible === !blocked) return;
|
||||
visible = !blocked;
|
||||
publish(visible);
|
||||
};
|
||||
const observer = new document.defaultView!.MutationObserver(update);
|
||||
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
|
||||
update();
|
||||
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { TerminalSession, Workspace } from '../../domain/models';
|
||||
import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const latest = useRef({ activeTabId, sessions, workspaces });
|
||||
latest.current = { activeTabId, sessions, workspaces };
|
||||
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
|
||||
const [noteId, setNoteId] = useState('');
|
||||
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
|
||||
useEffect(() => {
|
||||
const api = netcattyBridge.get();
|
||||
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
|
||||
const bridge = {
|
||||
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
|
||||
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
|
||||
respondPluginAIHost: api.respondPluginAIHost.bind(api),
|
||||
};
|
||||
let selectedNoteId: string | null = null;
|
||||
let mounted = true;
|
||||
let chooser: { runtimeId: string; cancel: () => void } | null = null;
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
const finish = (id: string | null) => {
|
||||
if (chooser?.cancel !== cancel) return;
|
||||
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
|
||||
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
|
||||
selectedNoteId = id || null;
|
||||
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
|
||||
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
|
||||
};
|
||||
const cancel = () => finish(null);
|
||||
chooser = { runtimeId, cancel };
|
||||
signal.addEventListener('abort', cancel, { once: true });
|
||||
setNoteId('');
|
||||
// This inventory stays inside trusted host UI. The plugin receives
|
||||
// summaries only for the Note chosen here and current selected scopes.
|
||||
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
|
||||
setChoice({ confirm: (id) => finish(id), cancel });
|
||||
if (signal.aborted) cancel();
|
||||
}),
|
||||
});
|
||||
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
|
||||
if (request.kind === 'request.cancel') {
|
||||
requests.get(String(request.payload.requestId))?.controller.abort();
|
||||
return;
|
||||
}
|
||||
if (request.kind === 'runtime.release') {
|
||||
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
|
||||
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
|
||||
runtime.releaseRuntime(request.runtimeId);
|
||||
return;
|
||||
}
|
||||
if (requests.has(request.requestId) || requests.size >= 64) return;
|
||||
const controller = new AbortController();
|
||||
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
|
||||
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
|
||||
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
|
||||
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {}).finally(() => requests.delete(request.requestId));
|
||||
});
|
||||
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
|
||||
window.addEventListener('pagehide', pageHide);
|
||||
void bridge.setPluginAIHostReady(true).catch(() => {});
|
||||
return () => {
|
||||
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
|
||||
for (const pending of requests.values()) pending.controller.abort();
|
||||
requests.clear(); runtime.shutdown();
|
||||
void bridge.setPluginAIHostReady(false).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
return { choice, noteId, setNoteId, noteChoices };
|
||||
}
|
||||
@@ -77,8 +77,8 @@ test('offline snapshot stays browsable, retry clears warning, and operations bin
|
||||
offline = false;
|
||||
await act(async () => state.retryList());
|
||||
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
|
||||
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
|
||||
await act(async () => state.install(entry.id));
|
||||
assert.deepEqual(installs, [[entry.id, 'second']]);
|
||||
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
|
||||
export function usePluginCatalog(selectedId: string | null) {
|
||||
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
|
||||
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
|
||||
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
|
||||
@@ -12,6 +12,7 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
const [readmeAttempt, setReadmeAttempt] = useState(0);
|
||||
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
|
||||
const installing = useRef(false);
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
@@ -22,12 +23,18 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
|
||||
const list = await bridge.listPluginCatalog();
|
||||
const list = await bridge.listPluginCatalog(sourceId);
|
||||
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
|
||||
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [listAttempt]);
|
||||
}, [listAttempt, sourceId]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSourceBinding(null);
|
||||
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
|
||||
return () => { active = false; };
|
||||
}, [selectedId, sourceId]);
|
||||
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
@@ -38,15 +45,15 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
|
||||
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
|
||||
if (active) setReadmeState({ ...base, status: 'ready', text });
|
||||
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [selectedId, selectionToken, readmeAttempt]);
|
||||
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
|
||||
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
|
||||
return {
|
||||
entries, listStatus, listError, source,
|
||||
entries, listStatus, listError, source, sourceBinding,
|
||||
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
|
||||
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
|
||||
readmeError: currentReadme?.error ?? null,
|
||||
@@ -54,14 +61,19 @@ export function usePluginCatalog(selectedId: string | null) {
|
||||
busy,
|
||||
retryList: () => setListAttempt((attempt) => attempt + 1),
|
||||
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
|
||||
async install(id: string) {
|
||||
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setBusy(true); setInstallError(null);
|
||||
try {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
|
||||
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
|
||||
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
|
||||
await bridge.resetPluginSource(id, sourceBinding.id);
|
||||
setSourceBinding(null);
|
||||
}
|
||||
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
|
||||
finally { installing.current = false; if (mounted.current) setBusy(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 };
|
||||
}
|
||||
@@ -61,9 +61,14 @@ export function usePluginManager() {
|
||||
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
|
||||
return bridge.setPluginEnabled(id, enabled);
|
||||
}),
|
||||
uninstall: (id: string) => run(async (bridge) => {
|
||||
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
|
||||
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
|
||||
return bridge.uninstallPlugin(id);
|
||||
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
|
||||
await bridge.uninstallPlugin(id);
|
||||
if (clearSource && source) {
|
||||
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
|
||||
await bridge.resetPluginSource(id, source.id);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginSources() {
|
||||
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const reload = useCallback(async () => {
|
||||
const values = await netcattyBridge.require().listPluginSources?.();
|
||||
if (!values) throw new Error('Source management unavailable');
|
||||
setSources(values);
|
||||
}, []);
|
||||
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
|
||||
const mutate = async (operation: () => Promise<unknown>) => {
|
||||
setBusy(true); setError(null);
|
||||
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
|
||||
catch (cause) { setError(String(cause)); return false; }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return { sources, error, busy,
|
||||
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
|
||||
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
|
||||
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
resolvePluginViewWindowScope,
|
||||
} from './pluginViewScopes';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
|
||||
|
||||
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
|
||||
|
||||
@@ -307,6 +308,17 @@ export function usePluginViewLifecycle({
|
||||
viewScopeId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance || !mountRef.current) return;
|
||||
let disposed = false;
|
||||
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
|
||||
void setViewVisibility(instance.id, visible).catch(() => {
|
||||
if (!disposed) void closeView(instance.id).catch(() => {});
|
||||
});
|
||||
});
|
||||
return () => { disposed = true; stop(); };
|
||||
}, [closeView, instance, setViewVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance || !mountRef.current) return;
|
||||
const update = () => {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
|
||||
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
|
||||
// evidence. All state is synthetic, with no real provider, key, Note or host.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
|
||||
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('localStorage', dom.window.localStorage);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
|
||||
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
|
||||
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
|
||||
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
let panel: JSDOM | undefined;
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
let locked = false; let contentAllowed = false; let reviewAllowed = false;
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const endpoint = `http://127.0.0.1:${address.port}/v1`;
|
||||
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
|
||||
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
|
||||
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
|
||||
return { ok: status.statusCode === 200, ...status };
|
||||
}
|
||||
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
|
||||
const handler = ipcHandlers.get(channel);
|
||||
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
return handler({ sender }, structuredClone(payload));
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
|
||||
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
|
||||
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
|
||||
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
|
||||
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: reviewAllowed ? 0 : 1 }; } } },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string }) => request.permission === 'context.selected.read' && !contentAllowed
|
||||
? { requestId: request.requestId, decision: 'deny' }
|
||||
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
|
||||
});
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
|
||||
const sample = path.join(rootPath, 'examples/plugins/assistant');
|
||||
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
|
||||
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
|
||||
}
|
||||
};
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'netcattyView', { value: {
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
assert.equal(noteChoice.options.length, 2);
|
||||
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
|
||||
const element = (id: string) => panel!.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent === 'Ready');
|
||||
assert.match(element('model').textContent!, /QA fixture/);
|
||||
const reference = element('context') as HTMLSelectElement;
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
|
||||
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
|
||||
reviewAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
|
||||
} finally {
|
||||
panel?.window.close();
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
|
||||
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
dom.window.close();
|
||||
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
|
||||
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import type { TerminalSession, Workspace } from '../../domain/models';
|
||||
import { usePluginAIContextHost } from '../../application/state/usePluginAIContextHost';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function PluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
|
||||
const { choice, noteId, setNoteId, noteChoices } = usePluginAIContextHost({ sessions, workspaces });
|
||||
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
|
||||
<DialogContent data-host-security-surface="">
|
||||
<DialogHeader><DialogTitle>Choose a reference for the plugin</DialogTitle>
|
||||
<DialogDescription>Share a Note summary and the current selected workspace, tab, session, and host summaries. Reading content requires a separate permission and text review.</DialogDescription></DialogHeader>
|
||||
<label className="text-sm" htmlFor="plugin-ai-note">Note</label>
|
||||
<select id="plugin-ai-note" value={noteId} onChange={(event) => setNoteId(event.target.value)} className="rounded border border-border bg-background p-2 text-foreground">
|
||||
<option value="">No Note</option>
|
||||
{noteChoices.map((note) => <option key={note.id} value={note.id}>{note.title}</option>)}
|
||||
</select>
|
||||
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
|
||||
<Button onClick={() => choice?.confirm(noteId)}>Share selected summaries</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
|
||||
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
|
||||
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
|
||||
} as NetcattyPluginCatalogEntry;
|
||||
const catalog = { entries: [entry], source: 'registry', listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
|
||||
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
|
||||
function Browser() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
|
||||
@@ -104,8 +104,28 @@ test('information previews on hover/focus, pins on click and Escape returns focu
|
||||
await act(async () => { healthy.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
|
||||
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
|
||||
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
|
||||
await act(async () => { thirdStatus.focus(); await settle(); });
|
||||
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
|
||||
assert.match(thirdPreview, /provided by the selected third party/);
|
||||
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
|
||||
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
|
||||
assert.equal(document.querySelector('[role="tooltip"] button'), null);
|
||||
await act(async () => { thirdStatus.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
|
||||
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
|
||||
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
|
||||
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
|
||||
await act(async () => { sourceInfo.click(); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
|
||||
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
|
||||
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
|
||||
|
||||
@@ -1,66 +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', '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 checkboxes = () => [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')];
|
||||
try {
|
||||
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
|
||||
assert.equal(checkboxes()[0].checked, false);
|
||||
assert.equal(checkboxes()[0].disabled, false, 'StrictMode initialization completes');
|
||||
assert.equal(checkboxes()[1].disabled, true);
|
||||
assert.match(document.body.textContent!, /no upload or sync/);
|
||||
assert.match(document.body.textContent!, /Dropped: 2/);
|
||||
await act(async () => checkboxes()[0].click());
|
||||
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
|
||||
await act(async () => checkboxes()[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('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 () => checkboxes()[0].click());
|
||||
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
|
||||
assert.equal(checkboxes()[0].checked, false, 'failed configuration refreshes actual OFF host state');
|
||||
assert.equal(checkboxes()[1].checked, false);
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
|
||||
} 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,31 +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';
|
||||
|
||||
/** Embed in the existing plugin settings surface, without a new marketplace tab. */
|
||||
export function PluginLogSettings() {
|
||||
const { resolvedLocale } = useI18n();
|
||||
const zh = resolvedLocale.startsWith('zh');
|
||||
const { status, error, busy, run } = usePluginLogs();
|
||||
const [confirmVerbose, setConfirmVerbose] = useState(false);
|
||||
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 <section data-host-security-surface="" className="space-y-3 rounded-lg border border-border/70 p-4 text-sm" aria-label={zh ? '本地插件日志' : 'Local plugin logs'}>
|
||||
<h4 className="font-medium">{zh ? '本地插件日志' : 'Local plugin logs'}</h4>
|
||||
<p className="text-xs text-muted-foreground">{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.'}</p>
|
||||
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(event) => void run('configure', { saveToDisk: event.target.checked, verbose: false })} />{zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally'}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(event) => event.target.checked ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />{zh ? '包含插件详细诊断文字(可能含敏感数据)' : 'Include verbose plugin text (may contain sensitive data)'}</label>
|
||||
{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>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
|
||||
</div>
|
||||
<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 }); }} />
|
||||
</section>;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
|
||||
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
|
||||
})) as NetcattyPluginCatalogEntry[];
|
||||
const catalog = {
|
||||
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
|
||||
} as ReturnType<typeof usePluginCatalog>;
|
||||
|
||||
@@ -42,7 +42,7 @@ test('catalog keeps search during selection/back, switches detail, and displays
|
||||
function Harness() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
|
||||
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
|
||||
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' }, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Harness />); });
|
||||
@@ -158,7 +158,21 @@ test('replacing an installed plugin discloses that its running version will stop
|
||||
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
|
||||
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
|
||||
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true } as NetcattyInstalledPlugin]} /></I18nProvider>);
|
||||
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin]} /></I18nProvider>);
|
||||
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
|
||||
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
|
||||
});
|
||||
|
||||
test('a mirror cannot claim another source installation or offer cross-source replacement', () => {
|
||||
const mirror = { id: 'third-party:mirror', kind: 'third-party' as const, name: 'Netcatty', origin: 'https://mirror.example.com' };
|
||||
const existing = { id: entries[0].id, activeVersion: '1.0.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin;
|
||||
const list = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog}
|
||||
marketSource={mirror} selected={null} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
|
||||
assert.doesNotMatch(list, />Installed</);
|
||||
const downloadable = { ...entries[0], artifact: { url: `${mirror.origin}/assets/${'a'.repeat(64)}.ncpkg`, sha256: 'a'.repeat(64) } };
|
||||
const detail = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, entries: [downloadable] }}
|
||||
marketSource={mirror} selected={downloadable.id} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
|
||||
assert.match(detail, /installed from another source/);
|
||||
assert.match(detail, /https:\/\/plugins.netcatty.app/);
|
||||
assert.match(detail, /disabled=""[^>]*>Install this version/);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PluginStatus } from './PluginStatus';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
|
||||
import { PluginInfo } from './PluginInfo';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
@@ -11,14 +12,15 @@ import { Button } from '../../ui/button';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { PluginReadme } from './PluginReadme';
|
||||
|
||||
export function PluginMarketplace({ installed = [], toolbarContainer, noVault }: { noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const catalog = usePluginCatalog(selected);
|
||||
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
|
||||
const catalog = usePluginCatalog(selected, source?.id);
|
||||
return <PluginMarketplaceContent catalog={catalog} marketSource={source} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
|
||||
}
|
||||
|
||||
// The browser owns only presentation state; installation and README policy stay in the existing hook.
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault }: {
|
||||
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
|
||||
marketSource?: NetcattyPluginSource;
|
||||
noVault?: boolean;
|
||||
toolbarContainer?: HTMLElement | null;
|
||||
catalog: ReturnType<typeof usePluginCatalog>;
|
||||
@@ -28,13 +30,19 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState('');
|
||||
const [confirmInstall, setConfirmInstall] = useState(false);
|
||||
const [clearPreviousSource, setClearPreviousSource] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const detailRef = useRef<HTMLHeadingElement>(null);
|
||||
const installButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const lastSelection = useRef<string | null>(null);
|
||||
const pageScroll = useRef(0);
|
||||
const entry = catalog.entries.find((item) => item.id === selected);
|
||||
const filtered = filterPluginCatalog(catalog.entries, search);
|
||||
const installedEntry = installed.find((item) => item.id === entry?.id);
|
||||
const sourceId = marketSource?.id ?? 'official';
|
||||
const sameSource = (plugin: NetcattyInstalledPlugin) => (plugin.source?.id ?? 'local') === sourceId;
|
||||
const installedEntry = installed.find((item) => item.id === entry?.id && sameSource(item));
|
||||
const conflictingInstall = installed.find((item) => item.id === entry?.id && !sameSource(item));
|
||||
const choose = (id: string) => {
|
||||
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
|
||||
pageScroll.current = pane?.scrollTop ?? 0;
|
||||
@@ -59,7 +67,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
};
|
||||
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
|
||||
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
|
||||
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
|
||||
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
|
||||
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
|
||||
<div className="plugin-marketplace-browser">
|
||||
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
|
||||
@@ -70,7 +78,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
|
||||
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
|
||||
<div className="plugin-marketplace-grid">{filtered.map((item) => {
|
||||
const isInstalled = installed.some((plugin) => plugin.id === item.id);
|
||||
const isInstalled = installed.some((plugin) => plugin.id === item.id && sameSource(plugin));
|
||||
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
|
||||
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
|
||||
@@ -89,7 +97,8 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
|
||||
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
|
||||
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
|
||||
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
|
||||
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
|
||||
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
|
||||
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
|
||||
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
|
||||
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
|
||||
@@ -97,6 +106,13 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
</div>
|
||||
</article>}
|
||||
</div>
|
||||
<Dialog open={confirmInstall && Boolean(entry)} onOpenChange={setConfirmInstall}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); installButtonRef.current?.focus(); }}>
|
||||
<DialogTitle>{t('settings.plugins.sources.installConfirm')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
|
||||
<p className="break-all text-sm font-medium">{marketSource?.origin}</p>
|
||||
{entry && <PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, source: marketSource, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />}
|
||||
{catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && <label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearPreviousSource} onChange={(event) => setClearPreviousSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}<span className="break-all">{catalog.sourceBinding.origin ?? t('settings.plugins.sources.local')}</span></label>}
|
||||
<Button disabled={catalog.busy || Boolean(catalog.listError) || Boolean(catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && !clearPreviousSource)} onClick={() => { if (entry) void catalog.install(entry.id, marketSource?.id, clearPreviousSource).then(() => setConfirmInstall(false)); }}>{t('settings.plugins.market.install')}</Button>
|
||||
</DialogContent></Dialog>
|
||||
</section>;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('');
|
||||
const rootRef = useRef<HTMLElement>(null);
|
||||
const scrollTop = useRef(0);
|
||||
const lastSelection = useRef<string | null>(null);
|
||||
@@ -72,11 +73,13 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
|
||||
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [clearSource, setClearSource] = useState(false);
|
||||
if (!manager.available) return null;
|
||||
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
|
||||
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
@@ -99,6 +102,7 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
|
||||
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
|
||||
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
@@ -111,9 +115,10 @@ export function PluginPackageManager({ manager, onSelectionChange }: { manager:
|
||||
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
|
||||
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
|
||||
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
|
||||
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
|
||||
</>}
|
||||
</article>)}
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { PluginInfo } from './PluginInfo';
|
||||
|
||||
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
|
||||
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
|
||||
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
|
||||
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
|
||||
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
|
||||
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
|
||||
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
|
||||
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
|
||||
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('source management requires consent and Escape restores either opener inside the plugin window', 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', '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 a synthetic preload surface on the isolated DOM window.
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: { listPluginSources: async () => [] }, configurable: true });
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { PluginThirdPartyMarket } = await import('./PluginThirdPartyMarket');
|
||||
const { PluginManagementDialog } = await import('./PluginManagementDialog');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginThirdPartyMarket installed={[]} /></PluginManagementDialog></I18nProvider>));
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
|
||||
for (const label of ['Manage sources', 'Add source']) {
|
||||
const opener = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === label)!;
|
||||
assert.ok(opener);
|
||||
await act(async () => { opener.focus(); opener.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]');
|
||||
const dialog = dialogs[dialogs.length - 1];
|
||||
assert.match(dialog.textContent ?? '', /real domain/);
|
||||
assert.equal(dialog.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
|
||||
assert.equal(dialog.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled, true);
|
||||
await act(async () => { dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(document.activeElement, opener);
|
||||
}
|
||||
} finally {
|
||||
// Radix schedules unmount autofocus on a timer. Drain it while the DOM's
|
||||
// Event constructor is still installed (Node 22 has a distinct global Event).
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { usePluginSources } from '../../../application/state/usePluginSources';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
|
||||
import { PluginMarketplace } from './PluginMarketplace';
|
||||
|
||||
export function PluginThirdPartyMarket({ installed, toolbarContainer, noVault }: {
|
||||
installed: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null; noVault?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const model = usePluginSources();
|
||||
const [selected, setSelected] = useState('');
|
||||
const [visited, setVisited] = useState<string[]>([]);
|
||||
const [managing, setManaging] = useState(false);
|
||||
const opener = useRef<HTMLButtonElement | null>(null);
|
||||
const openManager = (event: React.MouseEvent<HTMLButtonElement>) => { opener.current = event.currentTarget; setManaging(true); };
|
||||
const [name, setName] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [disablePlugins, setDisablePlugins] = useState(false);
|
||||
const sources = model.sources.filter((source) => source.kind === 'third-party');
|
||||
const source = sources.find((item) => item.id === selected && item.enabled);
|
||||
let domain = '';
|
||||
try { domain = new URL(address).host; } catch { /* Host validates before saving. */ }
|
||||
return <div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select aria-label={t('settings.plugins.sources.select')} value={selected} onChange={(event) => { setSelected(event.target.value); setVisited((items) => [...new Set([...items, event.target.value])]); }} className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm">
|
||||
<option value="">{t('settings.plugins.sources.select')}</option>
|
||||
{sources.filter((item) => item.enabled).map((item) => <option key={item.id} value={item.id}>{item.name} — {new URL(item.origin!).hostname}</option>)}
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={openManager}>{t('settings.plugins.sources.manage')}</Button>
|
||||
</div>
|
||||
{!source && <div className="py-8 text-center text-sm text-muted-foreground"><p>{t('settings.plugins.sources.empty')}</p><Button className="mt-3" variant="outline" onClick={openManager}>{t('settings.plugins.sources.add')}</Button></div>}
|
||||
{/* Keep each visited source mounted to preserve its own selection/search/scroll. */}
|
||||
{sources.filter((item) => item.enabled && (item.id === selected || visited.includes(item.id))).map((item) => {
|
||||
return <div key={item.id} hidden={item.id !== source?.id}><PluginMarketplace source={item} installed={installed} toolbarContainer={item.id === source?.id ? toolbarContainer : undefined} noVault={noVault} /></div>;
|
||||
})}
|
||||
<Dialog open={managing} onOpenChange={setManaging}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus(); }}>
|
||||
<DialogTitle>{t('settings.plugins.sources.manage')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
|
||||
{model.error && <p role="alert" className="text-sm text-destructive">{model.error}</p>}
|
||||
<div className="space-y-3">{sources.map((item) => <div key={item.id} className="space-y-2 rounded-lg bg-muted/30 p-3">
|
||||
<Input aria-label={t('settings.plugins.sources.name')} defaultValue={item.name} onBlur={(event) => { if (event.target.value !== item.name) void model.update(item, event.target.value, Boolean(item.enabled)); }} disabled={model.busy} />
|
||||
<p className="break-all text-xs text-muted-foreground">{item.origin}</p>
|
||||
<div className="flex items-center justify-between"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(item.enabled)} disabled={model.busy} onChange={(event) => void model.update(item, item.name, event.target.checked)} />{t('settings.plugins.manager.enabled')}</label><Button variant="ghost" size="sm" disabled={model.busy} onClick={() => { setRemoving(item.id); setDisablePlugins(false); }}>{t('settings.plugins.sources.remove')}</Button></div>
|
||||
{removing === item.id && <div className="space-y-2 text-sm"><p>{t('settings.plugins.sources.removeNote')}</p><label className="flex items-center gap-2"><input type="checkbox" checked={disablePlugins} onChange={(event) => setDisablePlugins(event.target.checked)} />{t('settings.plugins.sources.disablePlugins')}</label><Button size="sm" variant="destructive" disabled={model.busy} onClick={() => void model.remove(item.id, disablePlugins).then((ok) => { if (ok) setRemoving(null); })}>{t('settings.plugins.sources.remove')}</Button></div>}
|
||||
</div>)}</div>
|
||||
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); if (accepted) void model.add(name, address).then((ok) => { if (ok) { setName(''); setAddress(''); setAccepted(false); } }); }}>
|
||||
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t('settings.plugins.sources.name')} aria-label={t('settings.plugins.sources.name')} />
|
||||
<Input required type="url" value={address} onChange={(event) => { setAddress(event.target.value); setAccepted(false); }} placeholder="https://plugins.example.com" aria-label={t('settings.plugins.sources.address')} />
|
||||
{domain && <p className="break-all text-sm font-medium">{domain}</p>}
|
||||
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={accepted} onChange={(event) => setAccepted(event.target.checked)} />{t('settings.plugins.sources.accept')}</label>
|
||||
<Button type="submit" disabled={model.busy || !accepted}>{t('settings.plugins.sources.add')}</Button>
|
||||
</form>
|
||||
</DialogContent></Dialog>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
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 { PluginLogSettings } from './PluginLogSettings';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
|
||||
@@ -290,7 +290,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
const manager = usePluginManager();
|
||||
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
|
||||
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
|
||||
const availableFonts = useAvailableFonts();
|
||||
const scopeCatalog = usePluginSettingScopeCatalog();
|
||||
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
|
||||
@@ -311,11 +311,12 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} 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>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
|
||||
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
|
||||
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
|
||||
@@ -323,7 +324,8 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
|
||||
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={catalogToolbar} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
|
||||
<TabsContent value="installed" className="m-0 space-y-6">
|
||||
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
|
||||
{contextualScopes.size > 0 && (
|
||||
@@ -401,7 +403,6 @@ export default function SettingsPluginsTab() {
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
|
||||
</div>
|
||||
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
|
||||
<PluginLogSettings />
|
||||
</SettingsAnchor>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Plugin AI and selected context: implementation checkpoint
|
||||
|
||||
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
Branch: `feat/plugin-ai-context`.
|
||||
Status: source-bound services, default trusted main/renderer adapters, app preload,
|
||||
the AppView host chooser, canonical private IPC types, and an installable minimal
|
||||
example are connected. Complete independent review and native acceptance remain
|
||||
pending. This source is not ready to merge or release.
|
||||
|
||||
## Public boundaries
|
||||
|
||||
The parent approved `ai.model-invocation` and `context.selected-read`. The
|
||||
transport task owns the public schema, SDK, and runtime peer. Its
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
|
||||
routes and is merged into this branch. The parent assigned this task the additive
|
||||
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
|
||||
|
||||
The 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 through the registered SDK
|
||||
and runtime routes.
|
||||
|
||||
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` |
|
||||
| This task | Additive trusted-host IPC types and generation, app preload methods/types, AppView host mount, AI-only hostService adapter construction/registration |
|
||||
| This task | Host security-dialog visibility hook in the modal/tab plugin view lifecycle |
|
||||
| This task, reassigned by parent | Four permission descriptions and the bounded `network.stream` contract test expectation |
|
||||
| Transport task | Public canonical schema, generated SDK, runtime forwarding, generic stream registration |
|
||||
| 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
|
||||
|
||||
`PluginAISourceBinding` authenticates the current browser runtime before reading
|
||||
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
|
||||
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
|
||||
database and sync implementation are unchanged. Source origin does not imply
|
||||
that a package is officially maintained.
|
||||
|
||||
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
|
||||
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
|
||||
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. The main
|
||||
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
|
||||
total deadline. Host-owned request tags allow sender/runtime cancellation
|
||||
without cancelling the internal Agent's ordinary streams. 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.
|
||||
|
||||
## Connected host path
|
||||
|
||||
`createPluginHostService` constructs `PluginAITrustedHostAdapter` by default from
|
||||
the existing interaction window, app-lock subscription, and native dialog. It
|
||||
registers the trusted ready/response IPC handlers. App preload exposes the request
|
||||
subscription and validated responses only to the trusted application renderer;
|
||||
the sandbox view preload does not expose these methods.
|
||||
|
||||
`AppView` mounts `PluginAIContextHost`. Its hook chooses the currently configured
|
||||
host model and opens an app-owned Note chooser. `trustedHostRuntime` keeps the
|
||||
provider adapter and selected readers in renderer-local maps; only canonical,
|
||||
validated request/results cross the main/renderer IPC boundary. The default
|
||||
adapter authenticates the current main-window sender, uses one-use nonces,
|
||||
rechecks the current owner after awaits, and invalidates on lock, navigation,
|
||||
hide, destruction, or renderer replacement. Plugin permission revocation,
|
||||
disable, and runtime release abort that runtime's turns and transport requests.
|
||||
|
||||
Summary enumeration, content reads, and model invocation require separate
|
||||
permissions. Before selected text becomes model input, a native host dialog
|
||||
shows that exact text and version with Cancel as the default. Model invocation
|
||||
permission grants neither context nor action access. Menu IDs are never data
|
||||
authority. While the app-owned chooser is present, the active native modal/tab
|
||||
plugin view yields so it cannot cover the chooser. Existing embedded views use
|
||||
their host-overlay occlusion observer. Native stacking still requires desktop QA.
|
||||
|
||||
The selected-context authority is read-only text. Its constructor and lifecycle
|
||||
API are documented in [Selected context authority](selected-context-authority.md).
|
||||
Host metadata handles cannot be promoted into edit/connect authority; those
|
||||
actions require separate typed host targets, grants, and confirmations.
|
||||
|
||||
## 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 installed assistant command test passes on this branch's ordinary merge with
|
||||
transport head `c4433d496`. It installs and
|
||||
activates the six-file sample through the actual package manager and runtime peer,
|
||||
then checks canonical SDK/RPC, independent model/content denial, text-review
|
||||
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
|
||||
The in-process browser harness is a Node test transport, not native sandbox proof.
|
||||
|
||||
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
|
||||
app preload factory, mounted host chooser, installed sample command/runtime peer,
|
||||
existing provider factory, and existing main HTTP transport to the loopback
|
||||
fixture. It checks content denial, review cancellation, streaming usage, Stop,
|
||||
lock revocation, and absence of keys/configuration/policy on the trusted wire.
|
||||
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
|
||||
and teardown without restoring an old view. DOM tests do not establish native
|
||||
WebContentsView stacking or Chromium sandbox behavior.
|
||||
|
||||
The trusted main/renderer suites cover nonce replay, sender substitution, owner
|
||||
replacement, navigation, hide, runtime release, lock, cancellation, focus recovery,
|
||||
safe field projection, and late selected-content results. The main transport test
|
||||
also verifies targeted plugin cancellation leaves an ordinary synthetic Agent
|
||||
stream running and aborts an oversized plugin stream.
|
||||
|
||||
Failed aggregate runs remain evidence, not passing acceptance records:
|
||||
|
||||
| Log in `/tmp` | Result |
|
||||
| --- | --- |
|
||||
| `netcatty-ai-qa-tests-full.log` | 14,912 tests: 14,783 passed, 85 failed, 1 cancelled, 43 skipped; a stuck FIDO child was terminated |
|
||||
| `netcatty-ai-final-tests.log` | After standard dependency patches: 15,246 tests, 15,192 passed, 11 failed, 43 skipped |
|
||||
| `netcatty-ai-final-runtime.log` | 610 tests: 607 passed, 1 failed, 2 skipped |
|
||||
| `netcatty-ai-connected-final-tests.log` | Connected code `42a982660`: 15,248 tests, 15,204 passed, 1 failed, 0 cancelled, 43 skipped |
|
||||
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
|
||||
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
|
||||
|
||||
The ten Cursor failures in the second full run came from its missing SQLite
|
||||
native dependency. Rebuilding the existing dependency with the standard
|
||||
`node-gyp --devdir` option repaired them; the focused rerun passed 42 with 3
|
||||
skipped. The sole connected full-run failure was a test expectation missing
|
||||
`network.stream` after `network`. The runtime failure was permission-copy coverage
|
||||
missing descriptions for `ai.invoke`, `context.selected.list`,
|
||||
`context.selected.read`, and `network.stream`. The parent reassigned these two
|
||||
hunks here. Commit `0ba3ea606988e95df256d449395adb7190b46fa0` adds accurate English
|
||||
and Chinese descriptions and updates the resource-scope test. The test checks
|
||||
both bounded optional stream declarations and rejection of the unbounded stream
|
||||
shorthand. Transport can consume this isolated two-file commit.
|
||||
|
||||
The permission-copy test, complete 88-test plugin contract/SDK/CLI suite, and
|
||||
610-test runtime aggregate now pass (with 2 existing runtime skips). The earlier
|
||||
whole-repository red totals remain historical records; no passing whole-repository
|
||||
rerun is claimed from these focused repairs. Exact-head CI is recorded on the PR.
|
||||
|
||||
The final connected UI/visibility run passes all 3 tests. The 47 focused
|
||||
service/preload/runtime checks and 7 connected provider/renderer checks also pass.
|
||||
`npm run check:plugin-contract` passes. The six-file package validates and retains
|
||||
the SHA-256 recorded in the native recipe.
|
||||
|
||||
The connected AppView production build passes. Full lint after the permission
|
||||
repair reports no errors and
|
||||
seven existing warnings. The repository typecheck remains red with 773 diagnostics;
|
||||
the new adapter, component, hook, and visibility modules have no diagnostics. Native packaging's
|
||||
standard Electron rebuild stops at its hardcoded `/home/agent/.electron-gyp`
|
||||
cache outside this executor's writable roots. No dependency patch or home-directory
|
||||
override was used to bypass it.
|
||||
|
||||
The package build validates six files. The final artifact and native procedure
|
||||
are recorded in [the native QA recipe](assistant-native-qa.md). The source staging
|
||||
excludes build tooling and has no Node entry, network, filesystem, secret, storage,
|
||||
or terminal permission.
|
||||
|
||||
Independent review confirmed the malformed-reference revocation defect on
|
||||
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
|
||||
The repair is retained in #3985 head `4882e0501`. Further independent review was
|
||||
blocked by the review tool/model restriction. That review is incomplete and has
|
||||
not been retried through another route. CI is not a substitute for that review.
|
||||
|
||||
Native Electron launch on this executor aborts because `chrome-sandbox` is not
|
||||
root-owned mode 4755. No sandbox bypass or native retry was attempted. Native
|
||||
screenshots, video, and installed UI acceptance belong to the parent's working
|
||||
cloud GUI machine on the integrated exact head. No native acceptance is claimed here.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Test the selected Note assistant on an isolated desktop
|
||||
|
||||
This recipe tests a sandbox plugin against a deterministic loopback provider.
|
||||
The provider does not run a model or contact a paid service. The profile contains
|
||||
only synthetic data. The parent owns native screenshots and video on the working
|
||||
cloud GUI machine. The local executor's sandbox-helper failure remains a separate
|
||||
failed launch record.
|
||||
|
||||
## Prepare the integrated head
|
||||
|
||||
Use a head containing the market source API, #3985's malformed-context repair,
|
||||
#3986, and the transport task's canonical AI/context registration. Record the exact
|
||||
commit and artifact hashes. Keep all PRs Draft. Do not merge or release them.
|
||||
|
||||
Run the build and tests with the repository's supported Node version:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD
|
||||
npm run build
|
||||
npm run build:plugin-runtime-deps
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant-example.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/assistant-example.ncpkg
|
||||
sha256sum /tmp/assistant-example.ncpkg
|
||||
```
|
||||
|
||||
The package contains six files: its manifest, README, three panel files, and one
|
||||
browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
Start `node scripts/plugin-assistant-provider-fixture.mjs` from the repository.
|
||||
The server prints its loopback URL. It listens only on `127.0.0.1`; use a fresh
|
||||
unused port. Its response is fixed synthetic text, split into delayed SSE chunks.
|
||||
The response includes fixed usage metadata. It accepts no request tools or system
|
||||
messages. It keeps only request counts and prints no request content or headers.
|
||||
|
||||
Launch the exact integrated app on the working desktop with a fresh disposable
|
||||
user-data directory. Preserve Chromium sandboxing. Do not use a real app profile,
|
||||
cloud account, credential, host, Note, or chat history.
|
||||
|
||||
On that working desktop, use a separate shell for each long-running process:
|
||||
|
||||
```sh
|
||||
node scripts/plugin-assistant-provider-fixture.mjs --port 9217
|
||||
```
|
||||
|
||||
```sh
|
||||
netcattyAssistantQaProfile=$(mktemp -d /tmp/netcatty-assistant-qa.XXXXXX)
|
||||
env -u ELECTRON_RUN_AS_NODE node node_modules/electron/cli.js . --user-data-dir="$netcattyAssistantQaProfile"
|
||||
```
|
||||
|
||||
The fixture's counts are available from `http://127.0.0.1:9217/fixture/status`.
|
||||
Use a different unused loopback port if 9217 is occupied and update the provider
|
||||
URL accordingly. These launch commands are for the working GUI machine; they do
|
||||
not override this executor's failed sandbox-helper check.
|
||||
|
||||
In AI Settings, create a custom OpenAI-compatible provider with the fixture's
|
||||
`/v1` URL, model `deterministic-fixture`, and the literal synthetic credential marker
|
||||
`fixture-only`. This marker exercises the existing credential injection path
|
||||
without a real API credential. Select that
|
||||
provider and model through the host UI. Do not enable an external Agent or tools.
|
||||
|
||||
Create one Note named `Synthetic retry plan` containing:
|
||||
|
||||
```text
|
||||
Synthetic retry plan: add a retry button and show a connection status label.
|
||||
```
|
||||
|
||||
## Record the installed-plugin path
|
||||
|
||||
1. Import the package. Record its ID `com.netcatty.assistant`, version `0.1.0`,
|
||||
source, SHA-256, browser runtime, required permissions, and optional permissions.
|
||||
Installation must not grant optional access. Source labels do not imply that
|
||||
an author or package is officially maintained.
|
||||
2. Enable the plugin and open its `Selected Note Assistant` modal from the plugin
|
||||
view catalog. Allow only its required view and command registration permissions.
|
||||
3. Grant `ai.invoke` and `context.selected.list` when the host prompts. Record the
|
||||
host-selected model label. The host's Note chooser must show app-owned chrome.
|
||||
Verify the native plugin modal yields while this chooser is open and restores
|
||||
after dismissal; the plugin must not cover the host's selection controls.
|
||||
Select only `Synthetic retry plan` and click **Share selected summaries**.
|
||||
4. Verify that the plugin reference dropdown starts at **No reference**. Select
|
||||
`Synthetic retry plan` explicitly, then click **Send**.
|
||||
5. Deny `context.selected.read`. No fixture request may be sent. The optional
|
||||
model permission must not authorize Note access.
|
||||
6. Retry. Allow the single selected-context read. The separate host text dialog
|
||||
must show the exact synthetic text, scope, and version, with **Cancel** selected
|
||||
by default. Cancel once and confirm that no fixture request is sent.
|
||||
7. Retry and click **Share this text**. Record streamed text, completion, and token
|
||||
usage. Capture the full host window, not only the plugin iframe.
|
||||
8. Click **Send** again, share the same synthetic text, and click **Stop** while
|
||||
the delayed fixture is streaming. The text must stop changing; the fixture's
|
||||
aborted-connection counter must increase. Record this sequence as video.
|
||||
9. Change the Note during a pending review and attempt to share the old dialog.
|
||||
The expected-version check must reject the result. Do not send stale content.
|
||||
10. Revoke the plugin's AI/context permission while a turn is active. Disable,
|
||||
re-enable, and close its view. Old handles and late results must not resume.
|
||||
Lock/unlock the app and navigate or reload the main window in the isolated
|
||||
profile. Record a fresh successful run after reauthorization.
|
||||
11. Keep the fixture request counter and aborted-connection counter in the
|
||||
evidence. Do not include prompt bodies, headers, credentials, or Note content
|
||||
other than the synthetic text above in logs.
|
||||
|
||||
The automated suites cover source substitution, runtime identity, two-handle
|
||||
revocation after malformed input, late replies, byte/token/time bounds, and
|
||||
no-key serialization. Native execution must still verify the real prompts,
|
||||
installed runtime, configured provider transport, and UI lifecycle.
|
||||
|
||||
## Record remaining limits
|
||||
|
||||
Independent review found the malformed-context revocation bug. The regression
|
||||
reproduced and the repair passed focused tests. Further independent review was
|
||||
blocked by the review tool/model restriction. That review remains incomplete and
|
||||
has not been retried through another route. CI and native evidence do not replace
|
||||
that missing review.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Byte stream acceptance evidence
|
||||
|
||||
This is the historical report published by the transport owner at #3988
|
||||
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its local failures, package hash,
|
||||
and missing AI wiring describe that branch and environment. The current owner
|
||||
is #3993 on `integration/plugin-byte-stream-handoff`. Follow
|
||||
[the integration handoff](byte-stream-native-qa.md) for current checks, the
|
||||
connected AI checkpoint, and native prerequisites. The old GUI-local Docker
|
||||
recipe is not applicable to the occupied GUI machine without Docker.
|
||||
|
||||
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
|
||||
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
|
||||
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
The transport delta does not edit the source database or sync implementation.
|
||||
Draft PR #3988 records the published review head. No merge or release is authorized.
|
||||
|
||||
## Completed local checks
|
||||
|
||||
The delegated environment uses Node 24.19.0 on Linux.
|
||||
|
||||
- `npm run test:plugin-contract`: 90 passed, zero skipped.
|
||||
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
|
||||
Database-specific testing is now entirely in the example directory.
|
||||
- Final focused transport, source-isolation, installed entrypoint and disposable
|
||||
database run before the test-file move: 32 passed, zero skipped. After the
|
||||
move, the same installed database test passed again at its example path.
|
||||
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
|
||||
real MariaDB result and server-side INSERT denial passed through both the
|
||||
core and the ordinary installed example entrypoint. No host ports, fixture
|
||||
override, real hosts, or real credentials were used by this database test.
|
||||
Its container, network, package and profile were removed.
|
||||
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
|
||||
clearing before dispatch and safe text rendering passed.
|
||||
- Production `npm run build`, generated contract drift, changed-source lint,
|
||||
and whitespace checks passed.
|
||||
|
||||
Real broker TLS tests validate trust and the original requested hostname using
|
||||
a disposable fixture CA. Database traffic is plain TCP inside an internal
|
||||
Docker network. Neither check proves native BrowserWindow sandbox behavior,
|
||||
database TLS, device testing, or real model inference. The registered AI/context
|
||||
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
|
||||
wiring owned by the AI task.
|
||||
|
||||
The EOF and last-await cancellation regressions each failed before their fix
|
||||
and passed after it with real loopback sockets. Source changes before grant
|
||||
publication, source changes during a pending read, and a stream returned after
|
||||
runtime release have separate passing regressions.
|
||||
|
||||
## Reproducible native sample
|
||||
|
||||
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
|
||||
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
|
||||
Two separate staging directories produced the same five-file package:
|
||||
|
||||
- Archive bytes: 11,621.
|
||||
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
|
||||
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
|
||||
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
|
||||
|
||||
The artifact contains no credentials or server setup. Changing its approved
|
||||
fixture endpoint requires a newly prepared package and a recorded new hash.
|
||||
|
||||
## Checks that did not pass locally
|
||||
|
||||
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
|
||||
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
|
||||
Electron build caches under `/workspace` passed that step, then failed while
|
||||
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
|
||||
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
|
||||
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
|
||||
directory packaging remains failed locally and needs the normal CI or native
|
||||
QA build environment.
|
||||
|
||||
The broad `npm test` run reported failures in existing transfer-resume,
|
||||
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
|
||||
tests, then stalled without a final aggregate. Its owned process tree was
|
||||
terminated. It is not reported as a passing full test run.
|
||||
|
||||
An independent source-baseline worktree at `4213d8e` ran five affected files in
|
||||
the same environment with shared dependencies: 145 tests, 118 passed,
|
||||
24 failed, three skipped. It reproduced failures in transfer-resume,
|
||||
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
|
||||
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
|
||||
This diagnoses that tested subset only. It does not classify every broad-suite
|
||||
failure, replace exact-head CI, or make any failed result successful.
|
||||
|
||||
## Pending acceptance
|
||||
|
||||
Exact published-head CI and independent review remain pending. The parent owns
|
||||
sequential native GUI acceptance for streams and the database example, then AI,
|
||||
then logging integration. The native recipe covers ordinary install/source and
|
||||
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
|
||||
password, audit inspection, screenshots, and a short recording. No native GUI
|
||||
run or screenshot/video result is claimed by this delegated executor.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
The original transport checkpoint is #3988
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The separate integration branch
|
||||
`integration/plugin-byte-stream-handoff` preserves that history and ordinarily
|
||||
merges source identity #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
|
||||
and connected AI #3986 `173ef5abbb1f8cea4e2c409c5791186926b778e1`.
|
||||
The original transport branch remains untouched. Follow the
|
||||
[current native handoff](byte-stream-native-qa.md) for the integrated package.
|
||||
The late transport checkpoint `f1410f8` is subsequently preserved through an
|
||||
ordinary merge. [The comparison record](late-transport-integration.md) lists its
|
||||
remaining changes and the conflict resolutions that preserve the two P2 fixes
|
||||
and newer connected AI files.
|
||||
|
||||
## 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.
|
||||
|
||||
## Integrated AI interface
|
||||
|
||||
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 connected host renderer and shared permission descriptions from #3986 are
|
||||
preserved. Its owner documents the adapter and existing configured model path in
|
||||
[the AI checkpoint](ai-context-wip.md), with a
|
||||
[synthetic native assistant recipe](assistant-native-qa.md). No further
|
||||
independent review of AI internals was performed. The prior review denial remains
|
||||
unresolved and has not been retried through another route.
|
||||
|
||||
## 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.
|
||||
|
||||
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
|
||||
Its regression mocks interface lookup and uses a fake dialer. It does not retry
|
||||
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
|
||||
|
||||
Cancellation now discards buffered browser bytes, settles producer-cancelled
|
||||
credit once, and retains terminal producer state only until outstanding credit
|
||||
arrives. Existing sequence, schema, and negotiated-window checks still reject
|
||||
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
|
||||
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
|
||||
and runtime shutdown, without another scheduler.
|
||||
|
||||
Normal native BrowserWindow and screenshot/video acceptance remain pending.
|
||||
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
|
||||
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
|
||||
relax endpoint restrictions to obtain that evidence.
|
||||
@@ -0,0 +1,176 @@
|
||||
# Test integrated byte streams on the native desktop
|
||||
|
||||
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
|
||||
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
|
||||
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
|
||||
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
|
||||
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
|
||||
|
||||
## Prepare the desktop and fixture
|
||||
|
||||
Wait until the approved native GUI machine is free from SSH acceptance work.
|
||||
Use a fresh disposable app profile, a working display, and the platform's normal
|
||||
Electron sandbox. Keep the existing endpoint policy and system security settings.
|
||||
|
||||
The database pass requires a disposable MariaDB fixture at a reachable private
|
||||
IPv4 address. The address must not belong to the GUI machine. The current native
|
||||
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
|
||||
already approved separate Docker host or disposable database machine with an
|
||||
existing permitted network path. Do not install new software or create local
|
||||
forwarding exceptions as part of this recipe. If no fixture is available, record
|
||||
the database pass as pending.
|
||||
|
||||
There is also a separately labeled
|
||||
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
|
||||
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
|
||||
It has only been prepared and checked offline. The parent must obtain exact
|
||||
package and endpoint authorization before any install, permission activation,
|
||||
or connection. Its authentication and TLS requirements remain unverified. This
|
||||
candidate does not replace the owned fixture regression or prove that the GUI
|
||||
can reach the coding container's private database.
|
||||
|
||||
The registered automated fixture uses
|
||||
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
|
||||
It runs on an internal Docker network, publishes no host ports, generates a
|
||||
disposable password, and removes its container and network. On a prepared Linux
|
||||
executor with that recognized image, run:
|
||||
|
||||
```sh
|
||||
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
|
||||
```
|
||||
|
||||
That test exercises the protocol, broker, and installed package's SDK/RPC path
|
||||
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
|
||||
have its operator create only
|
||||
synthetic data and a dedicated SELECT-only account. Run this SQL using the
|
||||
fixture's administration channel, replacing the password placeholder with a
|
||||
fresh disposable password that is never included in screenshots or logs:
|
||||
|
||||
```sql
|
||||
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 '<fresh-disposable-password>';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
```
|
||||
|
||||
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
|
||||
within the approved isolated fixture network. Verify INSERT denial independently
|
||||
through the fixture's client. The plugin intentionally offers only its fixed read.
|
||||
|
||||
## Build and identify the packages
|
||||
|
||||
Use Node 22 or later and the lockfile. Run these commands in sequence:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run test:plugin-runtime
|
||||
npm run test:plugin-contract
|
||||
npm run test:plugin-examples
|
||||
npm run generate:plugin-contract
|
||||
npm run check:plugin-contract
|
||||
npm run generate:capability-tools
|
||||
npm run check:codex-app-server-schema
|
||||
npm run lint
|
||||
npm run build
|
||||
git diff --exit-code
|
||||
```
|
||||
|
||||
For the reproducible packaging-only example, use the fixed synthetic endpoint:
|
||||
|
||||
```sh
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
|
||||
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
|
||||
sha256sum /tmp/database-example.ncpkg
|
||||
```
|
||||
|
||||
The two five-file packages built on Node 24.19.0 with the locked dependencies
|
||||
match byte for byte. The current owned-fixture package SHA-256 is
|
||||
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
|
||||
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
|
||||
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
|
||||
fixture database, query, and opt-in live regression are retained.
|
||||
The six-file assistant package retains SHA-256
|
||||
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
|
||||
|
||||
The preparation tool removes temporary build paths from its minified browser
|
||||
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
|
||||
and record that package's new hash. Preparation does not dial the endpoint.
|
||||
|
||||
Build the assistant and follow its existing
|
||||
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
|
||||
synthetic credential marker, and synthetic Note. No paid model call is required.
|
||||
The AI internals were integrated without another independent review. The earlier
|
||||
review denial remains in effect and is not replaced by these tests.
|
||||
|
||||
## Record the installed browser path
|
||||
|
||||
1. Launch the integrated app on the approved desktop with a new disposable
|
||||
`--user-data-dir`. Capture the full app window and record the exact commit.
|
||||
2. In Settings > Plugins, import the database package. Capture its ID, version,
|
||||
package hash, source identity, browser entry, and exact endpoint permission.
|
||||
The import must leave the plugin disabled and optional access ungranted.
|
||||
3. Enable it and open **Disposable database example**. Deny the first endpoint
|
||||
request. Capture the failure and verify the runtime remains usable.
|
||||
4. Retry with only the disposable fixture credentials and allow the exact
|
||||
endpoint. Capture the one-row result and the source-aware permission prompt.
|
||||
The password field must clear when the request starts.
|
||||
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
|
||||
delay offered by the fixture operator. Do not add a host protocol scheduler
|
||||
or change the query. Confirm the request stops and a fresh read still works.
|
||||
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
|
||||
failure and a new request prompts again. Disable, re-enable, close the view,
|
||||
and restart the app. No old connection or result may resume.
|
||||
7. Record the assistant's denial, exact-text review, streamed result, usage,
|
||||
Stop, and post-revocation recovery through its linked recipe. Use the same
|
||||
integrated head and preserve its separate permission descriptions.
|
||||
8. Remove only the synthetic plugins and profile. Have the fixture operator
|
||||
remove the disposable account, data, container, and network. Stop the synthetic
|
||||
model provider.
|
||||
|
||||
## Record the remaining evidence
|
||||
|
||||
Save the exact head, app platform/version, package hashes, ordered commands and
|
||||
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
|
||||
synthetic Node runtime integration, and actual native sandbox acceptance.
|
||||
|
||||
The address regression uses a mocked `os.networkInterfaces()` result and a fake
|
||||
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
|
||||
regression uses the registered package, host service, SDK, RPC, and real loopback
|
||||
echo fixture under the existing test-only override. This override is unavailable
|
||||
through the production service constructor. Neither test certifies the native
|
||||
BrowserWindow sandbox or a live database pass on the GUI machine.
|
||||
|
||||
The integration executor completed these checks in order on 2026-10-26:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
|
||||
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
|
||||
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
|
||||
| Plugin contract generate and drift check | Passed, no generated changes |
|
||||
| Capability tool generation and drift check | Passed, no generated changes |
|
||||
| Codex App Server schema check | Passed |
|
||||
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
|
||||
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
|
||||
| Database repeat packaging and both package validations | Passed |
|
||||
|
||||
The late transport merge moves the opt-in database test into the example directory.
|
||||
Run its panel and protocol tests explicitly with
|
||||
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
|
||||
still requires explicit opt-in and the native Electron suite remains separate.
|
||||
This executor has Docker, but the pinned MariaDB image
|
||||
is not cached. No image was installed and no new live database run is claimed.
|
||||
Native GUI acceptance remains pending on the occupied desktop. Full repository
|
||||
tests and platform packages run in the integration PR's exact-head CI. Record
|
||||
their final workflow conclusions separately from these local results.
|
||||
|
||||
After the late transport merge and Rfam candidate preparation, the registered
|
||||
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
|
||||
and `npm run test:plugin-examples` has four passed and one Docker skip. The
|
||||
example suite is now registered in CI. Public protocol checks use only synthetic
|
||||
packet bytes and package preparation; no Rfam connection or permission was
|
||||
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
|
||||
head whose two workflows already succeeded.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Reconcile the late transport checkpoint
|
||||
|
||||
The integration owner is #3993, branch
|
||||
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
|
||||
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
|
||||
|
||||
The original transport owner later published #3988 at
|
||||
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
|
||||
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
|
||||
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
|
||||
The source checkpoint is already an ancestor of #3993.
|
||||
|
||||
## Keep the remaining transport changes
|
||||
|
||||
The late delta is not entirely superseded. An ordinary merge retains its
|
||||
published history and these useful changes:
|
||||
|
||||
- Attach cleanup cancels a producer returned after runtime release exactly
|
||||
once. The new regression fails against the old #3993 service and passes
|
||||
with the late service change. The test uses a loopback fixture and a fake
|
||||
stream producer. It does not enumerate real interfaces.
|
||||
- Service tests cover source replacement before permission publication and
|
||||
during a pending read. A router test checks one close notification for idle
|
||||
peer cancellation. The installed entrypoint also checks consumer cancellation.
|
||||
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
|
||||
manifest checks require the corresponding AI/context feature declarations.
|
||||
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
|
||||
- Database-specific testing moves out of `electron/plugins` into the minimal
|
||||
example. The opt-in test also installs its real package and checks the
|
||||
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
|
||||
evidence, not a new live pass on the current integration head.
|
||||
- The example panel test checks password clearing and text-only result output.
|
||||
|
||||
## Resolve overlap in favor of the integrated behavior
|
||||
|
||||
| Overlap | Resolution |
|
||||
| --- | --- |
|
||||
| Marketplace source files and lockfile | Already identical to the late checkpoint |
|
||||
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
|
||||
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
|
||||
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
|
||||
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
|
||||
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
|
||||
| Assistant preparation | Retain the newer six-file staging build |
|
||||
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
|
||||
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
|
||||
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
|
||||
|
||||
The original reports are retained in
|
||||
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
|
||||
AI wiring status nor their environment-specific broad-test failures replace the
|
||||
connected implementation and exact-head CI evidence on #3993. No main merge,
|
||||
force-push, endpoint-policy relaxation, AI internal review retry, or release is
|
||||
part of this reconciliation.
|
||||
|
||||
## Check the reconciled source
|
||||
|
||||
The merged transport source passed 627 registered runtime tests: 626 passed and
|
||||
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
|
||||
example panel passed; its opt-in database test remained skipped. The late attach
|
||||
regression failed against the old #3993 service with two cancel calls instead
|
||||
of one, then passed after the merge.
|
||||
|
||||
A subsequent example-only Rfam candidate reuses the limited browser protocol
|
||||
through two fixed entrypoints. Its offline tests check the original owned
|
||||
fixture database/query and the fixed public database/query, plus rejection of
|
||||
an unsupported auth plugin. No general query API is exported and no host
|
||||
database code is introduced. The owned MariaDB live regression remains opt-in.
|
||||
@@ -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,178 +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` is a standalone compact component, mounted by a single import
|
||||
and element in `SettingsPluginsTab`. It adds no marketplace tab and changes no
|
||||
Installed, Official, Third-party, or source-database implementation. When merging
|
||||
with the marketplace branch, preserve this element in the host settings surface.
|
||||
|
||||
`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. Confirm both options are off and no host-v1 files exist.
|
||||
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
|
||||
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
|
||||
host identity, outcomes, target digests, and absence of synthetic secret bodies.
|
||||
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
|
||||
the disclosure, write synthetic text, and verify the unverified record type.
|
||||
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
|
||||
app and verify that local saving remains selected but verbose text is off.
|
||||
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
|
||||
panel's separate security history remains. Turn local saving off.
|
||||
|
||||
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
|
||||
|
||||
```sh
|
||||
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
|
||||
```
|
||||
|
||||
Package: 3,270 bytes. SHA-256:
|
||||
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
|
||||
It declares only `commands` and `menus`; its command-palette entries are under
|
||||
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
|
||||
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
|
||||
to exercise drop accounting. **Test undeclared network denial** must return denial
|
||||
without a prompt or network I/O because the manifest declares no network access.
|
||||
No request body or credential is supplied. Do not grant additional permissions.
|
||||
|
||||
Capture the compact settings card, verbose disclosure and native Clear dialog,
|
||||
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
|
||||
build, a normal local import must log the source and opaque owner returned by its
|
||||
host database. On the older base, both stay explicitly unknown. Do not forge an
|
||||
Official source through fixture metadata. The real-source replacement case is
|
||||
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
|
||||
integration worktree; it uses only temporary synthetic database state.
|
||||
|
||||
The execution environment for this change could not launch Electron: its SUID
|
||||
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
|
||||
any profile. No sandbox-disable retry or system-permission change was attempted.
|
||||
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
|
||||
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
|
||||
system-directory change or packaging retry was attempted.
|
||||
|
||||
Native screenshots and recording therefore remain pending in the parent desktop
|
||||
QA queue. Component interaction tests are separate evidence and do not replace
|
||||
that acceptance.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Market sources (unreleased)
|
||||
|
||||
The plugin manager has Installed, Official marketplace and Third-party marketplace
|
||||
pages. Listing a plugin in the official market does not certify it or establish
|
||||
that Netcatty maintains it. Remote manifests, publisher names, source names and
|
||||
README content cannot create a host-issued official badge. This revision does not
|
||||
issue maintainer badges. README text and images remain author content.
|
||||
|
||||
## Add a source
|
||||
|
||||
Enter a public HTTPS origin, for example `https://plugins.example.com`, or that
|
||||
origin's `/v1/snapshot.json`. Names can change; identity is derived by the host
|
||||
from the canonical origin. Credentials, query strings, fragments, custom ports,
|
||||
IP literals and local hostnames are rejected. The initial third-party transport
|
||||
requires public IPv4 DNS records, pins the checked address to the TLS connection,
|
||||
and rejects private/reserved destinations. IPv6-only sources are not supported
|
||||
in this revision. No cookies, login tokens, redirects or GitHub fallback are used.
|
||||
|
||||
Third-party sources begin empty. A failed request never substitutes the official
|
||||
catalog. Each source has its own in-memory last-known-good snapshot and selection
|
||||
tokens; an offline snapshot is browse-only. A process restart requires a fresh
|
||||
third-party fetch. The official source remains fixed to `plugins.netcatty.app`
|
||||
and retains its bundled source examples when unavailable.
|
||||
|
||||
## Publish a static source
|
||||
|
||||
Use the existing snapshot protocol in the registry project's
|
||||
`docs/STATIC-DISTRIBUTION.md`. Every source
|
||||
serves `/v1/snapshot.json`, pointing to hash/size-bound catalog, index and delivery
|
||||
JSON under `/snapshots/<delivery-sha256>/`. All resources remain on the source's
|
||||
origin under content-addressed `/assets/` paths. Whole-JSON budgets, schema,
|
||||
manifest, compatibility, browser-runtime admission and archive validation still
|
||||
apply. Third-party source consent is not permission consent.
|
||||
|
||||
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
|
||||
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
|
||||
`artifact` can be null or a package reference:
|
||||
|
||||
```json
|
||||
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
|
||||
```
|
||||
|
||||
Packages are at most 32 MiB. The catalog entry must contain
|
||||
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
|
||||
The index's current, non-withdrawn version must exactly equal the catalog entry
|
||||
and use `distribution: "release"`. Source-only entries retain null delivery
|
||||
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
|
||||
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
|
||||
Existing official bootstrap entries are not changed or made installable.
|
||||
|
||||
Before every install, refresh and validate the entire snapshot again. A changed
|
||||
selection, withdrawal, unavailable source, altered hash or size prevents install;
|
||||
there is no fallback to another source or latest release. Packages install disabled.
|
||||
|
||||
## Ownership and changing source
|
||||
|
||||
A public plugin ID can have only one installed source. The package's host install
|
||||
metadata and durable database binding record that source, including across
|
||||
recovery and uninstall. Cross-source install or update is rejected, even when
|
||||
version and package bytes match. Local imports are a separate source.
|
||||
|
||||
Private settings, view state, persistent and in-memory grants, secrets, and cloud
|
||||
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
|
||||
inside the existing per-plugin FK. Code, permissions and source configuration do
|
||||
not participate in cloud sync. Old-source cloud settings may remain orphaned;
|
||||
they cannot hydrate the same plugin ID from a different source. Generic KV still
|
||||
is not cloud-synced. No host/Vault/input migration changes are made.
|
||||
|
||||
To change source, uninstall and explicitly select **clear this source's private
|
||||
data, grants and secrets**. The host serializes shutdown, revocation, private-file
|
||||
cleanup and binding reset. This is destructive and is never selected by default.
|
||||
Then install from the new source and review permissions again. An uninstall that
|
||||
retains data deliberately retains the binding and continues to reject other sources.
|
||||
|
||||
Removing a market keeps its installed plugins and data. Updates stop; optionally
|
||||
disable its plugins. No other market silently takes over. Disabling a source only
|
||||
stops browsing/install/update access; it does not silently revoke existing plugins.
|
||||
|
||||
## Validation limits
|
||||
|
||||
Tests use synthetic source responses and first-party packages. Passing tests is
|
||||
not a sandbox certification. Native UI, real third-party hosting, network
|
||||
availability and multi-device encrypted-sync acceptance must be reported
|
||||
separately. No real credentials or untrusted community code are needed for tests.
|
||||
|
||||
## Source-owner integration
|
||||
|
||||
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
|
||||
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
|
||||
`plugin.source` is host-owned `{id, kind, name, origin}`.
|
||||
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
|
||||
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
|
||||
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
|
||||
request or invent a `pluginId` fallback. Capture owner/source at async operation
|
||||
start and revalidate before committing; follow runtime lifecycle cancellation.
|
||||
|
||||
Plugin database schema 7 atomically converts existing raw local private rows and
|
||||
KV keys to the same encoded-owner model without deleting them. This is a one-time
|
||||
conversion, not a parallel runtime compatibility path. A conflicting conversion
|
||||
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
|
||||
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
|
||||
@@ -1,56 +1,84 @@
|
||||
# Official registry client
|
||||
# Official static distribution client
|
||||
|
||||
The Community page refreshes the fixed endpoint
|
||||
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
|
||||
in the main process. The renderer cannot choose a registry URL. This endpoint
|
||||
must be published separately; client implementation and synthetic tests do not
|
||||
prove the repository or an online artifact is available.
|
||||
Community starts at the fixed main-process endpoint
|
||||
`https://plugins.netcatty.app/v1/snapshot.json`. The renderer cannot choose an
|
||||
endpoint. All automatic catalog, README and potential package reads allow only
|
||||
that exact HTTPS origin: no credentials, query, fragment, noncanonical URL or
|
||||
redirect. No GitHub API/raw/avatar/release fallback exists. Explicit user-opened
|
||||
source links may still open GitHub in an external browser.
|
||||
|
||||
## Catalog contract
|
||||
## Snapshot contract
|
||||
|
||||
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
|
||||
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
|
||||
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
|
||||
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
|
||||
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
|
||||
including manifest defaults, uses the existing 128-level / 100,000-value budget
|
||||
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
|
||||
and license checks apply. Source-only entries remain build-and-import examples.
|
||||
No download counts, ratings, or timestamps are invented. Brand previews remain
|
||||
bundled and reviewed; arbitrary author image URLs are never loaded.
|
||||
This client implements the version-1 contract from
|
||||
`binaricat/netcatty-plugins@7bfe23e5627e16ba63806de6dded5b2667c02984`.
|
||||
The source catalog/index remain unchanged; **no `readmeAsset` field is added**.
|
||||
|
||||
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
|
||||
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
|
||||
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
|
||||
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
|
||||
when the last refresh succeeded; it is disabled while a request is pending.
|
||||
The last good catalog is retained **in memory for this application session**;
|
||||
a fresh offline launch uses bundled examples. There is no new disk cache,
|
||||
database migration, cloud sync record, or credential requirement.
|
||||
The pointer contains `schemaVersion: 1`, `registryCommit`, `snapshotId`, and
|
||||
`catalog`, `index`, `delivery` references, each `{path, sha256, size}`. Paths must
|
||||
be `/snapshots/<snapshotId>/<name>.json`; `snapshotId` equals the raw delivery
|
||||
SHA-256. The host checks the pointer before requesting its three documents,
|
||||
then checks raw byte counts/hashes, JSON budgets, schemas and cross-document
|
||||
identity. The three documents must all validate before replacing any cache.
|
||||
|
||||
## Selection and installation
|
||||
Delivery contains `schemaVersion`, `registryCommit`, `catalogSha256`,
|
||||
`indexSha256`, `sourceLockSha256`, `builderSha256` and `plugins`. Each item matches
|
||||
one exact catalog `id/version/repository/commit` tuple and supplies `readme`,
|
||||
`iconLight`, `iconDark`, `avatar`, `artifact`. Asset references are
|
||||
`{path, sha256, size}`, with `/assets/<sha256>.md` for README and `.png`/`.webp`
|
||||
for brand icons. This v1 publishing schema requires **avatar and artifact to
|
||||
be null**. The client rejects unsupported delivery metadata rather than
|
||||
inventing installable packages or deriving a GitHub avatar URL.
|
||||
|
||||
Each returned entry has a host-generated `selectionToken` covering its complete
|
||||
JSON contents. This is a concurrency check, **not a signature or trust proof**.
|
||||
README requests must still match that selection and fetch its pinned source
|
||||
commit. A changed entry requires refresh and renewed review.
|
||||
Index is validated with its pinned schema. Plugin/version identities must be
|
||||
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
|
||||
source-only entry. Withdrawal does not disable, uninstall or delete any existing
|
||||
plugin: this is catalog admission, not a remote installed-code kill switch.
|
||||
|
||||
A manual install must first refresh the official catalog successfully and match
|
||||
the selected token again. Failed refresh is browse-only, even if the cached
|
||||
entry has an artifact. Withdrawal, version changes, permission changes, README
|
||||
changes, or artifact hash changes cannot silently replace the selected package.
|
||||
Archive download then uses the existing approved-host, size, exact SHA-256,
|
||||
manifest and package validation pipeline. Installation leaves the plugin disabled;
|
||||
existing authorization gates still apply on activation/use.
|
||||
Schemas are vendored under `electron/plugins/distribution/`, with provenance and
|
||||
fixtures described there. All schema references resolve locally; schema validation
|
||||
does not fetch GitHub or netcatty.com.
|
||||
|
||||
`index.json` release history and withdrawal records are not consumed by this
|
||||
first client. Removing an entry does **not** disable, uninstall, update, or delete
|
||||
data from an already installed plugin. There are no background code updates.
|
||||
## Bounds, cache and selection
|
||||
|
||||
## Verification boundary
|
||||
Pointer reads are bounded to 16 KiB, each JSON document to 2 MiB, and each README
|
||||
to 256 KiB. Reads time out after 30 seconds. Whole JSON values retain the existing
|
||||
128-level / 100,000-value / finite-number boundary. Catalogs contain at most 1,000
|
||||
entries. Existing manifest identity, license and browser-only policies remain.
|
||||
|
||||
Service regressions use synthetic network responses and package bytes; the
|
||||
existing package manager tests cover archive admission. No third-party plugin,
|
||||
real credential, real sync account, or live registry write is used. Native QA
|
||||
must separately verify the visible offline/retry state and eventual real catalog
|
||||
availability once the repository is published.
|
||||
The host prepares private entries, private delivery resources and all public
|
||||
selection tokens before one synchronous cache publication. Parse, integrity,
|
||||
schema, clone, tuple or network failures preserve the last complete good snapshot.
|
||||
The cache remains **in memory for this app session**; a fresh offline launch lists
|
||||
bundled examples. No new disk database, sync record or credential is introduced.
|
||||
|
||||
A host-generated selection token binds the complete entry and its delivery item.
|
||||
This prevents silent selection changes; it is **not a signature**. README requests
|
||||
capture the matched static reference and verify its exact byte count and digest.
|
||||
Missing/offline snapshots show an error/Retry; they never synthesize a raw GitHub URL.
|
||||
README HTML and remote images remain inert in the existing safe renderer.
|
||||
|
||||
The current v1 distribution cannot install packages. A later publishing/client
|
||||
schema revision must jointly introduce reviewed package mirroring, keep source
|
||||
manifest/hash admission, and recheck the current pointer and fresh root withdrawal
|
||||
ledger before any install. A historical snapshot alone cannot establish availability.
|
||||
The existing lower-level package downloader accepts only official static URLs and
|
||||
exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
|
||||
|
||||
## Network inventory
|
||||
|
||||
| Path | Automatic source | Failure behavior |
|
||||
| --- | --- | --- |
|
||||
| Pointer / manual refresh | Fixed `/v1/snapshot.json` | Complete session cache, or bundled source-only entries |
|
||||
| Catalog/index/delivery | Pointer-bound same-origin immutable paths | Reject partial or mixed snapshots |
|
||||
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
|
||||
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
|
||||
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
|
||||
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
|
||||
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
|
||||
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
|
||||
|
||||
The real PR3 bootstrap is retained as a test fixture with its original bytes and
|
||||
hashes. Tests also verify its brand-icon asset bytes; this does not claim a new
|
||||
remote icon renderer. The static publisher is not a proxy. Client tests do not
|
||||
prove deployment, mainland reachability or successful native CDN access. Native
|
||||
QA must independently verify the published endpoint and visible failure state.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Selected-context authority reference
|
||||
|
||||
`electron/plugins/context/selectedContextAuthority.cjs` implements text disclosure.
|
||||
It does not issue typed Host targets or authorize edit, connect, or command actions.
|
||||
|
||||
The constructor is `new SelectedContextAuthority({ resolveDataOwner, clock?,
|
||||
limits? })`. `resolveDataOwner(context)` returns the authenticated host-owned source
|
||||
binding. Runtime identity comes from the host RPC context, never plugin arguments.
|
||||
|
||||
| Method | Meaning |
|
||||
| --- | --- |
|
||||
| `share({scope,title,version,readText,isCurrent}, context)` | Trusted host issues an opaque, expiring text handle. Returns a summary. |
|
||||
| `summaries(context)` | Enumerates only this source/runtime's shared summaries. Does not call `readText`. |
|
||||
| `read({handle,version}, context)` | Checks identity, expected version, current owner, lifetime, and resource currency before and after the bounded read. |
|
||||
| `assertCurrent({handle,version}, context)` | Checks currency without reading content. |
|
||||
| `onDidInvalidate(listener)` | Observes handle revocation. One failed listener cannot interrupt cleanup. |
|
||||
| `release(handle)` | Revokes one text handle. |
|
||||
| `releaseRuntime(runtimeId)` | Revokes that runtime's text handles. |
|
||||
| `releaseOwner(owner)` | Revokes that exact owner binding's text handles. |
|
||||
| `shutdown()` | Revokes all handles. |
|
||||
|
||||
The summary contains `handle`, `scope`, `title`, `version`, and `expiresAt`.
|
||||
`scope: "host"` means curated host-related text. It is not a Host entity reference.
|
||||
A summary, menu ID, title, or host-related text cannot become an action target.
|
||||
|
||||
`PluginAISourceBinding` in `electron/plugins/ai/sourceBinding.cjs` consumes the
|
||||
market owner's source API. `resolve(context)` first authenticates the current
|
||||
browser runtime and then returns a stable frozen object containing `runtimeId`,
|
||||
`pluginId`, `pluginVersion`, `sourceKey`, and `privateOwner`. It revalidates source
|
||||
binding after asynchronous authentication. The object never crosses plugin RPC.
|
||||
`sourceKey` binds the source ID, kind, and origin. It is not a trust endorsement.
|
||||
This helper supplies neither resource selection nor action authorization.
|
||||
|
||||
Resource currency remains a trusted `isCurrent()` callback. The callback binds
|
||||
the selected resource, expected revision, current host window, and its lifecycle.
|
||||
The authority stores the issued version separately and never accepts a later
|
||||
plugin-supplied revision as authoritative.
|
||||
|
||||
The Host/Vault action task requires a separate typed selection authority. Such a
|
||||
target binds the host-owned source identity, runtime, plugin version, window owner
|
||||
and generation, exact Host ID, expected Host revision, expiry, and allowed action.
|
||||
Each action needs its own grant and host confirmation. Text-read handles must not
|
||||
implicitly gain edit or connect authority. Host draft review and connection
|
||||
confirmation remain separate workflows owned by that task.
|
||||
@@ -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.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -241,6 +241,7 @@ let registeredVaultAgentBridge = null;
|
||||
|
||||
// Active streaming requests (for cancellation)
|
||||
const activeStreams = new Map();
|
||||
const pluginModelStreamOwners = new Map();
|
||||
|
||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
@@ -613,7 +614,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
const parsedUrl = new URL(url);
|
||||
// Register cancellation before any await so Stop during PAC/proxy lookup works.
|
||||
const controller = new AbortController();
|
||||
const { idleTimeoutMs, totalTimeoutMs } = resolveAIStreamTimeouts(options);
|
||||
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
|
||||
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
|
||||
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
|
||||
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
|
||||
: resolveAIStreamTimeouts(options);
|
||||
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
|
||||
let lifecycleFinished = false;
|
||||
let idleTimer;
|
||||
@@ -623,6 +628,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
clearTimeout(totalTimer);
|
||||
clearTimeout(idleTimer);
|
||||
activeStreams.delete(requestId);
|
||||
pluginModelStreamOwners.delete(requestId);
|
||||
};
|
||||
const noteStreamActivity = () => {
|
||||
if (lifecycleFinished) return;
|
||||
@@ -636,6 +642,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
}, totalTimeoutMs);
|
||||
noteStreamActivity();
|
||||
activeStreams.set(requestId, controller);
|
||||
if (pluginRuntimeId) pluginModelStreamOwners.set(requestId, { sender: event.sender, runtimeId: pluginRuntimeId });
|
||||
if (controller.signal.aborted) {
|
||||
finishLifecycle();
|
||||
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
|
||||
@@ -782,10 +789,19 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
|
||||
settleResolve({ statusCode, statusText });
|
||||
|
||||
let buffer = "";
|
||||
let pluginResponseBytes = 0;
|
||||
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
if (streamFinished) return;
|
||||
noteStreamActivity();
|
||||
if (pluginRuntimeId) {
|
||||
pluginResponseBytes += Buffer.byteLength(chunk);
|
||||
if (pluginResponseBytes > 131_072) {
|
||||
failStream(new Error("Plugin model stream byte budget exceeded"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const previousBufferLength = buffer.length;
|
||||
buffer += chunk.toString();
|
||||
// Guard against unbounded buffer growth
|
||||
@@ -1005,12 +1021,25 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
}
|
||||
|
||||
// This entry point is main-process only. Plugin views cannot choose a sender or
|
||||
// access it through IPC. Internal Agent streams have no plugin request tag.
|
||||
function cancelPluginAIStreamsForSender(sender, runtimeId) {
|
||||
let cancelled = 0;
|
||||
for (const [requestId, owner] of [...pluginModelStreamOwners]) {
|
||||
if (owner.sender !== sender || (runtimeId !== undefined && owner.runtimeId !== runtimeId)) continue;
|
||||
activeStreams.get(requestId)?.abort(createAbortError());
|
||||
cancelled++;
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
// Abort active streams and child processes on shutdown
|
||||
function cleanup() {
|
||||
for (const [id, controller] of activeStreams) {
|
||||
try { controller.abort(); } catch {}
|
||||
}
|
||||
activeStreams.clear();
|
||||
pluginModelStreamOwners.clear();
|
||||
|
||||
// Abort active SDK agent streams (set by registerSdkStreamHandlers on ctx).
|
||||
if (registeredContext && registeredContext.sdkActiveStreams) {
|
||||
@@ -1056,6 +1085,7 @@ module.exports = {
|
||||
buildExternalAgentSystemContext,
|
||||
buildExternalAgentContextualPrompt,
|
||||
_streamRequestForTests: streamRequest,
|
||||
cancelPluginAIStreamsForSender,
|
||||
_resolveAIStreamTimeoutsForTests: resolveAIStreamTimeouts,
|
||||
_getActiveStreamCountForTests: () => activeStreams.size,
|
||||
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
|
||||
|
||||
@@ -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,73 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const schema = require("../generated/plugin-contract.schema.json");
|
||||
|
||||
if (!schema.$defs.PluginAIChatResult) {
|
||||
test("assistant installed runtime requires the shared canonical AI/context contract", { skip: "Run on the parent-coordinated integration head" }, () => {});
|
||||
} else {
|
||||
test("installed assistant uses canonical SDK/RPC, independent content grants, reviewed version and revocation", { timeout: 15_000 }, async (t) => {
|
||||
const { createPluginHostService } = require("../hostService.cjs");
|
||||
const { inProcessBrowserRuntime } = require("../fixtures/inProcessBrowserRuntime.cjs");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-assistant-entry-"));
|
||||
let service; let contentAllowed = false; let modelAllowed = true; let reviewed = true; let invoked = 0; let versionCurrent = true;
|
||||
const decisions = [];
|
||||
t.after(async () => { await service?.manager.shutdown(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => path.resolve(__dirname, "../../.."), getPath: () => root, getVersion: () => "0.0.0" }, electron: {}, env: {},
|
||||
pluginAIHostAdapter: { assertActive: (context) => context.assertActive(),
|
||||
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: () => true, invoke: async () => {
|
||||
invoked++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; yield { kind: "usage", inputTokens: 12, outputTokens: 6, totalTokens: 18 }; })();
|
||||
} }),
|
||||
listSelected: async () => [{ key: "synthetic-note", scope: "note", title: "Synthetic retry plan", version: "v1",
|
||||
readText: () => "Synthetic retry plan: add a retry button", isCurrent: () => versionCurrent }],
|
||||
reviewText: async () => reviewed,
|
||||
},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
if ((request.permission === "context.selected.read" && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
|
||||
return { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
|
||||
},
|
||||
});
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
const sample = path.resolve(__dirname, "../../../examples/plugins/assistant");
|
||||
// Use an explicit six-file fixture snapshot, not source/build tooling.
|
||||
const staging = path.join(root, "package"); await fs.mkdir(path.join(staging, "dist"), { recursive: true });
|
||||
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js", "README.md"]) await fs.copyFile(path.join(sample, file), path.join(staging, file));
|
||||
await fs.copyFile(path.join(sample, "src/index.js"), path.join(staging, "dist/index.js"));
|
||||
const { buildPluginPackage, validatePluginPackage } = await import("@netcatty/plugin-cli");
|
||||
const archive = path.join(root, "assistant.ncpkg"); const built = await buildPluginPackage(staging, archive);
|
||||
assert.equal(built.fileCount, 6); assert.equal((await validatePluginPackage(archive)).manifest.id, "com.netcatty.assistant");
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const id = "com.netcatty.assistant";
|
||||
const run = (name, args) => service.contributionService.executeCommand(`${id}.${name}`, args);
|
||||
modelAllowed = false; await assert.rejects(run("open")); assert.equal(invoked, 0);
|
||||
modelAllowed = true;
|
||||
const chat = await run("open"); const references = await run("contexts"); assert.equal(references.length, 1);
|
||||
assert.equal(references[0].scope, "note"); assert.equal(references[0].text, undefined);
|
||||
const input = { chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: references[0].handle, version: references[0].version }] };
|
||||
await assert.rejects(run("send", input)); assert.equal(invoked, 0);
|
||||
contentAllowed = true; reviewed = false; await assert.rejects(run("send", input)); assert.equal(invoked, 0);
|
||||
reviewed = true;
|
||||
const turn = await run("send", input);
|
||||
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "text", sequence: 0, text: "Synthetic summary" });
|
||||
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "usage", sequence: 1, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
|
||||
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "end", sequence: 2 });
|
||||
await assert.rejects(run("send", { ...input, privateOwner: "spoofed" }));
|
||||
await assert.rejects(run("send", { ...input, contexts: [null] }));
|
||||
await assert.rejects(run("read", { turnHandle: turn.turnHandle, source: "spoofed" }));
|
||||
const pending = await run("send", input);
|
||||
service.permissionEngine.revokeApplication(id, "ai.invoke", "*");
|
||||
assert.equal(service.aiContextService.broker.turns.size, 0);
|
||||
await assert.rejects(run("read", { turnHandle: pending.turnHandle }));
|
||||
assert.ok(decisions.some((decision) => decision.permission === "context.selected.read" && decision.resources[0].startsWith("selected:")));
|
||||
const fresh = await run("open"); const [reference] = await run("contexts");
|
||||
versionCurrent = false;
|
||||
await assert.rejects(run("send", { chatHandle: fresh.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }));
|
||||
await service.manager.setEnabled(id, false); assert.equal(service.aiContextService.broker.chats.size, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"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) {
|
||||
try {
|
||||
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;
|
||||
} catch (error) {
|
||||
const binding = this.source.bindings.get(context.runtimeId);
|
||||
if (!context.signal?.aborted && binding?.pluginId === context.pluginId && binding.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#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,66 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { PluginAIContextService } = require("./aiContextService.cjs");
|
||||
|
||||
function fixture() {
|
||||
let current = true; let allowed = true; let reviewed = true; let sourceId = "source-a"; let revoke; let invoked = 0; let reads = 0;
|
||||
const authorizations = [];
|
||||
const context = { pluginId: "com.example.ai", runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
|
||||
const source = () => ({ id: sourceId, kind: "third-party", origin: "https://source.example" });
|
||||
const database = { getActivePlugin: () => ({ source: source(), enabled: true, activeVersion: "1", runtime: { status: "running" } }),
|
||||
getRetainedPluginSource: source, privateOwner: () => `opaque-${sourceId}`, publicPluginId: () => context.pluginId };
|
||||
const hostAdapter = { assertActive: async () => {},
|
||||
selectModel: async () => ({ label: "Synthetic", isCurrent: () => current, invoke: () => { invoked++; return (async function* () { yield { kind: "text", text: "Result" }; })(); } }),
|
||||
listSelected: async () => [{ key: "selected-note", scope: "note", title: "Synthetic", version: "1", isCurrent: () => current, readText: () => { reads++; return "Synthetic body"; } }],
|
||||
reviewText: async () => reviewed,
|
||||
};
|
||||
const permissionEngine = { onDidRevoke: (listener) => { revoke = listener; return { dispose() {} }; },
|
||||
authorize: async (_context, authorization) => { authorizations.push(authorization); if (!allowed) throw Error("Denied"); } };
|
||||
const service = new PluginAIContextService({ database, permissionEngine, hostAdapter, getRuntimeIdentity: () => ({ ...context, runtimeKind: "browser" }) });
|
||||
return { service, context, hostAdapter, authorizations, invoked: () => invoked, reads: () => reads,
|
||||
allow: (v) => { allowed = v; }, review: (v) => { reviewed = v; }, current: (v) => { current = v; }, source: (v) => { sourceId = v; }, revoke: () => revoke({ pluginId: context.pluginId }) };
|
||||
}
|
||||
|
||||
test("model invocation independently requests content permission and reviewed text", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
const { items: [reference] } = await f.service.list({}, f.context); assert.equal(f.reads(), 0);
|
||||
const chat = await f.service.open({}, f.context);
|
||||
f.allow(false);
|
||||
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
|
||||
assert.equal(f.reads(), 0); assert.equal(f.invoked(), 0);
|
||||
f.allow(true); f.review(false);
|
||||
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
|
||||
assert.equal(f.invoked(), 0);
|
||||
f.review(true);
|
||||
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context);
|
||||
assert.equal((await f.service.read(turn, f.context)).text, "Result");
|
||||
assert.ok(f.authorizations.every((authorization) => authorization.permission === "context.selected.read" && authorization.allowedScopes[0] === "once"));
|
||||
});
|
||||
|
||||
test("source change during host review suppresses text and inference", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
const { items: [reference] } = await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
|
||||
f.hostAdapter.reviewText = async () => { f.source("source-b"); return true; };
|
||||
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
|
||||
assert.equal(f.invoked(), 0);
|
||||
});
|
||||
|
||||
test("permission revocation clears all handles for the plugin, including chats without context", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
|
||||
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
f.revoke(); assert.equal(f.service.broker.turns.size, 0); assert.equal(f.service.selected.handles.size, 0);
|
||||
await assert.rejects(f.service.read(turn, f.context));
|
||||
});
|
||||
|
||||
test("cancelling one host request does not revoke another chat in the same runtime", async (t) => {
|
||||
const f = fixture(); t.after(() => f.service.shutdown());
|
||||
const chat = await f.service.open({}, f.context);
|
||||
let resolve; f.hostAdapter.listSelected = () => new Promise((done) => { resolve = done; });
|
||||
const controller = new AbortController();
|
||||
const listing = f.service.list({}, { ...f.context, signal: controller.signal });
|
||||
const cancelled = assert.rejects(listing);
|
||||
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
|
||||
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
"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.openings = new Set();
|
||||
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);
|
||||
this.#prune();
|
||||
if (this.closed || this.chats.size + this.openings.size >= this.limits.chats
|
||||
|| [...this.chats.values(), ...this.openings].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Plugin chat limit reached");
|
||||
}
|
||||
// Reserve before awaiting the host picker. Revoke/close can invalidate this
|
||||
// admission even if a host callback ignores cancellation and returns late.
|
||||
const opening = { ...binding, active: true };
|
||||
this.openings.add(opening);
|
||||
let model;
|
||||
try {
|
||||
model = await this.selectModel(context);
|
||||
const current = await this.#binding(context);
|
||||
if (!opening.active || this.closed || current.owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
|
||||
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();
|
||||
if (!opening.active || this.closed || (await this.#binding(context)).owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
|
||||
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 };
|
||||
} catch (error) {
|
||||
try { Promise.resolve(model?.dispose?.()).catch(() => {}); } catch {}
|
||||
throw this.#safe(error);
|
||||
} finally { this.openings.delete(opening); }
|
||||
}
|
||||
|
||||
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);
|
||||
try { Promise.resolve(chat.model.dispose?.()).catch(() => {}); } catch {}
|
||||
}
|
||||
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 opening of [...this.openings]) if (opening.runtimeId === runtimeId) { opening.active = false; this.openings.delete(opening); }
|
||||
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
|
||||
this.contexts?.releaseRuntime(runtimeId);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const opening of [...this.openings]) if (opening.owner === owner) { opening.active = false; this.openings.delete(opening); }
|
||||
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
|
||||
this.contexts?.releaseOwner(owner);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const opening of this.openings) opening.active = false; this.openings.clear(); 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,222 @@
|
||||
"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();
|
||||
});
|
||||
|
||||
test("pending model selections reserve capacity and cannot publish after revocation", async () => {
|
||||
const f = fixture({ limits: { perRuntime: 1 } }); const pending = deferred(); let disposed = 0;
|
||||
f.broker.selectModel = () => pending.promise;
|
||||
const opening = f.broker.open(f.context); const rejected = assert.rejects(opening);
|
||||
await new Promise(setImmediate);
|
||||
await assert.rejects(f.broker.open(f.context), (error) => error.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.releaseRuntime("r1");
|
||||
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
|
||||
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
"use strict";
|
||||
|
||||
const { randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const CHANNELS = Object.freeze({ request: "netcatty:plugins:ai-host-request", ready: "netcatty:plugins:ai-host-ready", response: "netcatty:plugins:ai-host-response" });
|
||||
|
||||
// Trusted main-window link, never a plugin IPC surface. Canonical envelopes are
|
||||
// supplied by the shared contract task; no provider config or key crosses it.
|
||||
class PluginAITrustedHostAdapter {
|
||||
constructor(options) {
|
||||
this.getWindow = options.getWindow;
|
||||
this.isLocked = options.isLocked ?? (() => true);
|
||||
this.dialog = options.dialog;
|
||||
this.cancelProviderStreams = options.cancelProviderStreams ?? ((sender, runtimeId) => require("../../bridges/aiBridge.cjs").cancelPluginAIStreamsForSender(sender, runtimeId));
|
||||
this.pending = new Map(); this.listeners = new Set(); this.reviewControllers = new Map();
|
||||
this.owner = null; this.ownerCleanup = null; this.generation = 0; this.closed = false;
|
||||
this.timeoutMs = Math.min(options.timeoutMs ?? 60_000, 60_000);
|
||||
this.validateRequest = options.validateRequest ?? null;
|
||||
this.validateResponse = options.validateResponse ?? null;
|
||||
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.invalidate(); });
|
||||
}
|
||||
|
||||
registerIpc(ipcMain) {
|
||||
if (this.ipcRegistration) throw new Error("Trusted AI host IPC is already registered");
|
||||
this.validateRequest ??= createDefinitionValidator("PluginAIHostRequest");
|
||||
this.validateResponse ??= createDefinitionValidator("PluginAIHostResponse");
|
||||
ipcMain.handle(CHANNELS.ready, (event, ready) => this.setReady(event.sender, ready));
|
||||
ipcMain.handle(CHANNELS.response, (event, response) => this.respond(event.sender, response));
|
||||
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(CHANNELS.ready); ipcMain.removeHandler(CHANNELS.response); } };
|
||||
return this.ipcRegistration;
|
||||
}
|
||||
|
||||
#owner() {
|
||||
const window = this.getWindow?.();
|
||||
if (this.closed || this.isLocked() || !this.owner || window?.webContents !== this.owner || window.isDestroyed?.()
|
||||
|| this.owner.isDestroyed?.() || window.isVisible?.() !== true) throw fail("unavailable", "Trusted AI host is unavailable");
|
||||
return this.owner;
|
||||
}
|
||||
|
||||
async assertActive(context) {
|
||||
await context.assertActive(); context.signal?.throwIfAborted(); this.#owner();
|
||||
}
|
||||
|
||||
setReady(sender, ready) {
|
||||
if (typeof ready !== "boolean" || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) {
|
||||
throw fail("permissionDenied", "AI host sender is not the main window");
|
||||
}
|
||||
if (!ready) { if (this.owner === sender) this.invalidate(true); return true; }
|
||||
if (this.closed) throw fail("unavailable", "AI host is closed");
|
||||
if (this.owner === sender) return true;
|
||||
this.invalidate(true); this.owner = sender;
|
||||
const window = this.getWindow();
|
||||
const release = () => this.invalidate(true);
|
||||
const hide = () => this.invalidate();
|
||||
const navigate = (details, _url, inPlace, mainFrame) => {
|
||||
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
|
||||
};
|
||||
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate); window.on?.("hide", hide);
|
||||
this.ownerCleanup = () => {
|
||||
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release);
|
||||
sender.removeListener?.("did-start-navigation", navigate); window.removeListener?.("hide", hide);
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
#envelope(kind, payload, runtimeId) {
|
||||
const request = { requestId: randomUUID(), actionToken: randomBytes(24).toString("base64url"), kind, payload, runtimeId };
|
||||
assertPluginJsonValue(request, { maxBytes: 24_576 });
|
||||
if (!this.validateRequest?.(request)) throw fail("internal", "Trusted AI host request is invalid");
|
||||
return request;
|
||||
}
|
||||
|
||||
#send(kind, payload, runtimeId) {
|
||||
const request = this.#envelope(kind, payload, runtimeId);
|
||||
return { request, sender: this.#owner(), generation: this.generation };
|
||||
}
|
||||
|
||||
#cleanup(kind, payload, runtimeId, sender = this.owner) {
|
||||
if (!sender || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) return;
|
||||
try { sender.send(CHANNELS.request, this.#envelope(kind, payload, runtimeId)); } catch {}
|
||||
}
|
||||
|
||||
async #request(kind, payload, context) {
|
||||
await this.assertActive(context);
|
||||
if (this.pending.size >= 64) throw fail("resourceExhausted", "Trusted AI host request limit reached");
|
||||
const { request, sender, generation } = this.#send(kind, payload, context.runtimeId);
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry = { request, sender, generation, context, resolve, reject };
|
||||
entry.finish = (result, error) => {
|
||||
if (!this.pending.delete(request.requestId)) return;
|
||||
clearTimeout(entry.timer); context.signal?.removeEventListener("abort", entry.abort);
|
||||
if (error) {
|
||||
this.#cleanup("request.cancel", { requestId: request.requestId }, context.runtimeId, sender);
|
||||
reject(error);
|
||||
} else resolve(result);
|
||||
};
|
||||
entry.abort = () => entry.finish(null, fail("cancelled", "Trusted AI host request cancelled"));
|
||||
entry.timer = setTimeout(() => entry.finish(null, fail("deadlineExceeded", "Trusted AI host request timed out")), this.timeoutMs);
|
||||
this.pending.set(request.requestId, entry);
|
||||
context.signal?.addEventListener("abort", entry.abort, { once: true });
|
||||
if (context.signal?.aborted) { entry.abort(); return; }
|
||||
try { sender.send(CHANNELS.request, request); }
|
||||
catch { entry.finish(null, fail("unavailable", "Trusted AI host disconnected")); }
|
||||
});
|
||||
}
|
||||
|
||||
async respond(sender, response) {
|
||||
if (sender !== this.#owner()) throw fail("permissionDenied", "AI host response sender is invalid");
|
||||
assertPluginJsonValue(response, { maxBytes: 24_576 });
|
||||
if (!this.validateResponse?.(response)) throw fail("invalidArgument", "AI host response is invalid");
|
||||
const entry = this.pending.get(response.requestId);
|
||||
if (!entry || entry.sender !== sender || entry.generation !== this.generation) return false;
|
||||
const expected = Buffer.from(entry.request.actionToken); const actual = Buffer.from(response.actionToken);
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
|
||||
// Consume the nonce before awaiting authentication to prevent replay races.
|
||||
entry.request.actionToken = "";
|
||||
try {
|
||||
await this.assertActive(entry.context);
|
||||
if (entry.generation !== this.generation || this.pending.get(response.requestId) !== entry) return false;
|
||||
if (response.error === true) entry.finish(null, fail("unavailable", "Trusted AI host operation failed"));
|
||||
else entry.finish(response.result);
|
||||
return true;
|
||||
} catch { entry.finish(null, fail("cancelled", "Trusted AI host response became stale")); return false; }
|
||||
}
|
||||
|
||||
async selectModel(context) {
|
||||
const selected = await this.#request("model.select", {}, context);
|
||||
if (typeof selected?.hostHandle !== "string" || typeof selected.label !== "string" || selected.label.length > 160) throw fail("internal", "Selected model is invalid");
|
||||
const generation = this.generation;
|
||||
return {
|
||||
label: selected.label,
|
||||
dispose: async () => { try { await this.#request("model.release", { hostHandle: selected.hostHandle }, { ...context, signal: undefined }); } catch {} },
|
||||
isCurrent: async () => generation === this.generation && (await this.#request("model.current", { hostHandle: selected.hostHandle }, context)).current === true,
|
||||
invoke: async (input) => {
|
||||
const activeContext = { ...context, signal: input.signal };
|
||||
const turn = await this.#request("model.start", { hostHandle: selected.hostHandle, prompt: input.prompt,
|
||||
contexts: input.contexts, maxOutputTokens: input.maxOutputTokens, maxChunkBytes: input.maxChunkBytes }, activeContext);
|
||||
if (typeof turn?.hostHandle !== "string") throw fail("internal", "Model turn is invalid");
|
||||
return {
|
||||
next: async () => {
|
||||
const result = await this.#request("model.next", { hostHandle: turn.hostHandle }, activeContext);
|
||||
if (result.done === true) return { done: true };
|
||||
if (result.done !== false || !result.event) throw fail("internal", "Model stream event is invalid");
|
||||
return { done: false, value: result.event };
|
||||
},
|
||||
return: async () => {
|
||||
// Cleanup must remain possible after the turn signal is aborted.
|
||||
try { await this.#request("model.stop", { hostHandle: turn.hostHandle }, { ...context, signal: undefined }); } catch {}
|
||||
return { done: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listSelected(context) {
|
||||
const result = await this.#request("context.select", {}, context);
|
||||
if (!Array.isArray(result?.items) || result.items.length > 5) throw fail("internal", "Selected context list is invalid");
|
||||
const generation = this.generation;
|
||||
return result.items.map((item) => ({ key: item.hostHandle, scope: item.scope, title: item.title, version: item.version,
|
||||
isCurrent: async () => generation === this.generation && (await this.#request("context.current", { hostHandle: item.hostHandle, version: item.version }, context)).current === true,
|
||||
readText: async ({ signal } = {}) => {
|
||||
const read = await this.#request("context.read", { hostHandle: item.hostHandle, version: item.version }, { ...context, signal: signal ?? context.signal });
|
||||
return read.text;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async reviewText(selected, context) {
|
||||
await this.assertActive(context);
|
||||
const window = this.getWindow(); const generation = this.generation;
|
||||
if (typeof this.dialog?.showMessageBox !== "function") return false;
|
||||
// A native permission dialog can resolve just before its parent regains
|
||||
// focus. Wait briefly for this exact owner, without focusing it ourselves.
|
||||
const focusDeadline = Date.now() + 1_000;
|
||||
while (window.isFocused?.() !== true) {
|
||||
await this.assertActive(context);
|
||||
if (generation !== this.generation || Date.now() >= focusDeadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
|
||||
this.reviewControllers.set(controller, context.runtimeId);
|
||||
try {
|
||||
const result = await this.dialog.showMessageBox(window, {
|
||||
type: "question", title: "Share selected context", message: `Share "${selected.title}" with ${selected.pluginId}?`,
|
||||
detail: `Scope: ${selected.scope}\nVersion: ${selected.version}\n\n${selected.text}`,
|
||||
buttons: ["Share this text", "Cancel"], defaultId: 1, cancelId: 1, noLink: true, signal,
|
||||
});
|
||||
await this.assertActive(context);
|
||||
return !signal.aborted && generation === this.generation && result.response === 0;
|
||||
} finally { this.reviewControllers.delete(controller); }
|
||||
}
|
||||
|
||||
onDidInvalidate(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
||||
releaseRuntime(runtimeId) {
|
||||
try { if (this.owner) this.cancelProviderStreams(this.owner, runtimeId); } catch {}
|
||||
for (const entry of [...this.pending.values()]) if (entry.context.runtimeId === runtimeId) entry.abort();
|
||||
for (const [controller, owner] of this.reviewControllers) if (owner === runtimeId) controller.abort();
|
||||
this.#cleanup("runtime.release", {}, runtimeId);
|
||||
}
|
||||
invalidate(releaseOwner = false) {
|
||||
this.generation++;
|
||||
try { if (this.owner) this.cancelProviderStreams(this.owner); } catch {}
|
||||
for (const entry of [...this.pending.values()]) entry.finish(null, fail("cancelled", "Trusted AI host lifecycle changed"));
|
||||
for (const controller of this.reviewControllers.keys()) controller.abort();
|
||||
for (const listener of [...this.listeners]) { try { listener(); } catch {} }
|
||||
if (releaseOwner) { this.ownerCleanup?.(); this.ownerCleanup = null; this.owner = null; }
|
||||
}
|
||||
shutdown() { this.ipcRegistration?.dispose(); this.ipcRegistration = null; this.closed = true; this.invalidate(true); this.unsubscribeLock?.(); this.listeners.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS };
|
||||
@@ -0,0 +1,96 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS } = require("./trustedHostAdapter.cjs");
|
||||
|
||||
function fixture(options = {}) {
|
||||
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false });
|
||||
const messages = [];
|
||||
sender.send = (channel, payload) => messages.push({ channel, payload: structuredClone(payload) });
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
let currentWindow = window; let locked = false;
|
||||
const adapter = new PluginAITrustedHostAdapter({ getWindow: () => currentWindow, isLocked: () => locked,
|
||||
// Domain tests isolate nonce/owner lifecycle. Canonical schema validation is
|
||||
// exercised separately when the shared contract is integrated.
|
||||
cancelProviderStreams: () => {},
|
||||
validateRequest: () => true, validateResponse: () => true, timeoutMs: 1_000,
|
||||
dialog: { showMessageBox: async () => ({ response: 0 }) }, ...options });
|
||||
const context = { runtimeId: "r", pluginId: "plugin", pluginVersion: "1", assertActive: async () => {} };
|
||||
adapter.setReady(sender, true);
|
||||
return { adapter, sender, window, context, messages, lockedTo: (v) => { locked = v; }, ownerTo: (v) => { currentWindow = v; } };
|
||||
}
|
||||
const tick = () => new Promise(setImmediate);
|
||||
|
||||
test("trusted renderer replies require the exact owner and one-use nonce", async (t) => {
|
||||
const f = fixture(); t.after(() => f.adapter.shutdown());
|
||||
const selecting = f.adapter.selectModel(f.context); await tick();
|
||||
const request = f.messages.at(-1).payload;
|
||||
const response = { requestId: request.requestId, actionToken: request.actionToken, result: { hostHandle: "model", label: "Synthetic model" } };
|
||||
await assert.rejects(f.adapter.respond({ id: 2 }, response));
|
||||
assert.equal(await f.adapter.respond(f.sender, { ...response, actionToken: "wrong" }), false);
|
||||
assert.equal(await f.adapter.respond(f.sender, response), true);
|
||||
assert.equal(await f.adapter.respond(f.sender, response), false);
|
||||
assert.equal((await selecting).label, "Synthetic model");
|
||||
});
|
||||
|
||||
test("owner navigation/hide, runtime release, cancellation and timeout discard pending replies", async () => {
|
||||
for (const action of ["navigate", "hide", "runtime", "abort", "timeout"]) {
|
||||
const f = fixture(action === "timeout" ? { timeoutMs: 20 } : {}); const controller = new AbortController();
|
||||
const selecting = f.adapter.selectModel({ ...f.context, signal: controller.signal });
|
||||
const rejected = assert.rejects(selecting); await tick();
|
||||
const request = f.messages.at(-1).payload;
|
||||
if (action === "navigate") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
|
||||
if (action === "hide") f.window.emit("hide");
|
||||
if (action === "runtime") f.adapter.releaseRuntime("r");
|
||||
if (action === "abort") controller.abort();
|
||||
await rejected; assert.equal(f.adapter.pending.size, 0);
|
||||
if (action !== "navigate") assert.equal(await f.adapter.respond(f.sender, { ...request, result: {} }), false);
|
||||
f.adapter.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("main window replacement and lock cannot reuse a previous renderer owner", async () => {
|
||||
const f = fixture(); f.ownerTo({ ...f.window, webContents: { id: 3 } });
|
||||
await assert.rejects(f.adapter.selectModel(f.context));
|
||||
assert.throws(() => f.adapter.setReady(f.sender, true)); f.adapter.shutdown();
|
||||
const next = fixture(); next.lockedTo(true); await assert.rejects(next.adapter.selectModel(next.context)); next.adapter.shutdown();
|
||||
});
|
||||
|
||||
test("selected text review is Cancel by default and cannot approve after cancellation", async () => {
|
||||
let resolve; let options;
|
||||
const f = fixture({ dialog: { showMessageBox: (_window, value) => { options = value; return new Promise((r) => { resolve = r; }); } } });
|
||||
const controller = new AbortController();
|
||||
const review = f.adapter.reviewText({ scope: "note", title: "Synthetic", version: "v1", pluginId: "plugin", text: "Synthetic text" }, { ...f.context, signal: controller.signal });
|
||||
await tick(); assert.equal(options.defaultId, options.cancelId); assert.equal(options.cancelId, 1);
|
||||
controller.abort(); resolve({ response: 0 }); await assert.rejects(review); f.adapter.shutdown();
|
||||
});
|
||||
|
||||
test("text review tolerates native permission focus restoration without focusing a window", async () => {
|
||||
const f = fixture(); let focused = false; let focusCalls = 0;
|
||||
f.window.isFocused = () => focused; f.window.focus = () => { focusCalls++; };
|
||||
const timer = setTimeout(() => { focused = true; }, 25);
|
||||
try {
|
||||
assert.equal(await f.adapter.reviewText({ title: "Synthetic", scope: "note", version: "1", text: "Synthetic", pluginId: "plugin" }, f.context), true);
|
||||
assert.equal(focusCalls, 0);
|
||||
} finally { clearTimeout(timer); f.adapter.shutdown(); }
|
||||
});
|
||||
|
||||
test("canonical trusted envelopes reject unknown requests, keys and provider diagnostics", async () => {
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
const requestValid = createDefinitionValidator("PluginAIHostRequest");
|
||||
const responseValid = createDefinitionValidator("PluginAIHostResponse");
|
||||
const identity = { requestId: "00000000-0000-4000-8000-000000000001", actionToken: "A".repeat(32), runtimeId: "runtime" };
|
||||
assert.equal(requestValid({ ...identity, kind: "model.select", payload: {} }), true);
|
||||
assert.equal(requestValid({ ...identity, kind: "model.select", payload: { providerId: "arbitrary" } }), false);
|
||||
assert.equal(requestValid({ ...identity, kind: "terminal.execute", payload: {} }), false);
|
||||
assert.equal(responseValid({ requestId: identity.requestId, actionToken: identity.actionToken,
|
||||
result: { hostHandle: identity.requestId, label: "Synthetic", apiKey: "forbidden" } }), false);
|
||||
assert.equal(responseValid({ requestId: identity.requestId, actionToken: identity.actionToken, error: "raw diagnostics" }), false);
|
||||
const handlers = new Map(); const f = fixture({ validateRequest: requestValid, validateResponse: responseValid });
|
||||
f.adapter.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler), removeHandler: (channel) => handlers.delete(channel) });
|
||||
const pending = f.adapter.selectModel(f.context); await tick(); const request = f.messages.at(-1).payload;
|
||||
assert.equal(await f.adapter.respond(f.sender, { requestId: request.requestId, actionToken: request.actionToken,
|
||||
result: { hostHandle: identity.requestId, label: "Synthetic" } }), true);
|
||||
assert.equal((await pending).label, "Synthetic"); f.adapter.shutdown(); assert.equal(handlers.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,59 @@
|
||||
"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 normalizeStreamAddress(address) {
|
||||
if (net.isIP(address) === 4) return address;
|
||||
if (net.isIP(address) !== 6) return null;
|
||||
const canonical = new URL(`http://[${address.split("%")[0]}]`).hostname.slice(1, -1);
|
||||
if (!canonical.startsWith("::ffff:")) return canonical;
|
||||
const words = canonical.slice(7).split(":");
|
||||
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
|
||||
return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
|
||||
}
|
||||
|
||||
function isLocalControlAddress(address) {
|
||||
address = normalizeStreamAddress(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 = address;
|
||||
// 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, normalizeStreamAddress };
|
||||
@@ -0,0 +1,138 @@
|
||||
"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", "retain", "readCancelled", "pending", "cancel", "cancelRead", "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", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
if (name === "readCancelled") return connection.readable.read();
|
||||
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 };
|
||||
if (name === "cancelRead") {
|
||||
const reading = connection.readable.read();
|
||||
connection.readable.cancel();
|
||||
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
|
||||
}
|
||||
if (name === "retain") {
|
||||
await connection.writable.write(Uint8Array.of(42));
|
||||
return Array.from(await connection.readable.read());
|
||||
}
|
||||
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(await run("cancelRead"), true);
|
||||
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("retain"), [42]);
|
||||
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
await assert.rejects(run("readCancelled"), /cancel/u);
|
||||
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,212 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { normalizeStreamAddress } = require("./byteStreamEndpoint.cjs");
|
||||
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) => {
|
||||
const normalized = normalizeStreamAddress(address);
|
||||
return normalized !== null && Object.values(os.networkInterfaces()).flat()
|
||||
.some((item) => item?.address && normalizeStreamAddress(item.address) === normalized);
|
||||
},
|
||||
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) 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) {
|
||||
const alreadyClosing = record.closing;
|
||||
this.closeRecord(record, error);
|
||||
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
|
||||
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,129 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const os = require("node:os");
|
||||
const { PluginByteStreamService } = require("./byteStreamService.cjs");
|
||||
|
||||
test("own-machine denial normalizes both interface and destination addresses without blocking private peers", async (t) => {
|
||||
let interfaces;
|
||||
t.mock.method(os, "networkInterfaces", () => interfaces);
|
||||
const service = new PluginByteStreamService({
|
||||
permissionEngine: { authorize: async () => {}, onDidRevoke: () => ({ dispose() {} }) },
|
||||
});
|
||||
t.after(() => service.shutdown());
|
||||
const dialed = [];
|
||||
service.broker.connectSocket = ({ host }) => {
|
||||
dialed.push(host);
|
||||
throw new Error("synthetic dialer reached");
|
||||
};
|
||||
const context = { pluginId: "synthetic", runtimeId: "synthetic", assertActive: async () => {} };
|
||||
for (const ownAddress of ["10.0.0.8", "::ffff:10.0.0.8", "::ffff:a00:8"]) {
|
||||
interfaces = { synthetic: [{ address: ownAddress }] };
|
||||
for (const endpoint of ["tcp://10.0.0.8:43210", "tcp://[::ffff:10.0.0.8]:43210", "tcp://[::ffff:a00:8]:43210"]) {
|
||||
await assert.rejects(service.broker.connect(endpoint, context), /control endpoints/u);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(dialed, []);
|
||||
interfaces = { synthetic: [{ address: "2001:0db8:0:0:0:0:0:8" }] };
|
||||
await assert.rejects(service.broker.connect("tcp://[2001:db8::8]:43210", context), /control endpoints/u);
|
||||
interfaces = { synthetic: [{ address: "10.0.0.8" }] };
|
||||
for (const endpoint of ["tcp://10.0.0.9:43210", "tcp://[::ffff:10.0.0.9]:43210", "tcp://192.168.1.9:43210"]) {
|
||||
await assert.rejects(service.broker.connect(endpoint, context), /synthetic dialer reached/u);
|
||||
}
|
||||
assert.deepEqual(dialed, ["10.0.0.9", "::ffff:a00:9", "192.168.1.9"]);
|
||||
assert.equal(service.broker.connections.size, 0);
|
||||
});
|
||||
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
let peer;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
let service;
|
||||
t.after(async () => {
|
||||
service?.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
let source = "first";
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
|
||||
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
|
||||
manifest: { features: { required: ["network.byte-streams"] } },
|
||||
};
|
||||
const audit = [];
|
||||
const writes = [];
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service = new PluginByteStreamService({
|
||||
database: {
|
||||
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
|
||||
getRetainedPluginSource: () => ({ id: source }),
|
||||
privateOwner: () => `opaque-${source}`,
|
||||
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
|
||||
recordSecurityAudit: (...args) => audit.push(args),
|
||||
},
|
||||
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
|
||||
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
|
||||
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
|
||||
})) },
|
||||
});
|
||||
service.broker.testLocalEndpoints.add(endpoint);
|
||||
service.broker.isControlEndpoint = () => false;
|
||||
service.register(registry);
|
||||
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
|
||||
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
|
||||
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
|
||||
}
|
||||
|
||||
test("source changes before grant publication cannot create a socket or grant", async (t) => {
|
||||
let approve;
|
||||
let grants = 0;
|
||||
const f = await fixture(t, { authorize: async (_context, descriptor) => {
|
||||
await new Promise((resolve) => { approve = resolve; });
|
||||
descriptor.validateBeforeGrant();
|
||||
grants++;
|
||||
} });
|
||||
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
while (!approve) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.swap(); approve();
|
||||
await assert.rejects(pending, /source binding/u);
|
||||
assert.equal(grants, 0);
|
||||
assert.equal(f.peer(), undefined);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.equal(f.audit.length, 0);
|
||||
});
|
||||
|
||||
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
await f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
f.swap();
|
||||
f.peer().write("synthetic old-source bytes");
|
||||
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
|
||||
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
|
||||
});
|
||||
|
||||
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
|
||||
let opened;
|
||||
let cancelled = 0;
|
||||
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
while (!opened) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.service.releaseRuntime("runtime-one");
|
||||
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
|
||||
await assert.rejects(attaching, /cancelled/u);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
});
|
||||
@@ -12,25 +12,29 @@ const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
|
||||
|
||||
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
|
||||
function assertDownloadUrl(value) {
|
||||
const { OFFICIAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
|
||||
const DISTRIBUTION_ORIGIN = "https://plugins.netcatty.app";
|
||||
function assertDownloadUrl(value, origin = DISTRIBUTION_ORIGIN) {
|
||||
if (typeof value !== "string" || /[\u0000-\u0020\u007f\\%?#]/u.test(value)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
|
||||
if (url.origin !== origin || url.username || url.password || url.port
|
||||
|| url.href !== value || url.pathname.split("/").some((part) => part === "." || part === "..")) {
|
||||
throw new Error("Catalog download URL is not an approved HTTPS static distribution source");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
function pinnedReadmeUrl(entry) {
|
||||
function assertReadmeSource(entry) {
|
||||
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|
||||
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error("Invalid or unpinned README path");
|
||||
}
|
||||
const prefix = `/${entry.repository}/${entry.commit}/`;
|
||||
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
|
||||
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
|
||||
return url.href;
|
||||
if (entry.repository.split("/").some((part) => part === "." || part === "..")) {
|
||||
throw new Error("README source escapes the pinned commit");
|
||||
}
|
||||
}
|
||||
function validateCatalog(entries) {
|
||||
function validateCatalog(entries, origin = DISTRIBUTION_ORIGIN) {
|
||||
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
|
||||
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
|
||||
const ids = new Set();
|
||||
@@ -43,71 +47,68 @@ function validateCatalog(entries) {
|
||||
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
|
||||
throw new Error("Invalid or unpinned plugin catalog entry");
|
||||
}
|
||||
pinnedReadmeUrl(entry);
|
||||
assertReadmeSource(entry);
|
||||
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
|
||||
assertCatalogManifest(entry.manifest, entry);
|
||||
ids.add(entry.id);
|
||||
if (Object.hasOwn(entry, "artifact")) {
|
||||
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|
||||
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|
||||
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
|
||||
throw new Error("Invalid catalog artifact metadata");
|
||||
for (const field of ["artifact"]) {
|
||||
if (!Object.hasOwn(entry, field)) continue;
|
||||
const asset = entry[field];
|
||||
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|
||||
|| Object.keys(asset).some((key) => key !== "url" && key !== "sha256")
|
||||
|| typeof asset.url !== "string" || typeof asset.sha256 !== "string") {
|
||||
throw new Error(`Invalid catalog ${field} metadata`);
|
||||
}
|
||||
assertDownloadUrl(entry.artifact.url);
|
||||
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
|
||||
assertDownloadUrl(asset.url, origin);
|
||||
if (!/^[a-f0-9]{64}$/u.test(asset.sha256)) throw new Error(`Catalog ${field} requires SHA-256`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
|
||||
async function fetchBounded(url, limit, fetchImpl = fetch, origin = DISTRIBUTION_ORIGIN) {
|
||||
const signal = AbortSignal.timeout(30_000);
|
||||
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
||||
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
url = new URL(response.headers.get("location"), url).href;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
|
||||
if (Number(response.headers.get("content-length")) > limit) {
|
||||
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("Catalog download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
const response = await fetchImpl(assertDownloadUrl(url, origin), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
throw new Error("Catalog redirects are not allowed");
|
||||
}
|
||||
throw new Error("Too many catalog redirects");
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
|
||||
if (Number(response.headers.get("content-length")) > limit) {
|
||||
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
|
||||
}
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Catalog download has no body");
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) throw new Error("Catalog download exceeds size limit");
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return Buffer.concat(chunks, size);
|
||||
} finally { await reader.cancel(); }
|
||||
}
|
||||
class PluginCatalogService {
|
||||
constructor(options) {
|
||||
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
|
||||
this.marketSource = assertSource(options.source ?? OFFICIAL_SOURCE);
|
||||
this.origin = this.marketSource.origin;
|
||||
this.entries = structuredClone(validateCatalog(options.entries ?? (this.marketSource.id === "official" ? catalog : []), this.origin));
|
||||
this.fetch = options.fetch ?? fetch;
|
||||
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
|
||||
this.manager = options.manager;
|
||||
this.sourceGuard = options.sourceGuard ?? (() => {});
|
||||
this.installing = false;
|
||||
}
|
||||
list() { return structuredClone(this.entries); }
|
||||
artifactSize() { return undefined; }
|
||||
get(id) {
|
||||
const entry = this.entries.find((item) => item.id === id);
|
||||
if (!entry) throw new Error("Plugin is not in the curated catalog");
|
||||
return entry;
|
||||
}
|
||||
async readme(id) {
|
||||
const entry = this.get(id);
|
||||
const url = pinnedReadmeUrl(entry);
|
||||
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
|
||||
}
|
||||
async install(id) {
|
||||
const entry = this.get(id);
|
||||
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
|
||||
@@ -115,16 +116,18 @@ class PluginCatalogService {
|
||||
this.installing = true;
|
||||
let directory;
|
||||
try {
|
||||
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
|
||||
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch, this.origin);
|
||||
const expectedSize = this.artifactSize(entry);
|
||||
if (expectedSize !== undefined && bytes.byteLength !== expectedSize) throw new Error("Catalog package size mismatch");
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== entry.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
|
||||
directory = await fs.mkdtemp(this.getTempFilePath("plugin-catalog-"));
|
||||
const archive = path.join(directory, "package.ncpkg");
|
||||
await fs.writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
|
||||
return await this.manager.install(archive, { enable: false, catalog: entry });
|
||||
return await this.manager.install(archive, { enable: false, catalog: entry, source: this.marketSource, sourceGuard: this.sourceGuard });
|
||||
} finally {
|
||||
this.installing = false;
|
||||
if (directory) await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
|
||||
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, assertReadmeSource, MAX_CATALOG_BYTES, DISTRIBUTION_ORIGIN };
|
||||
@@ -24,25 +24,17 @@ test("curated catalog uses pinned public first-party source and rejects advanced
|
||||
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
|
||||
});
|
||||
|
||||
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
|
||||
const requested = [];
|
||||
const service = new PluginCatalogService({ entries, fetch: async (url) => {
|
||||
requested.push(url); return new Response("# First-party example");
|
||||
} });
|
||||
assert.equal(await service.readme(entries[0].id), "# First-party example");
|
||||
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
|
||||
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
|
||||
await assert.rejects(service.readme(entries[0].id), /redirects/);
|
||||
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
|
||||
test("static downloader is bounded and never follows redirects", async () => {
|
||||
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 3, async () => new Response("1234")), /size limit/);
|
||||
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
|
||||
await assert.rejects(fetchBounded("https://plugins.netcatty.app/a", 10, async () => new Response(null, { status: 302, headers: { location: "https://github.com/a" } })), /redirects/);
|
||||
});
|
||||
|
||||
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
|
||||
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://plugins.netcatty.app/packages/synthetic.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
|
||||
let installs = 0;
|
||||
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
|
||||
assert.equal(name, "plugin-catalog-");
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -5,10 +5,13 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const SCHEMA_VERSION = 4;
|
||||
const { LOCAL_SOURCE, assertSource, privateOwner } = require("./sourceIdentity.cjs");
|
||||
|
||||
const SCHEMA_VERSION = 7;
|
||||
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
|
||||
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
|
||||
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
plugin_source_bindings: ["plugin_id", "source_json"],
|
||||
plugin_host_preferences: ["key", "value_json", "updated_at"],
|
||||
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
|
||||
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
|
||||
@@ -217,7 +220,46 @@ class PluginDatabase {
|
||||
CREATE TABLE plugin_host_preferences (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
||||
PRAGMA user_version = 4;
|
||||
`));
|
||||
if (version <= 4) this.transaction(() => this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_source_bindings (plugin_id TEXT PRIMARY KEY, source_json TEXT NOT NULL);
|
||||
`));
|
||||
this.#assertSchemaLayout();
|
||||
if (version <= 5) this.transaction(() => {
|
||||
// Upgraded installs and retained local private records must keep their
|
||||
// provenance after uninstall. Never infer official ownership from names.
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id AS plugin_id FROM plugins
|
||||
UNION SELECT plugin_id FROM plugin_settings
|
||||
UNION SELECT plugin_id FROM plugin_view_state
|
||||
UNION SELECT plugin_id FROM plugin_permission_grants
|
||||
UNION SELECT plugin_id FROM plugin_secrets
|
||||
UNION SELECT plugin_id FROM plugin_sync_sidecars
|
||||
`).all();
|
||||
const insert = this.db.prepare("INSERT OR IGNORE INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?)");
|
||||
for (const row of rows) {
|
||||
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(row.plugin_id)) insert.run(row.plugin_id, JSON.stringify(LOCAL_SOURCE));
|
||||
}
|
||||
this.db.exec("PRAGMA user_version = 6");
|
||||
});
|
||||
if (version <= 6) this.transaction(() => {
|
||||
// One-time conversion of existing local private rows; all sources now use
|
||||
// immutable encoded owners. Never retain a raw-ID lookup fallback.
|
||||
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars"]) {
|
||||
const rows = this.db.prepare(`SELECT DISTINCT plugin_id FROM ${table}`).all();
|
||||
for (const { plugin_id: id } of rows) {
|
||||
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(id)) {
|
||||
this.db.prepare(`UPDATE ${table} SET plugin_id = ? WHERE plugin_id = ?`).run(privateOwner(LOCAL_SOURCE, id), id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const row of this.db.prepare("SELECT plugin_id, key FROM plugin_kv").all()) {
|
||||
if (this.getPluginSource(row.plugin_id).id === "local") {
|
||||
const prefix = `${privateOwner(LOCAL_SOURCE, row.plugin_id)}:`;
|
||||
if (!row.key.startsWith(prefix)) this.db.prepare("UPDATE plugin_kv SET key = ? WHERE plugin_id = ? AND key = ?").run(prefix + row.key, row.plugin_id, row.key);
|
||||
}
|
||||
}
|
||||
this.db.exec("PRAGMA user_version = 7");
|
||||
});
|
||||
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
||||
// (hostService seeds after package initialize). Running here would promote
|
||||
// sole map candidates before recovered manifests are visible, then skip
|
||||
@@ -250,15 +292,64 @@ class PluginDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
getPluginSource(pluginId) {
|
||||
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
|
||||
return row ? assertSource(parseJson(row.source_json, "source identity")) : { ...LOCAL_SOURCE };
|
||||
}
|
||||
|
||||
getRetainedPluginSource(pluginId) {
|
||||
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
|
||||
if (row) return assertSource(parseJson(row.source_json, "source identity"));
|
||||
return this.db.prepare("SELECT id FROM plugins WHERE id = ?").get(pluginId) ? { ...LOCAL_SOURCE } : null;
|
||||
}
|
||||
|
||||
assertInstallSource(pluginId, source = LOCAL_SOURCE) {
|
||||
const trusted = assertSource(source);
|
||||
const row = this.db.prepare("SELECT source_json FROM plugin_source_bindings WHERE plugin_id = ?").get(pluginId);
|
||||
const existing = row ? assertSource(parseJson(row.source_json, "source identity"))
|
||||
: this.getActivePlugin(pluginId) ? LOCAL_SOURCE : null;
|
||||
if (existing && existing.id !== trusted.id) {
|
||||
throw new Error("Plugin source differs. Uninstall and explicitly clear the old source's private data before reinstalling.");
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
privateOwner(pluginId) {
|
||||
// Opaque owners enter only from host sync storage, never plugin RPC identity.
|
||||
if (/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) return pluginId;
|
||||
const source = this.getPluginSource(pluginId);
|
||||
return privateOwner(source, pluginId);
|
||||
}
|
||||
|
||||
publicPluginId(owner) {
|
||||
if (!/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(owner)) return null;
|
||||
const pluginId = owner.slice(owner.lastIndexOf(":") + 1);
|
||||
return this.privateOwner(pluginId) === owner ? pluginId : null;
|
||||
}
|
||||
|
||||
clearUninstalledSourceData(pluginId, expectedSourceId) {
|
||||
if (this.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing its source");
|
||||
if (this.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
|
||||
const owner = this.privateOwner(pluginId);
|
||||
this.transaction(() => {
|
||||
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars", "plugin_sync_provider_bindings"]) {
|
||||
this.db.prepare(`DELETE FROM ${table} WHERE plugin_id = ? OR plugin_id = ?`).run(owner, pluginId);
|
||||
}
|
||||
this.db.prepare("DELETE FROM plugin_source_bindings WHERE plugin_id = ?").run(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
installVersion(record, options = {}) {
|
||||
if (options.enable === true && options.forceDisabled === true) {
|
||||
throw new TypeError("Plugin version cannot be enabled and force-disabled together");
|
||||
}
|
||||
const source = this.assertInstallSource(record.pluginId, record.source);
|
||||
const now = this.clock();
|
||||
const manifestJson = JSON.stringify(record.manifest);
|
||||
const requestedEnabled = options.enable === true ? 1 : 0;
|
||||
const overwriteEnabled = options.enable === true || options.forceDisabled === true;
|
||||
this.transaction(() => {
|
||||
this.db.prepare("INSERT INTO plugin_source_bindings(plugin_id,source_json) VALUES(?,?) ON CONFLICT(plugin_id) DO UPDATE SET source_json=excluded.source_json").run(record.pluginId, JSON.stringify(source));
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -365,6 +456,7 @@ class PluginDatabase {
|
||||
#mapPlugin(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
source: this.getPluginSource(row.id),
|
||||
enabled: row.enabled === 1,
|
||||
activeVersion: row.active_version ?? null,
|
||||
installedAt: Number(row.installed_at),
|
||||
@@ -498,6 +590,8 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getValue(pluginId, key) {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
if (owner !== pluginId) key = `${owner}:${key}`;
|
||||
const row = this.db.prepare(
|
||||
"SELECT value_json FROM plugin_kv WHERE plugin_id = ? AND key = ?",
|
||||
).get(pluginId, key);
|
||||
@@ -505,6 +599,8 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setValue(pluginId, key, value) {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
if (owner !== pluginId) key = `${owner}:${key}`;
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Plugin storage value must be JSON serializable");
|
||||
this.db.prepare(`
|
||||
@@ -517,16 +613,22 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteValue(pluginId, key) {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
if (owner !== pluginId) key = `${owner}:${key}`;
|
||||
this.db.prepare("DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?").run(pluginId, key);
|
||||
}
|
||||
|
||||
listKeys(pluginId) {
|
||||
return this.db.prepare(
|
||||
"SELECT key FROM plugin_kv WHERE plugin_id = ? ORDER BY key COLLATE BINARY",
|
||||
).all(pluginId).map((row) => row.key);
|
||||
).all(pluginId).map((row) => row.key).filter((key) => {
|
||||
const owner = this.privateOwner(pluginId);
|
||||
return owner === pluginId || key.startsWith(`${owner}:`);
|
||||
}).map((key) => this.privateOwner(pluginId) === pluginId ? key : key.slice(this.privateOwner(pluginId).length + 1));
|
||||
}
|
||||
|
||||
getSetting(pluginId, settingId, scope, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT value_json FROM plugin_settings
|
||||
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
|
||||
@@ -535,6 +637,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
|
||||
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
|
||||
@@ -548,6 +651,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteSetting(pluginId, settingId, scope, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_settings
|
||||
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
|
||||
@@ -555,6 +659,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
listSettings(pluginId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
return this.db.prepare(`
|
||||
SELECT setting_id, scope, scope_id, value_json, updated_at
|
||||
FROM plugin_settings WHERE plugin_id = ?
|
||||
@@ -584,6 +689,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getSyncSidecar(pluginId, kind, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT value_json, updated_at FROM plugin_sync_sidecars
|
||||
WHERE plugin_id = ? AND kind = ? AND key = ?
|
||||
@@ -599,6 +705,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
|
||||
this.db.prepare(`
|
||||
@@ -611,6 +718,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteSyncSidecar(pluginId, kind, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_sync_sidecars
|
||||
WHERE plugin_id = ? AND kind = ? AND key = ?
|
||||
@@ -618,6 +726,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
listSyncSidecars(pluginId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
|
||||
FROM plugin_sync_sidecars
|
||||
@@ -672,6 +781,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getViewState(pluginId, viewId, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT state_json FROM plugin_view_state
|
||||
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
|
||||
@@ -680,6 +790,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
setViewState(pluginId, viewId, scopeId, state) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const serialized = JSON.stringify(state);
|
||||
if (serialized === undefined) throw new TypeError("Plugin view state must be JSON serializable");
|
||||
this.db.prepare(`
|
||||
@@ -692,6 +803,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteViewState(pluginId, viewId, scopeId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_view_state
|
||||
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
|
||||
@@ -704,8 +816,8 @@ class PluginDatabase {
|
||||
FROM plugin_permission_grants
|
||||
WHERE plugin_id = ?
|
||||
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
|
||||
`).all(pluginId).map((row) => ({
|
||||
pluginId: row.plugin_id,
|
||||
`).all(this.privateOwner(pluginId)).map((row) => ({
|
||||
pluginId,
|
||||
permission: row.permission,
|
||||
resource: row.resource,
|
||||
resourceKind: row.resource_kind,
|
||||
@@ -724,7 +836,7 @@ class PluginDatabase {
|
||||
declaration_hash = excluded.declaration_hash,
|
||||
granted_at = excluded.granted_at
|
||||
`).run(
|
||||
record.pluginId,
|
||||
this.privateOwner(record.pluginId),
|
||||
record.permission,
|
||||
record.resource,
|
||||
record.resourceKind,
|
||||
@@ -734,6 +846,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deletePermissionGrant(pluginId, permission, resource) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare(`
|
||||
DELETE FROM plugin_permission_grants
|
||||
WHERE plugin_id = ? AND permission = ? AND resource = ?
|
||||
@@ -741,10 +854,12 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteAllPermissionGrants(pluginId) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare("DELETE FROM plugin_permission_grants WHERE plugin_id = ?").run(pluginId);
|
||||
}
|
||||
|
||||
getSecretByKey(pluginId, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
||||
FROM plugin_secrets WHERE plugin_id = ? AND key = ?
|
||||
@@ -753,6 +868,7 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
getSecretByRef(pluginId, secretRef) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const row = this.db.prepare(`
|
||||
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
||||
FROM plugin_secrets WHERE plugin_id = ? AND secret_ref = ?
|
||||
@@ -762,7 +878,7 @@ class PluginDatabase {
|
||||
|
||||
#mapSecret(row) {
|
||||
return {
|
||||
pluginId: row.plugin_id,
|
||||
pluginId: this.publicPluginId(row.plugin_id),
|
||||
key: row.key,
|
||||
secretRef: row.secret_ref,
|
||||
ciphertext: Buffer.from(row.ciphertext),
|
||||
@@ -782,7 +898,7 @@ class PluginDatabase {
|
||||
ciphertext = excluded.ciphertext,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
record.pluginId,
|
||||
this.privateOwner(record.pluginId),
|
||||
record.key,
|
||||
record.secretRef,
|
||||
record.ciphertext,
|
||||
@@ -792,12 +908,14 @@ class PluginDatabase {
|
||||
}
|
||||
|
||||
deleteSecret(pluginId, key) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
this.db.prepare("DELETE FROM plugin_secrets WHERE plugin_id = ? AND key = ?")
|
||||
.run(pluginId, key);
|
||||
}
|
||||
|
||||
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
|
||||
deleteSecretsByKeyPrefix(pluginId, prefix) {
|
||||
pluginId = this.privateOwner(pluginId);
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM plugin_secrets
|
||||
WHERE plugin_id = ?
|
||||
@@ -857,7 +975,7 @@ class PluginDatabase {
|
||||
SELECT DISTINCT plugin_id
|
||||
FROM plugin_secrets
|
||||
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
||||
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
||||
`).all(prefix, `${escaped}:%`).map((row) => this.publicPluginId(row.plugin_id)).filter((id) => id !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -890,7 +1008,7 @@ class PluginDatabase {
|
||||
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
||||
const byProvider = new Map();
|
||||
for (const row of legacyRows) {
|
||||
const pluginId = row.plugin_id;
|
||||
const pluginId = this.publicPluginId(row.plugin_id);
|
||||
const key = row.key;
|
||||
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
||||
if (!key.startsWith(legacyPrefix)) continue;
|
||||
|
||||
@@ -6,6 +6,7 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const { privateOwner, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
|
||||
|
||||
const {
|
||||
MAX_SECURITY_AUDIT_DETAILS_BYTES,
|
||||
@@ -488,7 +489,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -499,7 +500,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
@@ -532,7 +533,7 @@ test("legacy map backfill does not overwrite an existing host binding", (context
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -566,7 +567,7 @@ test("legacy map backfill skips when no candidate holds sync credentials", (cont
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
|
||||
@@ -589,7 +590,7 @@ test("legacy map backfill binds sole credential-backed owner when maps conflict"
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
@@ -618,7 +619,7 @@ test("legacy map backfill does not let longest map override credential-backed pa
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
@@ -648,7 +649,7 @@ test("legacy map backfill skips when multiple credential-backed candidates exist
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
|
||||
@@ -672,7 +673,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -683,7 +684,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
@@ -711,7 +712,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
@@ -723,7 +724,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
@@ -750,7 +751,7 @@ test("legacy map backfill does not resurrect after explicit unbind tombstone", (
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
privateOwner(LOCAL_SOURCE, "com.example"),
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
@@ -823,7 +824,7 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
}
|
||||
// Parent prefix alone is not enough: a removed plugin may have owned
|
||||
// com.example.backup.sync while only com.example still has credentials.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Pinned static distribution schemas
|
||||
|
||||
`delivery.schema.json` and `index.schema.json` are unmodified UTF-8 files from
|
||||
binaricat/netcatty-plugins commit 7bfe23e5627e16ba63806de6dded5b2667c02984
|
||||
(PR 3). The host compiles them with the existing local plugin-contract schema;
|
||||
no schema reference causes network access.
|
||||
|
||||
`../fixtures/static-distribution/` contains that commit's actual
|
||||
`distribution/bootstrap/v1/snapshot.json`, snapshot catalog/index/delivery and
|
||||
four referenced README/PNG assets. Raw-byte SHA-256 checks in
|
||||
`staticDistribution.test.cjs` verify the publisher's supplied pointer and delivery
|
||||
hashes; every document and asset is checked against the pointer/sidecar.
|
||||
The fixture includes no executable plugin package, avatar or credentials.
|
||||
|
||||
Schema revisions must be coordinated with the registry publisher. In particular,
|
||||
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Netcatty static delivery v1 (source-only bootstrap)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"registryCommit",
|
||||
"plugins",
|
||||
"catalogSha256",
|
||||
"indexSha256",
|
||||
"sourceLockSha256",
|
||||
"builderSha256"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"registryCommit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"repository",
|
||||
"commit",
|
||||
"readme",
|
||||
"iconLight",
|
||||
"iconDark",
|
||||
"avatar",
|
||||
"artifact"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"readme": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
},
|
||||
"iconLight": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"iconDark": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"avatar": {
|
||||
"type": "null"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalogSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"indexSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"sourceLockSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"builderSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"plugins"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": 1
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"current",
|
||||
"versions"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[a-z0-9][a-z0-9.-]+$"
|
||||
},
|
||||
"current": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"versions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"entry",
|
||||
"distribution",
|
||||
"withdrawal"
|
||||
],
|
||||
"properties": {
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"name",
|
||||
"description",
|
||||
"author",
|
||||
"repository",
|
||||
"commit",
|
||||
"license",
|
||||
"readme",
|
||||
"manifest"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[a-z0-9][a-z0-9.-]+$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"readme": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"manifest": {
|
||||
"$ref": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json"
|
||||
},
|
||||
"artifact": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"url",
|
||||
"sha256"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"distribution": {
|
||||
"enum": [
|
||||
"source-only",
|
||||
"release"
|
||||
]
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
},
|
||||
"withdrawal": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"at",
|
||||
"reason",
|
||||
"advisory"
|
||||
],
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4096
|
||||
},
|
||||
"advisory": {
|
||||
"type": "string",
|
||||
"pattern": "^https://github.com/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const { isDeepStrictEqual } = require("node:util");
|
||||
const Ajv2020 = require("ajv/dist/2020").default;
|
||||
const addFormats = require("ajv-formats").default;
|
||||
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
|
||||
const { fetchBounded, validateCatalog, DISTRIBUTION_ORIGIN, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const { formatValidationErrors } = require("./contractValidator.cjs");
|
||||
const REGISTRY_URL = `${DISTRIBUTION_ORIGIN}/v1/snapshot.json`;
|
||||
const SHA256 = /^[a-f0-9]{64}$/u;
|
||||
const COMMIT = /^[a-f0-9]{40}$/u;
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: true });
|
||||
addFormats(ajv);
|
||||
ajv.addSchema(require("./generated/plugin-contract.schema.json"));
|
||||
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
|
||||
const deliverySchema = require("./distribution/delivery.schema.json");
|
||||
const validateDelivery = ajv.compile(deliverySchema);
|
||||
const deliveryV2 = structuredClone(deliverySchema);
|
||||
deliveryV2.title = "Netcatty static delivery v2 (hash-bound packages)";
|
||||
deliveryV2.properties.schemaVersion.const = 2;
|
||||
const packageRef = structuredClone(deliveryV2.properties.plugins.items.properties.readme);
|
||||
packageRef.properties.path.pattern = "^/assets/[a-f0-9]{64}\\.ncpkg$";
|
||||
packageRef.properties.size.maximum = 32 * 1024 * 1024;
|
||||
deliveryV2.properties.plugins.items.properties.artifact = { oneOf: [{ type: "null" }, packageRef] };
|
||||
const validateDeliveryV2 = ajv.compile(deliveryV2);
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
|
||||
|
||||
function exactKeys(value, keys) {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
&& Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
||||
}
|
||||
function assertReference(ref, path, limit) {
|
||||
if (!exactKeys(ref, ["path", "sha256", "size"]) || typeof ref.sha256 !== "string" || !SHA256.test(ref.sha256)
|
||||
|| ref.path !== path || !Number.isSafeInteger(ref.size) || ref.size < 0 || ref.size > limit) {
|
||||
throw new Error("Invalid static snapshot resource reference");
|
||||
}
|
||||
}
|
||||
function parseJson(bytes, label, limit = MAX_CATALOG_BYTES) {
|
||||
const value = JSON.parse(bytes.toString("utf8"));
|
||||
assertPluginJsonValue(value, { maxBytes: limit });
|
||||
if (!value || typeof value !== "object") throw new Error(`Invalid ${label}`);
|
||||
return value;
|
||||
}
|
||||
function assertPointer(pointer) {
|
||||
if (!exactKeys(pointer, ["schemaVersion", "registryCommit", "snapshotId", "catalog", "index", "delivery"])
|
||||
|| pointer.schemaVersion !== 1 || typeof pointer.registryCommit !== "string" || !COMMIT.test(pointer.registryCommit)
|
||||
|| typeof pointer.snapshotId !== "string" || !SHA256.test(pointer.snapshotId)) {
|
||||
throw new Error("Invalid static snapshot pointer");
|
||||
}
|
||||
for (const name of ["catalog", "index", "delivery"]) {
|
||||
assertReference(pointer[name], `/snapshots/${pointer.snapshotId}/${name}.json`, MAX_CATALOG_BYTES);
|
||||
}
|
||||
if (pointer.snapshotId !== pointer.delivery.sha256) throw new Error("Snapshot identity does not match delivery hash");
|
||||
}
|
||||
function assertAsset(ref, extensions) {
|
||||
if (!ref || typeof ref.path !== "string" || !extensions.some((extension) => ref.path === `/assets/${ref.sha256}.${extension}`)) {
|
||||
throw new Error("Invalid static asset path or format");
|
||||
}
|
||||
assertReference(ref, ref.path, 256 * 1024);
|
||||
}
|
||||
async function fetchVerified(ref, fetchImpl, origin = DISTRIBUTION_ORIGIN) {
|
||||
const bytes = await fetchBounded(`${origin}${ref.path}`, ref.size, fetchImpl, origin);
|
||||
if (bytes.byteLength !== ref.size || digest(bytes) !== ref.sha256) throw new Error("Static resource size or SHA-256 mismatch");
|
||||
return bytes;
|
||||
}
|
||||
function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_ORIGIN) {
|
||||
validateCatalog(entries, origin);
|
||||
if (!validateIndex(index)) throw new Error(`Invalid distribution index: ${formatValidationErrors(validateIndex.errors)}`);
|
||||
const validator = delivery.schemaVersion === 2 ? validateDeliveryV2 : validateDelivery;
|
||||
if (!validator(delivery)) throw new Error(`Invalid static delivery: ${formatValidationErrors(validator.errors)}`);
|
||||
if (delivery.registryCommit !== pointer.registryCommit || delivery.catalogSha256 !== pointer.catalog.sha256
|
||||
|| delivery.indexSha256 !== pointer.index.sha256) throw new Error("Static snapshot documents do not match");
|
||||
// v1 publishing deliberately refuses packages and avatars. Do not extend this
|
||||
// consumer ahead of the reviewed publishing schema or invent installable code.
|
||||
if (delivery.schemaVersion === 1 && entries.some((entry) => entry.artifact)) throw new Error("Static delivery v1 is source-only");
|
||||
const current = new Map();
|
||||
const indexIds = new Set();
|
||||
for (const plugin of index.plugins) {
|
||||
if (indexIds.has(plugin.id)) throw new Error("Duplicate distribution index plugin");
|
||||
indexIds.add(plugin.id);
|
||||
const versions = new Map();
|
||||
for (const version of plugin.versions) {
|
||||
if (version.entry.id !== plugin.id || versions.has(version.entry.version)) throw new Error("Invalid distribution version identity");
|
||||
versions.set(version.entry.version, version);
|
||||
}
|
||||
if (plugin.current !== null) {
|
||||
const version = versions.get(plugin.current);
|
||||
if (!version || version.withdrawal !== null || (version.distribution !== (version.entry.artifact ? "release" : "source-only"))) throw new Error("Current distribution version is missing or withdrawn");
|
||||
current.set(plugin.id, version.entry);
|
||||
}
|
||||
}
|
||||
if (current.size !== entries.length || entries.some((entry) => !isDeepStrictEqual(current.get(entry.id), entry))) {
|
||||
throw new Error("Catalog does not match the current non-withdrawn index");
|
||||
}
|
||||
const resources = new Map();
|
||||
const catalogTuples = new Set(entries.map(tuple));
|
||||
for (const plugin of delivery.plugins) {
|
||||
const key = tuple(plugin);
|
||||
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
|
||||
assertAsset(plugin.readme, ["md"]);
|
||||
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
|
||||
const entry = entries.find((item) => tuple(item) === key);
|
||||
if (plugin.artifact !== null) {
|
||||
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 32 * 1024 * 1024);
|
||||
if (!entry.artifact || entry.artifact.sha256 !== plugin.artifact.sha256 || entry.artifact.url !== `${origin}${plugin.artifact.path}`) throw new Error("Package delivery does not match catalog hash and source");
|
||||
} else if (entry.artifact) throw new Error("Catalog package has no delivery resource");
|
||||
resources.set(key, plugin);
|
||||
}
|
||||
if (resources.size !== entries.length) throw new Error("Missing delivery entry for catalog plugin");
|
||||
return resources;
|
||||
}
|
||||
async function fetchDistribution(fetchImpl, origin = DISTRIBUTION_ORIGIN) {
|
||||
const pointer = parseJson(await fetchBounded(`${origin}/v1/snapshot.json`, 16 * 1024, fetchImpl, origin), "snapshot pointer", 16 * 1024);
|
||||
assertPointer(pointer);
|
||||
const documents = await Promise.all(["catalog", "index", "delivery"].map(async (name) => parseJson(await fetchVerified(pointer[name], fetchImpl, origin), name)));
|
||||
const [entries, index, delivery] = documents;
|
||||
const resources = validateSet(pointer, entries, index, delivery, origin);
|
||||
return { entries, resources, pointer };
|
||||
}
|
||||
module.exports = { REGISTRY_URL, fetchDistribution, fetchVerified, tuple, validateSet, assertPointer, assertAsset };
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Hello Netcatty
|
||||
|
||||
This package is the runnable internal example for the plugin API. Package and install it to exercise native settings,
|
||||
command-palette contributions, lazy command and Provider activation,
|
||||
localization, terminal completion/decoration Providers, and the runtime SDK.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
|
||||
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/hello-netcatty --netcatty 0.0.0
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
|
||||
```
|
||||
|
||||
After installation, change **Greeting** under **Settings → Plugins**, then run
|
||||
**Examples: Say Hello** from the command palette. The setting is read through
|
||||
the host settings broker and the command handler is registered inside the
|
||||
sandboxed plugin runtime. Type `netc` at a shell prompt to exercise the bounded
|
||||
completion Provider, and print `Hello from Netcatty` to exercise the declarative
|
||||
decoration Provider. Neither Provider receives raw xterm objects or sensitive
|
||||
terminal input/output streams.
|
||||
|
||||
## Import and inspect
|
||||
|
||||
Launch the normal browser-only plugin build using a fresh Electron
|
||||
user-data directory for demonstrations. In **Settings > Plugins**, choose
|
||||
**Import .ncpkg**, select the generated archive, and open its details. Review the
|
||||
manifest permissions, engine ranges and SHA-256, then choose **Enable**. Native
|
||||
permission prompts still mediate activation and sensitive operations.
|
||||
|
||||
Importing an update is a manual action and leaves it disabled, including when
|
||||
the previous version was enabled. Review the new package before enabling it.
|
||||
Changing unsigned package contents changes its security principal; previous
|
||||
persistent grants are not transferred to different bytes.
|
||||
|
||||
An incompatible application/API upgrade disables installed plugins before
|
||||
publishing their contributions. The reason remains in the installed list, and
|
||||
package files and local data remain available. Returning to a compatible host
|
||||
requires explicit re-enable. Uninstall is different: package code and local KV
|
||||
are deleted; user-owned settings, secret storage and sync sidecars are retained.
|
||||
|
||||
For a synthetic demonstration, use only this first-party example, change the
|
||||
Greeting, invoke **Examples: Say Hello**, disable/re-enable, restart the app and
|
||||
inspect the package again. Do not connect real hosts or a cloud account. The
|
||||
existing greeting setting is local; declaring `sync: true` on a non-secret
|
||||
setting opts it into the existing encrypted settings sidecar. Generic SDK KV
|
||||
storage does not sync.
|
||||
|
||||
For a demonstration without terminal permissions, use `examples/plugins/starter-netcatty`.
|
||||
Enabling this lazy example does not grant permissions: required permissions are
|
||||
requested before its first command/provider activation, and optional permissions
|
||||
when used. Never treat Enabled as a security certification or grant.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
+37
@@ -0,0 +1,37 @@
|
||||
# Plugin Starter
|
||||
|
||||
A minimal first-party browser plugin for learning import, settings and lifecycle.
|
||||
It requests `commands`, `settings.read`, `menus` and `views`: no terminal text, host credentials,
|
||||
network, files, secrets or advanced Node access. It works without OS key storage.
|
||||
Permission declarations are not grants; follow the app's normal mediation.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm run build:plugin-packages
|
||||
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
|
||||
```
|
||||
|
||||
Start the actual app normally with a fresh test profile; no development flag is
|
||||
needed. Open **Settings > Plugins > Open plugin manager > Installed**, import the package, inspect its
|
||||
permissions and enable it. Change **Greeting**, then find **Starter: Greeting Panel** (入门:问候面板)
|
||||
in the command palette. Review the normal permission prompts, then click
|
||||
**Show greeting** to see the exact preference in the sandboxed panel.
|
||||
**Starter: Read Greeting** is also listed and refreshes an already-open panel.
|
||||
The panel inserts text using `textContent`, never HTML. It does not write to
|
||||
a terminal or connect to a host. Denied permissions remain denied.
|
||||
|
||||
Disable, restart and re-enable to verify the preference persists. Re-importing
|
||||
the same package leaves it disabled for review. Uninstall removes code and local
|
||||
KV; user-owned settings remain. The greeting is local only, not cloud-synced.
|
||||
|
||||
The separate Hello Netcatty example demonstrates terminal providers and requests
|
||||
terminal permissions. Use this Starter for the default no-sensitive-access demo.
|
||||
|
||||
|
||||
Version 0.1.2 adds light/dark PNG brand icons in `assets/`. Displaying these
|
||||
images does not activate the plugin or request permissions. This example now
|
||||
requires API `>=0.1.0-internal.1 <0.2.0` and `metadata.brand-icon`.
|
||||
Older Starter 0.1.1 packages remain compatible with the new host.
|
||||
See [brand icon author instructions](../../../docs/plugin-platform/plugin-icons.md).
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 873 B |
@@ -0,0 +1,195 @@
|
||||
[
|
||||
{
|
||||
"id": "com.netcatty.starter",
|
||||
"version": "0.1.2",
|
||||
"name": "Plugin Starter",
|
||||
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/starter-netcatty/README.md",
|
||||
"manifest": {
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.starter",
|
||||
"name": "starter-netcatty",
|
||||
"displayName": {
|
||||
"en": "Plugin Starter",
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"version": "0.1.2",
|
||||
"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": [
|
||||
"onCommand:com.netcatty.starter.greet",
|
||||
"onView:com.netcatty.starter.panel"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"views"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greeting",
|
||||
"label": {
|
||||
"en": "Greeting",
|
||||
"zh-CN": "问候语"
|
||||
},
|
||||
"description": "A local preference for the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello, synthetic world!"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greet",
|
||||
"title": {
|
||||
"en": "Starter: Read Greeting",
|
||||
"zh-CN": "入门:读取问候语"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.starter.greet",
|
||||
"location": "commandPalette",
|
||||
"group": "examples"
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.starter.panel",
|
||||
"title": {
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"metadata.brand-icon"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"kind": "package",
|
||||
"light": "assets/logo-light.png",
|
||||
"dark": "assets/logo-dark.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello",
|
||||
"version": "0.1.0",
|
||||
"name": "Hello Netcatty",
|
||||
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/hello-netcatty/README.md",
|
||||
"manifest": {
|
||||
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.hello",
|
||||
"name": "hello-netcatty",
|
||||
"displayName": {
|
||||
"en": "Hello Netcatty",
|
||||
"zh-CN": "你好,Netcatty"
|
||||
},
|
||||
"description": "Minimal plugin contract and SDK example.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.hello.sayHello",
|
||||
"onProvider:com.netcatty.hello.completion",
|
||||
"onProvider:com.netcatty.hello.decoration",
|
||||
"onProvider:com.netcatty.hello.theme"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"provider.terminal",
|
||||
"terminal.complete",
|
||||
"terminal.output",
|
||||
"terminal.decorate"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.hello.greeting",
|
||||
"label": "Greeting",
|
||||
"description": "Text used by the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello from Netcatty"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.hello.sayHello",
|
||||
"title": "Say Hello",
|
||||
"category": "Examples"
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.hello.sayHello",
|
||||
"location": "commandPalette",
|
||||
"group": "examples",
|
||||
"order": 10
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"id": "com.netcatty.hello.completion",
|
||||
"label": "Hello completion",
|
||||
"description": "Suggests the example hello command.",
|
||||
"kind": "terminal.completion"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.decoration",
|
||||
"label": "Hello decoration",
|
||||
"description": "Highlights the example greeting in terminal output.",
|
||||
"kind": "terminal.decoration"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.theme",
|
||||
"label": "Hello theme",
|
||||
"description": "Adds a subtle cursor color to the example terminal theme.",
|
||||
"kind": "terminal.theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"builderSha256": "57199cc6a5d30f5f356adb072d51de9e2283dac6e26c647a4404e4c2261c4a70",
|
||||
"catalogSha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
|
||||
"indexSha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
|
||||
"plugins": [
|
||||
{
|
||||
"artifact": null,
|
||||
"avatar": null,
|
||||
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
|
||||
"iconDark": {
|
||||
"path": "/assets/7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe.png",
|
||||
"sha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe",
|
||||
"size": 873
|
||||
},
|
||||
"iconLight": {
|
||||
"path": "/assets/ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947.png",
|
||||
"sha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
|
||||
"size": 873
|
||||
},
|
||||
"id": "com.netcatty.starter",
|
||||
"readme": {
|
||||
"path": "/assets/d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e.md",
|
||||
"sha256": "d685ec68558c81705f03195ca35a0a62ea6ec75d8837d5ddb5b5a970890aae6e",
|
||||
"size": 2050
|
||||
},
|
||||
"repository": "binaricat/Netcatty",
|
||||
"version": "0.1.2"
|
||||
},
|
||||
{
|
||||
"artifact": null,
|
||||
"avatar": null,
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"iconDark": null,
|
||||
"iconLight": null,
|
||||
"id": "com.netcatty.hello",
|
||||
"readme": {
|
||||
"path": "/assets/1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209.md",
|
||||
"sha256": "1692573445e3688b4fe3612282becc086b191a034c0609deeaaebf615627c209",
|
||||
"size": 2951
|
||||
},
|
||||
"repository": "binaricat/Netcatty",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
],
|
||||
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
|
||||
"schemaVersion": 1,
|
||||
"sourceLockSha256": "753711c1860ff0fcc10a558182f8c868d9d06b40e5c8de5522249d0d27b139d6"
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"plugins": [
|
||||
{
|
||||
"id": "com.netcatty.starter",
|
||||
"current": "0.1.2",
|
||||
"versions": [
|
||||
{
|
||||
"entry": {
|
||||
"id": "com.netcatty.starter",
|
||||
"version": "0.1.2",
|
||||
"name": "Plugin Starter",
|
||||
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/starter-netcatty/README.md",
|
||||
"manifest": {
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.starter",
|
||||
"name": "starter-netcatty",
|
||||
"displayName": {
|
||||
"en": "Plugin Starter",
|
||||
"zh-CN": "插件入门"
|
||||
},
|
||||
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
|
||||
"version": "0.1.2",
|
||||
"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": [
|
||||
"onCommand:com.netcatty.starter.greet",
|
||||
"onView:com.netcatty.starter.panel"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"views"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greeting",
|
||||
"label": {
|
||||
"en": "Greeting",
|
||||
"zh-CN": "问候语"
|
||||
},
|
||||
"description": "A local preference for the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello, synthetic world!"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.starter.greet",
|
||||
"title": {
|
||||
"en": "Starter: Read Greeting",
|
||||
"zh-CN": "入门:读取问候语"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.starter.greet",
|
||||
"location": "commandPalette",
|
||||
"group": "examples"
|
||||
}
|
||||
],
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.starter.panel",
|
||||
"title": {
|
||||
"en": "Starter: Greeting Panel",
|
||||
"zh-CN": "入门:问候面板"
|
||||
},
|
||||
"location": "modal",
|
||||
"entry": "panel.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"metadata.brand-icon"
|
||||
]
|
||||
},
|
||||
"icon": {
|
||||
"kind": "package",
|
||||
"light": "assets/logo-light.png",
|
||||
"dark": "assets/logo-dark.png"
|
||||
}
|
||||
}
|
||||
},
|
||||
"distribution": "source-only",
|
||||
"withdrawal": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello",
|
||||
"current": "0.1.0",
|
||||
"versions": [
|
||||
{
|
||||
"entry": {
|
||||
"id": "com.netcatty.hello",
|
||||
"version": "0.1.0",
|
||||
"name": "Hello Netcatty",
|
||||
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
|
||||
"author": "Netcatty",
|
||||
"repository": "binaricat/Netcatty",
|
||||
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"readme": "examples/plugins/hello-netcatty/README.md",
|
||||
"manifest": {
|
||||
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.hello",
|
||||
"name": "hello-netcatty",
|
||||
"displayName": {
|
||||
"en": "Hello Netcatty",
|
||||
"zh-CN": "你好,Netcatty"
|
||||
},
|
||||
"description": "Minimal plugin contract and SDK example.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal <0.2.0"
|
||||
},
|
||||
"main": {
|
||||
"browser": "dist/index.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.hello.sayHello",
|
||||
"onProvider:com.netcatty.hello.completion",
|
||||
"onProvider:com.netcatty.hello.decoration",
|
||||
"onProvider:com.netcatty.hello.theme"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"settings.read",
|
||||
"menus",
|
||||
"provider.terminal",
|
||||
"terminal.complete",
|
||||
"terminal.output",
|
||||
"terminal.decorate"
|
||||
],
|
||||
"optional": []
|
||||
},
|
||||
"contributes": {
|
||||
"settings": [
|
||||
{
|
||||
"id": "com.netcatty.hello.greeting",
|
||||
"label": "Greeting",
|
||||
"description": "Text used by the example command.",
|
||||
"control": "text",
|
||||
"scope": "application",
|
||||
"default": "Hello from Netcatty"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "com.netcatty.hello.sayHello",
|
||||
"title": "Say Hello",
|
||||
"category": "Examples"
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"command": "com.netcatty.hello.sayHello",
|
||||
"location": "commandPalette",
|
||||
"group": "examples",
|
||||
"order": 10
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"id": "com.netcatty.hello.completion",
|
||||
"label": "Hello completion",
|
||||
"description": "Suggests the example hello command.",
|
||||
"kind": "terminal.completion"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.decoration",
|
||||
"label": "Hello decoration",
|
||||
"description": "Highlights the example greeting in terminal output.",
|
||||
"kind": "terminal.decoration"
|
||||
},
|
||||
{
|
||||
"id": "com.netcatty.hello.theme",
|
||||
"label": "Hello theme",
|
||||
"description": "Adds a subtle cursor color to the example terminal theme.",
|
||||
"kind": "terminal.theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"distribution": "source-only",
|
||||
"withdrawal": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"catalog": {
|
||||
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/catalog.json",
|
||||
"sha256": "47e967516437996e00eac8fbe97699baa72439ba1bf7ae791731e4964bb13774",
|
||||
"size": 5739
|
||||
},
|
||||
"delivery": {
|
||||
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/delivery.json",
|
||||
"sha256": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55",
|
||||
"size": 1869
|
||||
},
|
||||
"index": {
|
||||
"path": "/snapshots/53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55/index.json",
|
||||
"sha256": "af2e953e3c631b36a0e712806d0dcfb0a058fd02f2051025eddded8cc115d461",
|
||||
"size": 7726
|
||||
},
|
||||
"registryCommit": "32578ae27ff86f0151511478d698a1cdc12fb2e0",
|
||||
"schemaVersion": 1,
|
||||
"snapshotId": "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { createHash } = require("node:crypto");
|
||||
const root = path.join(__dirname, "static-distribution");
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const read = (name) => fs.readFileSync(path.join(root, name));
|
||||
const catalog = JSON.parse(read("catalog.json"));
|
||||
const delivery = JSON.parse(read("delivery.json"));
|
||||
function realFiles() {
|
||||
const pointer = JSON.parse(read("snapshot.json"));
|
||||
const files = new Map([["/v1/snapshot.json", read("snapshot.json")]]);
|
||||
for (const name of ["catalog", "index", "delivery"]) files.set(pointer[name].path, read(`${name}.json`));
|
||||
for (const name of fs.readdirSync(path.join(root, "assets"))) files.set(`/assets/${name}`, read(`assets/${name}`));
|
||||
return files;
|
||||
}
|
||||
function makeFiles(entries = catalog, mutate = () => {}) {
|
||||
const index = { schemaVersion: 1, plugins: entries.map((entry) => ({ id: entry.id, current: entry.version, versions: [{ entry: structuredClone(entry), distribution: "source-only", withdrawal: null }] })) };
|
||||
const sidecar = { ...structuredClone(delivery), plugins: entries.map((entry) => ({ ...structuredClone(delivery.plugins.find((item) => item.id === entry.id) ?? delivery.plugins[0]), id: entry.id, version: entry.version, repository: entry.repository, commit: entry.commit })) };
|
||||
mutate(index, sidecar);
|
||||
const catalogBytes = Buffer.from(JSON.stringify(entries));
|
||||
const indexBytes = Buffer.from(JSON.stringify(index));
|
||||
sidecar.catalogSha256 = digest(catalogBytes); sidecar.indexSha256 = digest(indexBytes);
|
||||
const deliveryBytes = Buffer.from(JSON.stringify(sidecar));
|
||||
const snapshotId = digest(deliveryBytes);
|
||||
const pointer = { schemaVersion: 1, registryCommit: sidecar.registryCommit, snapshotId };
|
||||
const files = realFiles();
|
||||
for (const [name, bytes] of [["catalog", catalogBytes], ["index", indexBytes], ["delivery", deliveryBytes]]) {
|
||||
const ref = { path: `/snapshots/${snapshotId}/${name}.json`, size: bytes.length, sha256: digest(bytes) };
|
||||
pointer[name] = ref; files.set(ref.path, bytes);
|
||||
}
|
||||
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
|
||||
return files;
|
||||
}
|
||||
function responseFor(files, url) {
|
||||
if (new URL(url).origin !== "https://plugins.netcatty.app") throw new Error("Unexpected upstream origin");
|
||||
const bytes = files.get(new URL(url).pathname);
|
||||
return bytes ? new Response(bytes) : new Response("Missing fixture", { status: 404 });
|
||||
}
|
||||
module.exports = { root, read, digest, catalog, delivery, realFiles, makeFiles, responseFor };
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -23,12 +21,15 @@ const {
|
||||
createUtilityModuleMappings,
|
||||
normalizePluginModuleResources,
|
||||
} = require("./moduleResources.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { MarketSourcesService } = require("./marketSourcesService.cjs");
|
||||
const { PluginThemeService } = require("./themeService.cjs");
|
||||
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 { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
|
||||
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
@@ -39,7 +40,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 +93,12 @@ 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, 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;
|
||||
let aiHostAdapter;
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -103,7 +106,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 +146,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
|
||||
@@ -211,36 +212,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 +240,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 +256,34 @@ 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);
|
||||
aiHostAdapter = options.pluginAIHostAdapter ?? new PluginAITrustedHostAdapter({
|
||||
getWindow: options.getInteractionWindow ?? (() => null),
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
dialog: options.electron.dialog,
|
||||
});
|
||||
if (options.electron.ipcMain) aiHostAdapter.registerIpc?.(options.electron.ipcMain);
|
||||
aiContextService = new PluginAIContextService({ database, permissionEngine,
|
||||
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
|
||||
hostAdapter: aiHostAdapter,
|
||||
});
|
||||
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,
|
||||
@@ -401,33 +401,31 @@ function createPluginHostService(options) {
|
||||
: null;
|
||||
const themeService = new PluginThemeService({database,packageStore,contributionService});
|
||||
const manager = new PluginManager({
|
||||
secretStore,
|
||||
permissionEngine,
|
||||
database,
|
||||
packageStore,
|
||||
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,
|
||||
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
contributionService,
|
||||
@@ -444,6 +442,8 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
protocol,
|
||||
networkBroker,
|
||||
byteStreamService,
|
||||
aiContextService,
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
rpcRegistry,
|
||||
@@ -455,7 +455,9 @@ function createPluginHostService(options) {
|
||||
};
|
||||
} catch (error) {
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
void logService.close();
|
||||
try { byteStreamService?.shutdown(); } catch {}
|
||||
try { aiContextService?.shutdown(); } catch {}
|
||||
if (!aiContextService) { try { aiHostAdapter?.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);
|
||||
});
|
||||
@@ -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,42 @@
|
||||
"use strict";
|
||||
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { createSourceFetch } = require("./sourceFetch.cjs");
|
||||
|
||||
class MarketSourcesService {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.manager = options.manager;
|
||||
this.registry = new PluginSourceRegistry(options.database);
|
||||
this.catalogs = new Map();
|
||||
}
|
||||
#catalog(sourceId = "official") {
|
||||
const source = this.registry.get(sourceId);
|
||||
let catalog = this.catalogs.get(source.id);
|
||||
if (!catalog) {
|
||||
catalog = new RegistryCatalogService({ ...this.options, source,
|
||||
fetch: source.id === "official" ? this.options.fetch : (this.options.createSourceFetch ?? createSourceFetch)(source.origin),
|
||||
sourceGuard: () => this.registry.get(source.id),
|
||||
});
|
||||
this.catalogs.set(source.id, catalog);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
list(sourceId) { return this.#catalog(sourceId).list(); }
|
||||
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
|
||||
install(id, token, sourceId = "official", acceptedSourceId) {
|
||||
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
|
||||
return this.#catalog(sourceId).install(id, token);
|
||||
}
|
||||
listSources() { return this.registry.list(); }
|
||||
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
|
||||
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
|
||||
async removeSource(payload) {
|
||||
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
|
||||
await this.manager.changeMarketSource(() => {
|
||||
this.registry.remove(payload.id);
|
||||
this.catalogs.delete(payload.id);
|
||||
}, payload.disablePlugins ? payload.id : undefined);
|
||||
}
|
||||
}
|
||||
module.exports = { MarketSourcesService };
|
||||
@@ -0,0 +1,40 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { MarketSourcesService } = require("./marketSourcesService.cjs");
|
||||
const { realFiles } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
|
||||
test("source removal retains installed plugins, optional disabling is serialized, and no source silently takes over", async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "market-sources-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const database = new PluginDatabase(path.join(root, "db.sqlite"));
|
||||
t.after(() => database.close());
|
||||
const stopped = [];
|
||||
const manager = new PluginManager({ database, packageStore: { async initialize() {} }, runtimeSupervisor: {
|
||||
async startEnabled() {}, async stop(id) { stopped.push(id); },
|
||||
} });
|
||||
const requests = [];
|
||||
const service = new MarketSourcesService({ database, manager, createSourceFetch: (origin) => async (url) => {
|
||||
assert.equal(new URL(url).origin, origin); requests.push(url);
|
||||
return new Response(realFiles().get(new URL(url).pathname));
|
||||
}, fetch: async () => { assert.fail("third-party browsing must not fetch official upstream"); } });
|
||||
const source = await service.addSource({ name: "Community", address: "https://community.example.com" });
|
||||
const entry = (await service.list(source.id)).entries[0];
|
||||
database.installVersion({ pluginId: entry.id, version: entry.version, manifest: entry.manifest, source, archiveSha256: "a".repeat(64), packageRelativePath: "test" }, { enable: true });
|
||||
await assert.rejects(async () => service.install(entry.id, entry.selectionToken, source.id), /confirm/);
|
||||
await service.updateSource({ id: source.id, name: "Renamed", enabled: false });
|
||||
const count = requests.length;
|
||||
assert.throws(() => service.list(source.id), /disabled/);
|
||||
assert.equal(database.getActivePlugin(entry.id).enabled, true);
|
||||
await service.removeSource({ id: source.id, disablePlugins: true });
|
||||
assert.equal(database.getActivePlugin(entry.id).enabled, false);
|
||||
assert.equal(database.getActivePlugin(entry.id).source.id, source.id);
|
||||
assert.deepEqual(stopped, [entry.id]);
|
||||
assert.throws(() => service.readme(entry.id, entry.selectionToken, source.id), /removed/);
|
||||
assert.equal(requests.length, count);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
"use strict";
|
||||
// Include the isolated domain suites in npm run test:plugin-runtime.
|
||||
require("./ai/modelInvocationBroker.test.cjs");
|
||||
require("./context/selectedContextAuthority.test.cjs");
|
||||
require("./ai/sourceBinding.test.cjs");
|
||||
require("./ai/trustedHostAdapter.test.cjs");
|
||||
require("./ai/aiContextService.test.cjs");
|
||||
require("./ai/aiContextEntrypoint.test.cjs");
|
||||
@@ -1,5 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const { LOCAL_SOURCE, assertSource } = require("./sourceIdentity.cjs");
|
||||
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const { constants } = fs;
|
||||
@@ -127,6 +129,7 @@ function validateInstallMetadata(value) {
|
||||
version,
|
||||
archiveSha256: value.archiveSha256,
|
||||
contentSha256: value.contentSha256,
|
||||
source: assertSource(value.source ?? LOCAL_SOURCE),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,6 +229,7 @@ class PackageStore {
|
||||
const pluginId = assertPluginStorageSegment(manifest.id, "ID");
|
||||
const version = assertPluginStorageSegment(manifest.version, "version");
|
||||
const targetDirectory = resolveInstalledVersionDirectory(this.paths, pluginId, version);
|
||||
const source = this.database.assertInstallSource(pluginId, options.source ?? LOCAL_SOURCE);
|
||||
const previousPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (options.catalog) {
|
||||
if (snapshot.sha256 !== options.catalog.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
|
||||
@@ -266,6 +270,7 @@ class PackageStore {
|
||||
if (previousPlugin?.activeVersion !== version || options.enable === false) await prepareActivation("switch-active-version");
|
||||
const existingPackage = path.join(targetDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
source,
|
||||
pluginId,
|
||||
version,
|
||||
manifest: existingValidation.manifest,
|
||||
@@ -282,6 +287,7 @@ class PackageStore {
|
||||
if (!(error && error.code === "ENOENT")) throw error;
|
||||
}
|
||||
const metadata = {
|
||||
source,
|
||||
pluginId,
|
||||
version,
|
||||
archiveSha256: snapshot.sha256,
|
||||
@@ -299,6 +305,7 @@ class PackageStore {
|
||||
);
|
||||
try {
|
||||
this.database.installVersion({
|
||||
source,
|
||||
pluginId,
|
||||
version,
|
||||
manifest,
|
||||
@@ -391,6 +398,7 @@ class PackageStore {
|
||||
if (metadata.pluginId !== pluginEntry.name || metadata.version !== versionEntry.name) {
|
||||
throw new Error("Plugin install metadata does not match its directory");
|
||||
}
|
||||
this.database.assertInstallSource(metadata.pluginId, metadata.source);
|
||||
if (committedVersion && committedVersion.archiveSha256 !== metadata.archiveSha256) {
|
||||
throw new Error("Plugin install archive hash does not match its database record");
|
||||
}
|
||||
@@ -406,6 +414,7 @@ class PackageStore {
|
||||
if (!committedVersion) {
|
||||
const packageDirectory = path.join(versionDirectory, PACKAGE_DIRECTORY);
|
||||
this.database.installVersion({
|
||||
source: metadata.source,
|
||||
pluginId: metadata.pluginId,
|
||||
version: metadata.version,
|
||||
manifest: validation.manifest,
|
||||
@@ -579,6 +588,12 @@ class PackageStore {
|
||||
}
|
||||
}
|
||||
|
||||
async clearUninstalledPrivateFiles(pluginId) {
|
||||
assertPluginStorageSegment(pluginId, "ID");
|
||||
if (this.database.getActivePlugin(pluginId)) throw new Error("Plugin is still installed");
|
||||
await rm(path.join(this.paths.data, pluginId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async uninstall(pluginId) {
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin) return false;
|
||||
|
||||
@@ -201,7 +201,7 @@ class PluginPermissionEngine {
|
||||
}
|
||||
|
||||
#grantKey(pluginId, permission, declarationHash, resource) {
|
||||
return `${pluginId}\0${permission}\0${declarationHash}\0${resource}`;
|
||||
return `${this.database.privateOwner?.(pluginId) ?? pluginId}\0${permission}\0${declarationHash}\0${resource}`;
|
||||
}
|
||||
|
||||
#sessionGrantKey(sessionId, grantKey) {
|
||||
@@ -219,6 +219,7 @@ class PluginPermissionEngine {
|
||||
) {
|
||||
return Object.freeze({
|
||||
pluginId: context.pluginId,
|
||||
sourceOwner: this.database.privateOwner?.(context.pluginId) ?? context.pluginId,
|
||||
permission,
|
||||
declarationHash,
|
||||
resource,
|
||||
@@ -239,6 +240,7 @@ class PluginPermissionEngine {
|
||||
sessionId,
|
||||
) {
|
||||
return grant.pluginId === context.pluginId
|
||||
&& grant.sourceOwner === (this.database.privateOwner?.(context.pluginId) ?? context.pluginId)
|
||||
&& grant.permission === permission
|
||||
&& grant.declarationHash === declarationHash
|
||||
&& (grant.scope !== "session" || grant.sessionId === sessionId)
|
||||
@@ -637,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,16 +20,17 @@ 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",
|
||||
permissions: "netcatty:plugins:permissions",
|
||||
revokePermissions: "netcatty:plugins:revoke-permissions",
|
||||
resetSource: "netcatty:plugins:reset-source",
|
||||
sourceBinding: "netcatty:plugins:source-binding",
|
||||
sources: "netcatty:plugins:sources",
|
||||
addSource: "netcatty:plugins:add-source",
|
||||
updateSource: "netcatty:plugins:update-source",
|
||||
removeSource: "netcatty:plugins:remove-source",
|
||||
catalog: "netcatty:plugins:catalog",
|
||||
catalogReadme: "netcatty:plugins:catalog-readme",
|
||||
catalogInstall: "netcatty:plugins:catalog-install",
|
||||
@@ -664,14 +665,18 @@ function registerPluginBridge(ipcMain, options) {
|
||||
? { credentialStorageAvailable: credentialResolver.isAvailable() } : {}),
|
||||
};
|
||||
});
|
||||
handle(CHANNELS.catalog, async () => options.catalogService?.list() ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
|
||||
handle(CHANNELS.sources, async () => options.catalogService?.listSources() ?? []);
|
||||
handle(CHANNELS.addSource, async (_manager, payload) => options.catalogService.addSource(payload));
|
||||
handle(CHANNELS.updateSource, async (_manager, payload) => options.catalogService.updateSource(payload));
|
||||
handle(CHANNELS.removeSource, async (_manager, payload) => options.catalogService.removeSource(payload));
|
||||
handle(CHANNELS.catalog, async (_manager, payload) => options.catalogService?.list(payload?.sourceId) ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
|
||||
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.readme(payload?.id, payload?.selectionToken);
|
||||
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
|
||||
});
|
||||
handle(CHANNELS.catalogInstall, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.install(payload?.id, payload?.selectionToken);
|
||||
return options.catalogService.install(payload?.id, payload?.selectionToken, payload?.sourceId, payload?.acceptedSourceId);
|
||||
});
|
||||
handle(CHANNELS.interactionReady, async (_manager, payload, event) => {
|
||||
if (!options.interactionService) throw new Error("Host interactions are unavailable");
|
||||
@@ -682,6 +687,8 @@ function registerPluginBridge(ipcMain, options) {
|
||||
return options.interactionService.respond(event.sender, payload);
|
||||
});
|
||||
handle(CHANNELS.list, async (activeManager) => activeManager.list());
|
||||
handle(CHANNELS.sourceBinding, async (activeManager, payload) => activeManager.database.getRetainedPluginSource(assertManagedPluginId(payload)));
|
||||
handle(CHANNELS.resetSource, async (activeManager, payload) => activeManager.resetUninstalledSource(assertManagedPluginId(payload), payload?.expectedSourceId));
|
||||
const assertManagedPluginId = (payload) => {
|
||||
const id = payload?.pluginId;
|
||||
if (!validateManagedPluginId(id)) {
|
||||
@@ -689,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 };
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user