Compare commits

..
Author SHA1 Message Date
陈奇 2a9c310498 Merge frozen plugin sources and logging for native QA
Source parent: 4213d8e672
Logging parent: 9b853130ca

Ordinary merge with no manual conflicts or additional source edits.
2026-10-26 15:14:08 +00:00
陈奇 9b853130ca fix(plugins): bind log provenance and enforce segment retention 2026-10-26 15:00:54 +00:00
Codex 4213d8e672 fix(plugins): distinguish third-party catalog status copy 2026-10-26 14:48:46 +00:00
Codex 59721f9717 fix(plugins): pin sync owners across source lifecycle awaits 2026-10-26 14:37:25 +00:00
陈奇 e3cc23bca8 feat(plugins): add opt-in local diagnostics and host audit logs 2026-10-26 14:26:41 +00:00
Codex ab8764a317 Merge reviewed theme security color aliases into source integration 2026-10-26 14:06:02 +00:00
Codex 7e72ae65ad test(plugins): assert catalog operations bind official source 2026-10-26 14:02:00 +00:00
Codex f2e878858f test(plugins): drain dialog autofocus cleanup before restoring DOM globals 2026-10-26 13:49:24 +00:00
Codex 2ff86c2217 fix(plugins): restore opener focus after source and install dialogs 2026-10-26 13:42:31 +00:00
Codex 5e6278cbc7 fix(plugins): distinguish installed packages from mirrored source entries 2026-10-26 13:34:59 +00:00
Codex 83952aac0d fix(plugins): resolve source-scoped credential owners for provider recovery 2026-10-26 13:31:36 +00:00
Codex 7ec6473112 fix(plugins): retain local provenance through source schema upgrades 2026-10-26 13:28:34 +00:00
Codex 17e6834f74 test(plugins): include source binding in catalog interaction fixtures 2026-10-26 13:19:46 +00:00
Codex 9ed8bf76dd feat(plugins): bind third-party markets to isolated source identities 2026-10-26 13:15:52 +00:00
Codex 1e5e77fb9c merge: integrate plugin static distribution and capability theme stack 2026-10-26 12:34:54 +00:00
Codex d0e2c905cb feat(plugins): bind static reads to versioned snapshot sidecar 2026-10-26 09:13:45 +00:00
Codex c11cde9bb2 feat(plugins): use official static distribution for marketplace reads 2026-10-26 08:57:23 +00:00
81 changed files with 4521 additions and 501 deletions

No files matched your search

+22
View File
@@ -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…',
+22
View File
@@ -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…',
+22
View File
@@ -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': 'Загрузка настроек плагинов…',
+22
View File
@@ -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': '正在加载插件设置…',
+22
View File
@@ -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": "作者尚未提供功能簡介。",
+2 -2
View File
@@ -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());
});
+20 -8
View File
@@ -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); }
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginLogs() {
const [status, setStatus] = useState<NetcattyPluginLogStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(true);
const run = useCallback(async (action: 'status' | 'configure' | 'open' | 'export' | 'clear', settings?: { saveToDisk: boolean; verbose: boolean }) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
const result = await (action === 'configure' ? bridge.configurePluginLogs?.(settings!)
: action === 'open' ? bridge.openPluginLogFolder?.()
: action === 'export' ? bridge.exportPluginLogs?.()
: action === 'clear' ? bridge.clearPluginLogs?.() : bridge.getPluginLogStatus?.());
if (!result) throw new Error('Local plugin logging is unavailable');
if (mounted.current) setStatus(result);
} catch (cause) {
let message = cause instanceof Error ? cause.message : String(cause);
if (action === 'configure') {
// Configuration fails closed before disk I/O. Show actual host state,
// even when persisting a setting failed, rather than keeping stale ON.
try {
const actual = await netcattyBridge.require().getPluginLogStatus?.();
if (!actual) throw new Error('Local plugin log status is unavailable');
if (mounted.current) setStatus(actual);
} catch (refreshError) {
if (mounted.current) setStatus(null);
message += `\n${refreshError instanceof Error ? refreshError.message : String(refreshError)}`;
}
}
if (mounted.current) setError(message);
} finally {
pending.current = false;
if (mounted.current) setBusy(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void run('status');
return () => { mounted.current = false; };
}, [run]);
return { status, error, busy, run };
}
+7 -2
View File
@@ -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);
}
}),
};
}
+25
View File
@@ -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)),
};
}
@@ -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);
@@ -0,0 +1,66 @@
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);
}
}
});
@@ -0,0 +1,31 @@
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/);
});
+24 -8
View File
@@ -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 -3
View File
@@ -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,9 +1,11 @@
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';
@@ -289,7 +291,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>>>({});
@@ -310,11 +312,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} />}
@@ -322,7 +325,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 && (
@@ -400,6 +404,7 @@ 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,87 @@
# Local logging validation
Base: PR #3978 at `91b8927c56314e68c06e2c008e84559ddfb96d78`.
All data used in these checks is synthetic and each persistence test creates its
own temporary profile. No real logs or credentials were opened.
| Check | Result |
| --- | --- |
| `node --test electron/plugins/*.test.cjs` | 561 passed, 2 expected skips (Electron live test and the #3981 database integration test on this older base) |
| New logging settings, existing settings and permission-panel interaction tests | 4 passed |
| `npm run build` | Passed; existing large-chunk warning |
| `npm run lint` | No errors, 7 existing warnings; final changed-source lint also passed |
| `npm run check:plugin-contract` | Passed, no generated drift |
| `npx tsc --noEmit` against this change and the exact base | 773 diagnostics in each, zero added or removed after normalizing worktree paths |
| `git diff --check` | Passed |
Coverage includes disabled and enabled persistence, opt-in plugin text, sensitive
field exclusion, host source attribution, unknown-source fallback, URL rejection,
spoofed metadata, cyclic/accessor/proxy input, noisy writers, shared queue and disk
limits, rotation, expiration, drop/error counts, export, clear confirmation,
disable/revoke, stale runtime callbacks, and manager shutdown. The filesystem
regressions cover direct paths, symlink aliases, a symlinked profile directory,
and revocation during the final protected-storage check.
## Remaining native checks
Electron launch failed before application/profile startup. The executable at
`node_modules/electron/dist/electron --version` aborted with:
> The SUID sandbox helper binary was found, but is not configured correctly.
Its message requires `chrome-sandbox` to be root-owned with mode 4755. No sandbox
switch, privilege escalation, permission repair, or native retry followed. Native
smoke execution, screenshots, and a short recording remain pending. The acceptance
flow is in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
`npm run pack:dir` completed its production build, then failed during serialport
native rebuilding: node-gyp returned ENOENT for mkdir
`/home/agent/.electron-gyp`. No system-directory change or packaging retry followed.
A native package was not produced.
## Parent integration
The only shared settings-page change is one import and one `<PluginLogSettings />`
element. Preserve that element when integrating the marketplace task's page.
There are no Installed, Official, Third-party, source-database, or unrelated host
data changes.
The default host-owned resolver is wired through `createPluginHostService`. It
consumes #3981's confirmed API without editing its database or sync implementation.
On the older #3987 base, missing source APIs remain explicitly unknown. No shared
transport-registration change is needed beyond the existing logging registration.
## Source and review follow-up
A separate worktree at `59721f97172acffffe960a6268a2916a90a6c554` received only
the logging backend overlay. Its logging/source/host regression command passed
33 tests with no skips, including the real database replacement, encoded local-owner roundtrip and
shutdown-drain case. The product branch does not import marketplace changes or bump its database
schema.
Independent review of the implementation follow-up found no remaining blocking
findings. Independent runs passed 50 focused regressions with one expected source
skip, 20 real-source integration regressions with no skips, and the logging UI
interaction test. The reviewer made no edits and did not launch Electron. A further independent
review of the newer encoded-local-owner contract found no issues; all six
provenance tests passed on exact `59721f97172acffffe960a6268a2916a90a6c554`,
with an additional probe confirming the local encoded owner.
Fixed review findings have deterministic regressions: 31 days of low-volume
appends and export cannot extend segment retention via mtime; stale sources cannot
rotate away existing evidence; settings-write failure shows actual host state;
log draining still completes when another shutdown stage rejects; and activation
permission denial produces a fixed host record before runtime creation.
Exact prior head `e3cc23bca81a4187ccc13b90d3882889d524ca3b` passed remote
[test 40339839263](https://github.com/binaricat/Netcatty/actions/runs/40339839263)
and [build-packages 40339839660](https://github.com/binaricat/Netcatty/actions/runs/40339839660).
Those runs do not validate the follow-up head. Its exact-head CI is reported in
the PR after pushing.
The native fixture build is reproducible: 3,270 bytes, SHA-256
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
The concise flow and commands are in [Local plugin logging](local-logging.md#native-qa-with-synthetic-data).
Native screenshots and recording remain assigned to the parent's working cloud
GUI machine. No local sandbox bypass or packaging retry is authorized by this
handoff.
+178
View File
@@ -0,0 +1,178 @@
# 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.
+101
View File
@@ -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.
+74 -46
View File
@@ -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.
+1
View File
@@ -245,6 +245,7 @@ function createBridgeRegistrar(context) {
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
themeService: pluginHostService?.themeService,
logService: pluginHostService?.logService,
viewHost: pluginHostService?.viewHost,
env: process.env,
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
+57 -54
View File
@@ -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 };
+4 -12
View File
@@ -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-");
+127 -9
View File
@@ -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;
+14 -13
View File
@@ -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.
+16
View File
@@ -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/"
}
}
}
]
}
}
}
}
}
}
}
}
}
+122
View File
@@ -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 };
+26
View File
@@ -187,6 +187,8 @@ function assertSameOpenedDirectory(expectedStats, pathStats) {
class PluginFilesystemBroker {
constructor(options = {}) {
this.quotaManager = options.quotaManager ?? null;
this.protectedDirectories = options.protectedDirectories ?? [];
this.protectedFiles = options.protectedFiles ?? [];
this.fileSystem = options.fileSystem ?? fsp;
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
if (this.openDirectoryHandle != null && typeof this.openDirectoryHandle !== "function") {
@@ -261,10 +263,33 @@ class PluginFilesystemBroker {
}
}
async #assertWritable(canonical) {
const variants = async (entry) => {
const lexical = path.resolve(entry);
try { return [lexical, await this.fileSystem.realpath(lexical)]; }
catch (error) { if (error?.code !== "ENOENT") throw error; return [lexical]; }
};
for (const file of this.protectedFiles) {
if ((await variants(file)).includes(canonical)) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
for (const directory of this.protectedDirectories) {
for (const parent of await variants(directory)) {
const relative = path.relative(parent, canonical);
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Host audit storage is protected");
}
}
}
}
async writeFile(params, context) {
const value = assertSecureWriteMode(assertWriteParams(params));
await this.#assertWritable(path.resolve(value.path));
assertAuthorizedPath(context, "filesystem.write", value.path);
const { canonical, stats } = await resolveWritePath(value.path, this.fileSystem);
await this.#assertWritable(canonical);
assertAuthorizedPath(context, "filesystem.write", canonical);
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", value.bytes.byteLength);
await context.assertActive();
@@ -278,6 +303,7 @@ class PluginFilesystemBroker {
]);
assertSameOpenedFile(openedStats, stats);
assertSameOpenedFile(openedStats, pathStats);
await this.#assertWritable(canonical);
await context.assertActive();
if (value.overwrite) await handle.truncate(0);
await handle.writeFile(value.bytes);
@@ -390,3 +390,61 @@ test("filesystem broker rejects oversized and non-canonical payloads", async (co
(error) => error.code === RPC_ERRORS.resourceExhausted,
);
});
test("plugin writes cannot modify host audit files or preferences, including symlink aliases", async (t) => {
const root = createRoot(t);
const directory = path.join(root, "host-logs");
await fsp.mkdir(directory);
const audit = path.join(directory, "runtime.jsonl");
const preferences = path.join(root, "local-logging.json");
await fsp.writeFile(audit, "host audit");
await fsp.writeFile(preferences, "host preferences");
const alias = path.join(root, "alias");
await fsp.symlink(audit, alias);
const broker = new PluginFilesystemBroker({ protectedDirectories: [directory], protectedFiles: [preferences] });
for (const target of [audit, preferences, alias]) {
const authorization = broker.describeWriteAuthorization({ path: target, data: "overwrite", overwrite: true });
await assert.rejects(broker.writeFile({ path: target, data: "overwrite", overwrite: true }, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
}
assert.equal(await fsp.readFile(audit, "utf8"), "host audit");
assert.equal(await fsp.readFile(preferences, "utf8"), "host preferences");
});
test("host audit protection resolves a symlinked userData directory", async (t) => {
const root = createRoot(t);
const actual = path.join(root, "actual-profile");
await fsp.mkdir(path.join(actual, "logs"), { recursive: true });
const profileAlias = path.join(root, "profile-alias");
await fsp.symlink(actual, profileAlias);
const target = path.join(actual, "logs", "runtime.jsonl");
await fsp.writeFile(target, "host audit");
const broker = new PluginFilesystemBroker({ protectedDirectories: [path.join(profileAlias, "logs")] });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
await assert.rejects(broker.writeFile(params, runtimeContext(authorization)), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "host audit");
});
test("revocation during the protected-storage check cannot write an unrelated granted file", async (t) => {
const root = createRoot(t);
const logs = path.join(root, "host-logs");
const target = path.join(root, "synthetic.txt");
await fsp.mkdir(logs);
await fsp.writeFile(target, "prior");
let checks = 0;
let active = true;
const broker = new PluginFilesystemBroker({ protectedDirectories: [logs], fileSystem: {
...fsp, async realpath(requested) {
const canonical = await fsp.realpath(requested);
if (requested === logs && ++checks === 3) active = false;
return canonical;
},
} });
const params = { path: target, data: "overwrite", overwrite: true };
const authorization = broker.describeWriteAuthorization(params);
const context = { ...runtimeContext(authorization), async assertActive() {
if (!active) throw Object.assign(new Error("synthetic revocation"), { code: RPC_ERRORS.permissionDenied });
} };
await assert.rejects(broker.writeFile(params, context), { code: RPC_ERRORS.permissionDenied });
assert.equal(await fsp.readFile(target, "utf8"), "prior");
});
@@ -0,0 +1,38 @@
import { definePlugin } from "@netcatty/plugin-sdk";
export default definePlugin({
activate(context) {
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.example.local-logging-fixture.${suffix}`, handler,
));
register("emit", () => {
context.logger.info("Synthetic diagnostic for local logging QA", {
apiKey: "SYNTHETIC-API-KEY-NOT-A-CREDENTIAL",
prompt: "SYNTHETIC-PROMPT-BODY",
Notes: "SYNTHETIC-NOTE-BODY",
SQL: "SYNTHETIC-SQL-BODY",
results: "SYNTHETIC-RESULT-BODY",
terminalContent: "SYNTHETIC-TERMINAL-BODY",
pluginId: "synthetic-spoofed-plugin",
sourceId: "official",
runtimeId: "synthetic-spoofed-runtime",
});
return { ok: true, synthetic: true };
});
register("noise", () => {
for (let index = 0; index < 400; index++) console.info(`Synthetic diagnostic burst ${index}`);
return { ok: true, synthetic: true, emitted: 400 };
});
register("denied", async () => {
// No network permission is declared. The host must reject before any
// network I/O or approval prompt; no body or credential is ever supplied.
try {
await context.network.request({ method: "GET", url: "https://logging-fixture.example.com/should-never-be-sent" });
throw new Error("Synthetic undeclared request unexpectedly succeeded");
} catch (error) {
if (error?.code !== "permission_denied" && error?.code !== -32007) throw error;
return { denied: true, synthetic: true };
}
});
},
});
@@ -0,0 +1,29 @@
{
"manifestVersion": 1,
"id": "com.example.local-logging-fixture",
"name": "local-logging-fixture",
"displayName": "Synthetic local logging fixture",
"description": "Synthetic diagnostics and denied-operation checks. No real data or credentials.",
"version": "0.1.0",
"publisher": "example",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"main": { "browser": "index.js" },
"activationEvents": [
"onCommand:com.example.local-logging-fixture.emit",
"onCommand:com.example.local-logging-fixture.noise",
"onCommand:com.example.local-logging-fixture.denied"
],
"permissions": { "required": ["commands", "menus"], "optional": [] },
"contributes": {
"commands": [
{ "id": "com.example.local-logging-fixture.emit", "title": "Emit synthetic diagnostic", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.noise", "title": "Emit synthetic diagnostic burst", "category": "Logging QA" },
{ "id": "com.example.local-logging-fixture.denied", "title": "Test undeclared network denial", "category": "Logging QA" }
],
"menus": [
{ "command": "com.example.local-logging-fixture.emit", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.noise", "location": "commandPalette" },
{ "command": "com.example.local-logging-fixture.denied", "location": "commandPalette" }
]
}
}
@@ -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.
@@ -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).
@@ -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 };
+66 -38
View File
@@ -175,45 +175,73 @@ class PluginHostRpcRegistry {
await assertCurrent?.();
transportContext.signal?.throwIfAborted();
};
await assertActive();
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
const startedAt = Date.now();
let authorizationOutcome = "not-reached";
let auditResources;
let auditResult;
let auditError;
try {
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
return result;
const validatedParams = registration.validateParams
? registration.validateParams(params)
: params;
if (validatedParams && typeof validatedParams.then === "function") {
throw new TypeError("Plugin host RPC parameter validators must be synchronous");
}
const authorizationContext = Object.freeze({
...transportContext,
...identity,
assertActive,
kind,
method,
metadata: registration.metadata,
recordAuthorization: (outcome) => {
if (["allowed", "denied", "not-required"].includes(outcome)) authorizationOutcome = outcome;
},
params: validatedParams,
});
const resolvedAuthorization = typeof registration.authorization === "function"
? await registration.authorization(validatedParams, authorizationContext)
: registration.authorization;
const authorization = resolvedAuthorization == null
? null
: cloneAndFreezeMetadata(resolvedAuthorization);
auditResources = authorization?.resources;
await assertActive();
const context = Object.freeze({
...authorizationContext,
authorization,
});
let index = -1;
const dispatch = async (nextIndex) => {
if (nextIndex <= index) throw new Error("Plugin host RPC middleware called next() more than once");
index = nextIndex;
const current = middleware[nextIndex];
if (current) return current(context, () => dispatch(nextIndex + 1));
await assertActive();
return registration.handler(validatedParams, context);
};
const result = await dispatch(0);
await assertActive();
auditResult = result;
return result;
} catch (error) {
auditError = error;
throw error;
} finally {
// Never record params, handler results, error messages or plugin text.
const dataBytes = (value) => {
if (typeof value === "string") return Buffer.byteLength(value);
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value.byteLength;
return null;
};
const field = (value, key) => value && typeof value === "object"
? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
try { identity.logger?.audit?.({ operation: method, resources: auditResources,
authorization: authorizationOutcome, result: auditError ? "error" : "ok",
errorCode: auditError?.code, durationMs: Date.now() - startedAt,
inputBytes: dataBytes(field(params, "data")), outputBytes: dataBytes(field(auditResult, "data")) }); } catch {}
}
};
const requestHandlers = Object.freeze(Object.fromEntries(
[...this.requests].map(([method, registration]) => [
+63 -28
View File
@@ -1,6 +1,8 @@
"use strict";
const path = require("node:path");
const { PluginLogService } = require("./pluginLogService.cjs");
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
@@ -21,7 +23,7 @@ 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");
@@ -37,6 +39,7 @@ const {
resolveDefaultRuntimeKind,
} = require("./runtimeSupervisor.cjs");
const { PluginQuotaManager } = require("./quotaManager.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const { registerSecurePluginCapabilities } = require("./secureCapabilities.cjs");
const { PluginSecretStore } = require("./secretStore.cjs");
const { SecretLeaseStore } = require("./secretLease.cjs");
@@ -92,6 +95,7 @@ function createPluginHostService(options) {
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const database = new PluginDatabase(paths.database);
let interactionService;
const logService = new PluginLogService({ paths, electron: options.electron, resolveProvenance: options.resolvePluginProvenance ?? createPluginLogProvenanceResolver(database) });
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -139,6 +143,8 @@ function createPluginHostService(options) {
const filesystemBroker = new PluginFilesystemBroker({
quotaManager,
openDirectoryHandle: options.openPluginDirectoryHandle,
protectedDirectories: [paths.logs],
protectedFiles: [logService.preferencesPath, `${logService.preferencesPath}.tmp`],
});
const networkBroker = new PluginNetworkBroker({
fetch: options.fetch ?? (options.electron.net?.fetch
@@ -205,25 +211,36 @@ function createPluginHostService(options) {
|| !context.availableKinds.includes(kind)
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
skipPermissions: ["runtime.advanced"],
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
try {
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
skipPermissions: ["runtime.advanced"],
});
if (kind === "utility") {
await permissionEngine.authorize({
pluginId: context.plugin.id,
pluginVersion: context.plugin.activeVersion,
runtimeId: null,
manifest: context.plugin.manifest,
securityPrincipal: context.securityPrincipal,
signal: context.signal,
}, {
permission: "runtime.advanced",
resources: ["*"],
reason: "Start an advanced runtime with full Node, filesystem, and network authority",
operationId: `runtime.advanced:${context.plugin.activeVersion}`,
});
}
} catch (error) {
const identity = logService.identity({
pluginId: context.plugin.id, pluginVersion: context.plugin.activeVersion,
runtimeId: null, runtimeKind: kind, securityPrincipal: context.securityPrincipal,
}, context.plugin);
logService.audit(identity, { operation: "runtime.authorization", result: "error",
authorization: error?.code === RPC_ERRORS.permissionDenied ? "denied" : "unknown",
errorCode: error?.code });
throw error;
}
return kind;
};
@@ -233,6 +250,7 @@ function createPluginHostService(options) {
};
runtimeSupervisor = new RuntimeSupervisor({
electron: options.electron,
logService,
database,
packageStore,
protocol,
@@ -256,6 +274,16 @@ function createPluginHostService(options) {
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] : [],
});
});
const terminalProviderService = new PluginTerminalProviderService({
contributionService,
permissionEngine,
@@ -373,28 +401,34 @@ function createPluginHostService(options) {
: null;
const themeService = new PluginThemeService({database,packageStore,contributionService});
const manager = new PluginManager({
secretStore,
permissionEngine,
database,
packageStore,
runtimeSupervisor,
contributionService,
beforeClose: async () => {
themeService.shutdown();
interactionService.shutdown();
terminalDataPipelineService?.shutdown();
extensionProviderService.shutdown();
credentialResolver.shutdown?.();
await viewHost?.shutdown();
await companionSupervisor.shutdown();
leaseStore.shutdown();
permissionEngine.shutdown();
quotaManager.shutdown();
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();
}
},
});
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,
@@ -422,6 +456,7 @@ function createPluginHostService(options) {
};
} catch (error) {
try { interactionService?.shutdown(); } catch {}
void logService.close();
database.close();
throw error;
}
+80
View File
@@ -401,3 +401,83 @@ test("normal release blocks a custom utility placement before permission prompts
}), /requires developer mode/);
assert.equal(prompts, 0);
});
test("host service logs revocation with injected provenance and drains through manager shutdown", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-host-"));
let seen;
const service = createPluginHostService({ ...createOptions(root), resolvePluginProvenance(input) {
seen = input;
return { sourceId: "synthetic-host-source", namespace: "test", sourceKind: "local" };
} });
t.after(async () => { await service.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-audit", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" } };
service.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: "b".repeat(64), packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await service.logService.ready;
assert.equal(service.logService.status().saveToDisk, false);
await service.logService.configure({ saveToDisk: true, verbose: false });
service.permissionEngine.revokeAll(manifest.id);
await service.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(service.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "permission.revoked");
assert.equal(record.sourceId, "synthetic-host-source");
assert.equal(record.archiveSha256, "b".repeat(64));
assert.equal(record.pluginVersion, "1.0.0");
assert.equal(record.runtimeId, null);
assert.equal(seen.pluginId, manifest.id);
assert.equal(seen.archiveSha256, "b".repeat(64));
await service.manager.shutdown();
assert.equal(service.logService.sink.closed, true);
});
test("logging always drains before database close when another shutdown stage rejects", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-log-shutdown-failure-"));
const host = createPluginHostService(createOptions(root));
t.after(async () => { await host.manager.shutdown().catch(() => {}); fs.rmSync(root, { recursive: true, force: true }); });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
let resume;
const gate = new Promise((resolve) => { resume = resolve; });
host.logService.sink.queue = host.logService.sink.queue.then(() => gate);
const logger = host.logService.createLogger({ pluginId: "com.example.synthetic", pluginVersion: "1.0.0", runtimeId: "host-shutdown", runtimeKind: "browser" }, {});
logger.audit({ operation: "synthetic.before-shutdown", result: "ok" });
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
let settled = false;
const shutdown = host.manager.shutdown().finally(() => { settled = true; });
// Attach the rejection assertion before yielding so the expected failure is handled.
const rejected = assert.rejects(shutdown, /synthetic companion shutdown failure/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.doesNotThrow(() => host.database.db.prepare("SELECT 1").get());
resume();
await rejected;
assert.equal(host.logService.sink.closed, true);
assert.equal(host.logService.retentionTimer._destroyed, true);
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "synthetic.before-shutdown");
});
test("activation permission denial is audited before a runtime logger exists", async (t) => {
const { defaultSecurityPrincipal } = require("./permissionResources.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-synthetic-activation-log-"));
const host = createPluginHostService(createOptions(root));
t.after(async () => { await host.manager.shutdown(); fs.rmSync(root, { recursive: true, force: true }); });
const manifest = { manifestVersion: 1, id: "com.example.synthetic-activation", version: "1.0.0", name: "Synthetic", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["commands"] } };
const archiveSha256 = "a".repeat(64);
host.database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256, packageRelativePath: `${manifest.id}/${manifest.version}/package` }, { enable: true });
await host.logService.ready;
await host.logService.configure({ saveToDisk: true, verbose: false });
await assert.rejects(host.runtimeSupervisor.resolveRuntimeKind({
plugin: host.database.getActivePlugin(manifest.id), availableKinds: ["browser"], signal: new AbortController().signal,
securityPrincipal: defaultSecurityPrincipal(manifest, archiveSha256),
}), { code: RPC_ERRORS.permissionDenied });
await host.logService.sink.flush();
const record = JSON.parse(await fsp.readFile(host.logService.sink.file(0), "utf8"));
assert.equal(record.operation, "runtime.authorization");
assert.equal(record.authorization, "denied");
assert.equal(record.runtimeId, null);
assert.equal(record.errorCode, RPC_ERRORS.permissionDenied);
assert.equal(record.archiveSha256, archiveSha256);
assert.equal(Object.hasOwn(record, "message"), false);
assert.equal(host.runtimeSupervisor.getRuntimeIdentity(manifest.id), null);
});
@@ -0,0 +1,34 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const test = require("node:test");
test("native logging fixture packs reproducibly and exposes only synthetic commands", async (t) => {
const root = path.join(__dirname, "fixtures", "local-logging-plugin");
const output = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-logging-package-"));
t.after(() => fs.rm(output, { recursive: true, force: true }));
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
await buildPluginPackage(root, path.join(output, "one.ncpkg"));
await buildPluginPackage(root, path.join(output, "two.ncpkg"));
assert.deepEqual(await fs.readFile(path.join(output, "one.ncpkg")), await fs.readFile(path.join(output, "two.ncpkg")));
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
assert.deepEqual(manifest.permissions, { required: ["commands", "menus"], optional: [] });
const plugin = (await import(pathToFileURL(path.join(root, "index.js")).href)).default;
const commands = new Map();
const logs = [];
let deniedRequests = 0;
plugin.activate({
subscriptions: { add() {} },
commands: { registerCommand(id, handler) { commands.set(id, handler); } },
logger: { info(...args) { logs.push(args); } },
network: { async request(request) { assert.equal(request.method, "GET"); assert.equal(request.body, undefined); deniedRequests++; throw Object.assign(new Error("synthetic permission denial"), { code: "permission_denied" }); } },
});
assert.deepEqual(await commands.get(`${manifest.id}.emit`)(), { ok: true, synthetic: true });
assert.match(logs[0][0], /Synthetic/);
assert.equal(logs[0][1].sourceId, "official", "spoofed field is deliberately nested plugin text, never host provenance");
assert.deepEqual(await commands.get(`${manifest.id}.denied`)(), { denied: true, synthetic: true });
assert.equal(deniedRequests, 1);
});
+42
View File
@@ -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);
});
+15
View File
@@ -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;
+15 -3
View File
@@ -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,11 +639,21 @@ class PluginPermissionEngine {
createMiddleware() {
return async (context, next) => {
if (context.metadata.public === true) return next();
if (context.metadata.public === true) {
context.recordAuthorization?.("not-required");
return next();
}
if (!context.authorization) {
context.recordAuthorization?.("denied");
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
}
await this.authorize(context, context.authorization);
try {
await this.authorize(context, context.authorization);
} catch (error) {
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
throw error;
}
context.recordAuthorization?.("allowed");
return next();
};
}
+28 -3
View File
@@ -20,11 +20,22 @@ 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",
@@ -659,14 +670,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");
@@ -677,6 +692,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)) {
@@ -684,6 +701,14 @@ function registerPluginBridge(ipcMain, options) {
}
return id;
};
for (const [channel, action] of [
[CHANNELS.logStatus, "status"], [CHANNELS.logConfigure, "configure"],
[CHANNELS.logOpen, "openFolder"], [CHANNELS.logExport, "exportLogs"], [CHANNELS.logClear, "clear"],
]) handle(channel, async (_manager, payload) => {
if (!options.logService) throw new Error("Local plugin logging is unavailable");
await options.logService.ready;
return options.logService[action](payload);
});
handle(CHANNELS.permissions, async (_manager, payload) => {
const pluginId = assertManagedPluginId(payload);
if (!options.permissionEngine || !options.database) throw new Error("Plugin permissions are unavailable");
+14
View File
@@ -1721,3 +1721,17 @@ test('theme catalog and selection remain trusted app management channels',async(
for(const channel of [CHANNELS.themeCatalog,CHANNELS.themeApply])await assert.rejects(ipc.handlers.get(channel)({trusted:false},{}),/Untrusted/);
assert.equal(calls,0);await ipc.handlers.get(CHANNELS.themeCatalog)({trusted:true});await ipc.handlers.get(CHANNELS.themeApply)({trusted:true},{});assert.equal(calls,2);
});
test("local log management rejects plugin/untrusted senders before touching the sink", async () => {
const ipcMain = createIpcMain();
const actions = [];
const logService = { ready: Promise.resolve() };
for (const action of ["status", "configure", "openFolder", "exportLogs", "clear"]) logService[action] = async () => { actions.push(action); return { saveToDisk: false }; };
registerPluginBridge(ipcMain, { manager: { async initialize() {} }, logService, isTrustedSender: (event) => event?.trusted === true });
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) {
await assert.rejects(ipcMain.handlers.get(channel)({}, {}), /Untrusted/);
assert.equal(actions.length, 0);
}
for (const channel of [CHANNELS.logStatus, CHANNELS.logConfigure, CHANNELS.logOpen, CHANNELS.logExport, CHANNELS.logClear]) await ipcMain.handlers.get(channel)({ trusted: true }, {});
assert.deepEqual(actions, ["status", "configure", "openFolder", "exportLogs", "clear"]);
});
+36
View File
@@ -0,0 +1,36 @@
"use strict";
/**
* Host-only adapter. Call with the host-assigned activation identity after the
* caller guard; never pass plugin request params, manifest source claims or an
* owner supplied by a plugin. Missing source APIs mean unknown provenance.
*/
function createPluginLogProvenanceResolver(database) {
const methods = ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"];
const resolve = (identity) => {
if (!methods.every((method) => typeof database?.[method] === "function")) return null;
const plugin = database.getActivePlugin(identity.pluginId);
if (!plugin || plugin.id !== identity.pluginId
|| plugin.activeVersion !== identity.pluginVersion
|| plugin.archiveSha256 !== identity.archiveSha256) return null;
const source = plugin.source;
const retained = database.getRetainedPluginSource(identity.pluginId);
if (!source || !retained || source.id !== retained.id || source.kind !== retained.kind) return null;
const owner = database.privateOwner(identity.pluginId);
if (typeof owner !== "string" || !owner || owner === identity.pluginId || database.publicPluginId(owner) !== identity.pluginId) return null;
return { sourceId: source.id, sourceKind: source.kind, namespace: owner };
};
return (identity) => {
const initial = resolve(identity);
if (!initial) return null;
return Object.freeze({ ...initial, isCurrent: () => {
try {
const current = resolve(identity);
return current !== null && current.sourceId === initial.sourceId
&& current.sourceKind === initial.sourceKind && current.namespace === initial.namespace;
} catch { return false; }
} });
};
}
module.exports = { createPluginLogProvenanceResolver };
@@ -0,0 +1,70 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { createPluginHostService } = require("./hostService.cjs");
test("real source database replacement never reattributes old runtime log records", {
skip: typeof PluginDatabase.prototype.getRetainedPluginSource !== "function" && "requires the source API from PR #3981; run on the documented integration worktree",
}, async (t) => {
const { thirdPartySource, OFFICIAL_SOURCE } = require("./sourceIdentity.cjs");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-integration-"));
const host = createPluginHostService({
app: { getPath: () => root, getAppPath: () => process.cwd(), getVersion: () => "0.0.0" },
electron: {}, env: {},
});
const database = host.database;
const pluginId = "com.example.source-log-fixture";
// The current source contract encodes every namespace, including local.
const { LOCAL_SOURCE } = require("./sourceIdentity.cjs");
database.installVersion({ pluginId: "com.example.local-source-fixture", version: "1.0.0", manifest: {}, archiveSha256: "b".repeat(64), packageRelativePath: "com.example.local-source-fixture/1.0.0", source: LOCAL_SOURCE });
const localOwner = database.privateOwner("com.example.local-source-fixture");
assert.notEqual(localOwner, "com.example.local-source-fixture");
assert.equal(database.publicPluginId(localOwner), "com.example.local-source-fixture");
const first = thirdPartySource("Synthetic first source", "https://synthetic-first.example.com");
const install = (source) => database.installVersion({ pluginId, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
install(first);
const service = host.logService;
t.after(async () => { await host.manager.shutdown().catch(() => {}); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: true });
const activation = { pluginId, pluginVersion: "1.0.0", runtimeId: "source-fixture-A", runtimeKind: "browser" };
const firstLogger = service.createLogger(activation, database.getActivePlugin(pluginId));
firstLogger.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
await service.sink.flush();
const oldOwner = database.privateOwner(pluginId);
let resume;
const queueGate = new Promise((resolve) => { resume = resolve; });
service.sink.queue = service.sink.queue.then(() => queueGate);
await firstLogger.write("info", "synthetic queued before replacement");
database.removePlugin(pluginId);
assert.equal(database.getRetainedPluginSource(pluginId).id, first.id);
database.clearUninstalledSourceData(pluginId, first.id);
install(OFFICIAL_SOURCE);
assert.equal(database.publicPluginId(oldOwner), null);
resume();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 1);
const replacement = service.createLogger({ ...activation, runtimeId: "source-fixture-B" }, database.getActivePlugin(pluginId));
replacement.audit({ operation: "runtime.running", result: "ok", authorization: "not-required" });
await service.sink.flush();
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 2);
assert.equal(records[0].sourceId, first.id);
assert.equal(records[0].namespace, oldOwner);
assert.equal(records[1].sourceId, "official");
assert.equal(records[1].namespace, database.privateOwner(pluginId));
assert.equal(Object.hasOwn(records[1], "officiallyMaintained"), false);
assert.doesNotMatch(JSON.stringify(records), /queued before replacement|synthetic-first.example.com/);
replacement.audit({ operation: "synthetic.before-shutdown", result: "ok" });
host.companionSupervisor.shutdown = async () => { throw new Error("synthetic companion shutdown failure"); };
await assert.rejects(host.manager.shutdown(), /synthetic companion shutdown failure/);
assert.equal(service.sink.closed, true);
const drained = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(drained.at(-1).operation, "synthetic.before-shutdown");
assert.equal(drained.at(-1).sourceId, "official");
});
@@ -0,0 +1,149 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createPluginLogProvenanceResolver } = require("./pluginLogProvenance.cjs");
const { PluginLogService } = require("./pluginLogService.cjs");
function sourceFixture(pluginId = "com.example.synthetic-source-logging") {
const identity = { pluginId, pluginVersion: "1.0.0", archiveSha256: "a".repeat(64), runtimeId: "host-runtime-A", runtimeKind: "browser" };
let source = { id: `third-party:${"b".repeat(64)}`, kind: "third-party", name: "Synthetic source", origin: "https://synthetic.example.com" };
let active = { id: pluginId, activeVersion: identity.pluginVersion, archiveSha256: identity.archiveSha256, source };
let retained = source;
const owner = () => source ? `source:${source.id === "official" ? "c".repeat(64) : "d".repeat(64)}:${pluginId}` : null;
const database = {
getActivePlugin(id) { assert.equal(id, pluginId); return active; },
getRetainedPluginSource(id) { assert.equal(id, pluginId); return retained; },
privateOwner(id) { assert.equal(id, pluginId); return owner(); },
publicPluginId(encoded) { return encoded === owner() ? pluginId : null; },
};
return { identity, database, plugin: () => active, setActive(value) { active = value; },
setRetained(value) { retained = value; },
replace() { source = { id: "official", kind: "official", name: "Not a maintenance claim", origin: "https://plugins.netcatty.app" }; active = { ...active, source }; retained = source; },
uninstall() { active = null; },
};
}
test("host provenance uses the bound owner and ignores plugin-supplied source metadata", () => {
const f = sourceFixture();
const resolver = createPluginLogProvenanceResolver(f.database);
const result = resolver({ ...f.identity, sourceId: "official", source: { id: "official" }, owner: "spoof" });
assert.equal(result.sourceId, f.plugin().source.id);
assert.equal(result.sourceKind, "third-party");
assert.equal(result.namespace, f.database.privateOwner(f.identity.pluginId));
assert.equal(result.isCurrent(), true);
assert.equal(Object.hasOwn(result, "officiallyMaintained"), false);
assert.equal(Object.hasOwn(result, "origin"), false);
f.replace();
assert.equal(result.isCurrent(), false);
assert.equal(resolver(f.identity).sourceId, "official");
});
test("missing source APIs, retained mismatch, orphaned owner and changed package remain unknown", () => {
const f = sourceFixture();
const resolve = createPluginLogProvenanceResolver(f.database);
assert.equal(createPluginLogProvenanceResolver({ getActivePlugin: f.database.getActivePlugin })(f.identity), null);
assert.equal(resolve({ ...f.identity, pluginVersion: "2.0.0" }), null);
assert.equal(resolve({ ...f.identity, archiveSha256: "c".repeat(64) }), null);
f.database.privateOwner = () => null;
assert.equal(resolve(f.identity), null, "no bare pluginId fallback");
f.database.privateOwner = () => f.identity.pluginId;
f.database.publicPluginId = (owner) => owner;
assert.equal(resolve(f.identity), null, "a bare owner is not an authenticated namespace");
f.database.privateOwner = () => "source:orphan";
assert.equal(resolve(f.identity), null);
const g = sourceFixture();
g.setRetained({ id: "different", kind: "third-party" });
assert.equal(createPluginLogProvenanceResolver(g.database)(g.identity), null);
const h = sourceFixture();
const snapshot = createPluginLogProvenanceResolver(h.database)(h.identity);
h.uninstall();
assert.equal(snapshot.isCurrent(), false);
assert.equal(createPluginLogProvenanceResolver(h.database)(h.identity), null, "retained source alone cannot authenticate an installed activation");
});
test("queued diagnostics and host facts are revalidated after async I/O before commit", async (t) => {
const f = sourceFixture(`com.${"a".repeat(120)}`);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-log-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
assert.equal(service.status().saveToDisk, false);
await service.configure({ saveToDisk: true, verbose: true });
const loggerA = service.createLogger(f.identity, f.plugin());
loggerA.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
let release;
let entered;
const paused = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const prune = service.sink.prune.bind(service.sink);
service.sink.prune = async () => { entered(); await gate; return prune(); };
loggerA.audit({ operation: "synthetic.pending-source-A", authorization: "allowed", result: "ok" });
await loggerA.write("info", "synthetic pending diagnostic A");
await paused;
f.replace();
release();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 2);
const loggerB = service.createLogger({ ...f.identity, runtimeId: "host-runtime-B" }, f.plugin());
loggerB.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
const records = (await fs.readFile(service.sink.file(0), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 2);
assert.match(records[0].sourceId, /^third-party:/u);
assert.equal(records[1].sourceId, "official");
assert.notEqual(records[0].namespace, records[1].namespace);
assert.equal(records[0].runtimeId, "host-runtime-A");
assert.equal(records[1].runtimeId, "host-runtime-B");
assert.doesNotMatch(JSON.stringify(records), /synthetic.pending-source-A|pending diagnostic|example.com|maintenance/i);
f.uninstall();
await loggerB.write("info", "synthetic stale callback");
loggerB.audit({ operation: "synthetic.after-uninstall", result: "ok" });
assert.equal(service.status().droppedRecords, 4);
});
test("a thrown source revalidation fails closed without breaking plugin execution", async (t) => {
const f = sourceFixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-failure-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: true });
const logger = service.createLogger(f.identity, f.plugin());
f.database.getActivePlugin = () => { throw new Error("synthetic unavailable source database"); };
await logger.write("info", "synthetic ignored");
logger.audit({ operation: "storage.get", result: "ok" });
await service.sink.flush();
assert.equal(service.status().droppedRecords, 2);
assert.equal(service.status().writeErrors, 0);
});
test("a stale queued source cannot rotate away existing audit evidence", async (t) => {
const f = sourceFixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-source-rotation-"));
const service = new PluginLogService({ paths: { root, logs: path.join(root, "logs") }, limits: { maxFiles: 1 }, resolveProvenance: createPluginLogProvenanceResolver(f.database) });
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
await service.ready;
await service.configure({ saveToDisk: true, verbose: false });
const logger = service.createLogger(f.identity, f.plugin());
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await service.sink.flush();
const before = await fs.readFile(service.sink.file(0), "utf8");
service.sink.maxBytes = Buffer.byteLength(before) + 16;
let resume;
let entered;
const paused = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { resume = resolve; });
const prune = service.sink.prune.bind(service.sink);
service.sink.prune = async () => { entered(); await gate; return prune(); };
logger.audit({ operation: "runtime.running", authorization: "not-required", result: "ok" });
await paused;
f.replace();
resume();
await service.sink.flush();
assert.equal(service.status().droppedRecords, 1);
assert.equal(await fs.readFile(service.sink.file(0), "utf8"), before);
});
+150
View File
@@ -0,0 +1,150 @@
"use strict";
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const path = require("node:path");
const { createHmac, randomBytes } = require("node:crypto");
const { PluginLogger, PluginLogSink } = require("./pluginLogger.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const bounded = (value, length = 128) => typeof value === "string"
? value.slice(0, length).replace(/[^a-zA-Z0-9._:@/-]/gu, "_") : null;
const sourceKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,128}$/u.test(value) ? value : null;
const ownerKey = (value) => typeof value === "string" && /^[a-zA-Z0-9._:-]{1,256}$/u.test(value) ? value : null;
const count = (value) => Number.isSafeInteger(value) && value >= 0 ? value : null;
class PluginLogService {
constructor(options) {
this.directory = path.join(options.paths.logs, "host-v1");
this.preferencesPath = path.join(options.paths.root, "local-logging.json");
this.resolveProvenance = options.resolveProvenance ?? (() => null);
this.electron = options.electron;
this.provenanceChecks = new WeakMap();
this.salt = randomBytes(32);
this.sink = new PluginLogSink({ directory: this.directory, ...options.limits });
this.settings = { saveToDisk: false, verbose: false };
this.management = Promise.resolve();
try {
if (fs.statSync(this.preferencesPath).size <= 1024) {
const saved = JSON.parse(fs.readFileSync(this.preferencesPath, "utf8"));
this.settings.saveToDisk = saved.saveToDisk === true;
// Free-form diagnostics always require fresh consent after app restart.
}
} catch {}
this.ready = this.sink.setEnabled(this.settings.saveToDisk).then(async () => {
if (!this.sink.enabled && fs.existsSync(this.directory)) await this.sink.prune();
}).catch(() => {
this.sink.writeErrors++;
this.settings.saveToDisk = false;
this.sink.enabled = false;
});
this.retentionTimer = setInterval(() => {
if (!fs.existsSync(this.directory) || this.sink.closed) return;
this.sink.queue = this.sink.queue.then(() => this.sink.prune()).catch(() => { this.sink.writeErrors++; });
}, 60 * 60 * 1000);
this.retentionTimer.unref?.();
}
status() {
return { ...this.settings, ...this.sink.status(), maxBytes: this.sink.maxBytes * this.sink.maxFiles,
retentionDays: this.sink.retentionMs / 86400000 };
}
async configure(request) {
if (!request || typeof request.saveToDisk !== "boolean" || typeof request.verbose !== "boolean") throw new TypeError("Invalid plugin log settings");
return this.#manage(async () => {
if (this.sink.closed) throw new Error("Plugin logging is closed");
const settings = { saveToDisk: request.saveToDisk, verbose: request.saveToDisk && request.verbose };
// Stop ingress before changing preferences. Failure leaves persistence off.
this.settings = { saveToDisk: false, verbose: false };
await this.sink.setEnabled(false);
await fsp.mkdir(path.dirname(this.preferencesPath), { recursive: true, mode: 0o700 });
const temporary = `${this.preferencesPath}.tmp`;
await fsp.writeFile(temporary, JSON.stringify({ saveToDisk: settings.saveToDisk }), { mode: 0o600 });
await fsp.rename(temporary, this.preferencesPath);
await this.sink.setEnabled(settings.saveToDisk);
this.settings = settings;
return this.status();
});
}
#manage(action) {
const result = this.management.then(() => this.ready).then(action);
this.management = result.catch(() => {});
return result;
}
identity(identity, plugin) {
// Only the injected HOST resolver may assign source and namespace identity.
let source = null;
try { source = this.resolveProvenance(Object.freeze({ pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
archiveSha256: plugin?.archiveSha256 ?? null, securityPrincipal: identity.securityPrincipal })); } catch {}
const metadata = Object.freeze({ pluginId: bounded(identity.pluginId), pluginVersion: bounded(identity.pluginVersion),
runtimeId: bounded(identity.runtimeId), runtimeKind: bounded(identity.runtimeKind),
archiveSha256: /^[a-f0-9]{64}$/u.test(plugin?.archiveSha256 ?? "") ? plugin.archiveSha256 : null,
sourceId: sourceKey(source?.sourceId), sourceKind: sourceKey(source?.sourceKind), namespace: ownerKey(source?.namespace) });
if (source?.isCurrent && typeof source.isCurrent === "function") {
this.provenanceChecks.set(metadata, () => source.isCurrent() === true);
}
return metadata;
}
createLogger(identity, plugin) {
const provenance = this.identity(identity, plugin);
return new PluginLogger({ sink: this.sink, identity: provenance, verbose: () => this.settings.verbose, isCurrent: this.provenanceChecks.get(provenance), audit: (event) => this.audit(provenance, event) });
}
audit(identity, event) {
if (!this.sink.enabled) return;
// An allowlist, never spread request/response/error objects into an audit.
const resources = Array.isArray(event.resources) ? event.resources.slice(0, 16) : [];
this.sink.enqueue({ timestamp: new Date().toISOString(), recordType: "host-audit", trust: "host-observed",
...identity, operation: bounded(event.operation, 256),
targetIds: resources.filter((item) => typeof item === "string").map((item) =>
createHmac("sha256", this.salt).update(item.slice(0, 8192)).digest("hex")),
authorization: ["allowed", "denied", "not-required", "not-reached", "unknown"].includes(event.authorization) ? event.authorization : "unknown",
result: event.result === "ok" ? "ok" : "error",
errorCode: Object.values(RPC_ERRORS).includes(event.errorCode) ? event.errorCode : event.result === "ok" ? null : RPC_ERRORS.internal,
durationMs: count(event.durationMs), inputBytes: count(event.inputBytes), outputBytes: count(event.outputBytes) }, this.provenanceChecks.get(identity));
}
async openFolder() {
return this.#manage(async () => {
await fsp.mkdir(this.directory, { recursive: true, mode: 0o700 });
const error = await this.electron.shell.openPath(this.directory);
if (error) throw new Error("Could not open plugin log folder");
return this.status();
});
}
async exportLogs() {
return this.#manage(async () => {
const result = await this.electron.dialog.showSaveDialog({ title: "Export local plugin logs", defaultPath: "netcatty-plugin-logs.jsonl" });
if (result.canceled || !result.filePath) return this.status();
if (path.resolve(result.filePath).startsWith(path.resolve(this.directory) + path.sep)) throw new Error("Choose a destination outside the log folder");
const task = this.sink.queue.then(async () => {
const chunks = [];
for (let index = this.sink.maxFiles - 1; index >= 0; index--) {
try {
const stats = await fsp.lstat(this.sink.file(index));
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > this.sink.maxBytes) throw new Error("Unsafe plugin log file");
if (await this.sink.retained(this.sink.file(index))) chunks.push(await fsp.readFile(this.sink.file(index)));
} catch (error) { if (error.code !== "ENOENT") throw error; }
}
await fsp.writeFile(result.filePath, Buffer.concat(chunks), { mode: 0o600 });
});
this.sink.queue = task.catch(() => {});
await task;
return this.status();
});
}
async clear() {
return this.#manage(async () => {
const result = await this.electron.dialog.showMessageBox({ type: "warning", title: "Clear local plugin logs?",
message: "Delete the saved local audit and diagnostic files? The bounded security history in plugin permissions is kept.",
buttons: ["Cancel", "Clear logs"], defaultId: 0, cancelId: 0, noLink: true });
if (result.response === 1) await this.sink.clear();
return this.status();
});
}
async close() {
clearInterval(this.retentionTimer);
await this.management;
await this.ready;
await this.sink.close();
}
}
module.exports = { PluginLogService };
+163 -73
View File
@@ -1,96 +1,186 @@
"use strict";
const fs = require("node:fs/promises");
const { constants } = require("node:fs");
const path = require("node:path");
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
const { assertPluginStorageSegment } = require("./paths.cjs");
const { TokenBucket, DEFAULT_QUOTAS } = require("./quotaManager.cjs");
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
const SENSITIVE_KEY = /(?:password|passwd|token|secret|api.?key|private.?key|credential|authorization|cookie|totp|otpauth|prompt|notes?|sql|results?|terminal|content|body|payload)/iu;
function redactLogValue(value, key = "", depth = 0) {
// Bounds apply to the entire traversal, including hostile, cyclic or wide input.
// This is damage limitation for opt-in diagnostics, not a guarantee of secrecy.
function redactLogValue(value, key = "", depth = 0, budget = { remaining: 4096 }) {
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
if (depth >= 8) return "[TRUNCATED]";
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
if (typeof value === "string") return value.slice(0, 2_048);
if (Array.isArray(value)) {
const output = [];
for (let index = 0; index < Math.min(value.length, 64); index += 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
output.push(descriptor && "value" in descriptor
? redactLogValue(descriptor.value, "", depth + 1)
: "[UNAVAILABLE]");
}
return output;
if (depth >= 6 || budget.remaining <= 0) return "[TRUNCATED]";
budget.remaining -= 16;
if (value == null || typeof value === "boolean") return value;
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string") {
const text = value.slice(0, Math.min(2048, Math.max(0, budget.remaining))).replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ");
budget.remaining -= text.length;
return text;
}
if (typeof value === "object") {
const output = {};
for (const entryKey of Object.keys(value).slice(0, 64)) {
if (SENSITIVE_KEY.test(entryKey)) {
output[entryKey.slice(0, 128)] = "[REDACTED]";
continue;
}
if (typeof value !== "object") return "[UNSUPPORTED]";
try {
const output = Array.isArray(value) ? [] : Object.create(null);
// Stop enumeration as soon as the budget is exhausted.
let count = 0;
for (const entryKey in value) {
if (budget.remaining <= 0 || count++ >= 32) break;
const descriptor = Object.getOwnPropertyDescriptor(value, entryKey);
output[entryKey.slice(0, 128)] = descriptor && "value" in descriptor
? redactLogValue(descriptor.value, entryKey, depth + 1)
: "[UNAVAILABLE]";
if (!descriptor?.enumerable) continue;
const safeKey = entryKey.slice(0, 64).replace(/[\u0000-\u001f\u007f]/gu, " ");
budget.remaining -= safeKey.length;
const item = "value" in descriptor ? redactLogValue(descriptor.value, entryKey, depth + 1, budget) : "[UNAVAILABLE]";
if (Array.isArray(output)) output.push(item);
else output[safeKey] = item;
}
return output;
} catch { return "[UNAVAILABLE]"; }
}
/** One app-owned sink gives every plugin a shared disk and queue budget. */
class PluginLogSink {
constructor(options) {
this.directory = options.directory;
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
this.maxPendingBytes = options.maxPendingBytes ?? 256 * 1024;
this.retentionMs = options.retentionMs ?? 7 * 24 * 60 * 60 * 1000;
for (const value of [this.maxBytes, this.maxFiles, this.maxPendingBytes, this.retentionMs]) {
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError("Invalid plugin log limit");
}
this.clock = options.clock ?? Date.now;
this.enabled = false;
this.closed = false;
this.pendingBytes = 0;
this.diagnosticPendingBytes = 0;
this.dropped = 0;
this.writeErrors = 0;
this.queue = Promise.resolve();
this.generation = 0;
}
return "[UNSUPPORTED]";
enqueue(record, isCurrent = () => true) {
if (!this.enabled || this.closed) return;
if (this.clearing) { this.dropped++; return; }
const current = () => { try { return isCurrent() === true; } catch { return false; } };
if (!current()) { this.dropped++; return; }
let line;
try { line = `${JSON.stringify({ ...record, timestamp: new Date(this.clock()).toISOString(), droppedRecords: this.dropped, writeErrors: this.writeErrors })}\n`; }
catch { this.dropped++; return; }
const bytes = Buffer.byteLength(line);
const diagnostic = record.recordType === "plugin-diagnostic";
if ((diagnostic && this.diagnosticPendingBytes + bytes > this.maxPendingBytes / 4) || bytes > Math.min(this.maxBytes, 16 * 1024) || this.pendingBytes + bytes > this.maxPendingBytes) {
this.dropped++; return;
}
const generation = this.generation;
this.pendingBytes += bytes;
if (diagnostic) this.diagnosticPendingBytes += bytes;
this.queue = this.queue.then(async () => {
if (generation !== this.generation || !current()) { this.dropped++; return; }
await this.prune();
const active = this.file(0);
let size = 0;
try { size = (await fs.stat(active)).size; } catch (error) { if (error.code !== "ENOENT") throw error; }
if (!current()) { this.dropped++; return; }
if (size + bytes > this.maxBytes) {
await fs.rm(this.file(this.maxFiles - 1), { force: true });
for (let index = this.maxFiles - 2; index >= 0; index--) {
if (!current()) { this.dropped++; return; }
try { await fs.rename(this.file(index), this.file(index + 1)); }
catch (error) { if (error.code !== "ENOENT") throw error; }
}
}
if (!current()) { this.dropped++; return; }
const handle = await fs.open(active, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0), 0o600);
try {
if (!(await handle.stat()).isFile()) throw new Error("Unsafe plugin log file");
if (!current()) { this.dropped++; return; }
await handle.writeFile(line);
} finally { await handle.close(); }
}).catch(() => { this.dropped++; this.writeErrors++; }).finally(() => {
this.pendingBytes -= bytes;
if (diagnostic) this.diagnosticPendingBytes -= bytes;
});
}
file(index) { return path.join(this.directory, index === 0 ? "runtime.jsonl" : `runtime.${index}.jsonl`); }
async retained(file) {
// Every host record is capped at 16 KiB. Read only the first line: append
// updates mtime, so mtime cannot enforce record retention for active files.
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
const buffer = Buffer.alloc(16 * 1024);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const newline = buffer.indexOf(10, 0);
if (newline < 0 || newline >= bytesRead) return false;
let first;
try { first = JSON.parse(buffer.subarray(0, newline).toString("utf8")); } catch { return false; }
const start = typeof first.timestamp === "string" ? Date.parse(first.timestamp) : NaN;
return Number.isFinite(start) && this.clock() - start < this.retentionMs;
} finally { await handle.close(); }
}
async prune() {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
for (let index = 0; index < this.maxFiles; index++) {
try {
const file = this.file(index);
const stats = await fs.lstat(file);
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Unsafe plugin log file");
if (stats.size > this.maxBytes || !await this.retained(file)) await fs.rm(file);
} catch (error) { if (error.code !== "ENOENT") throw error; }
}
}
flush() { return this.queue; }
async setEnabled(enabled) {
this.enabled = false;
this.generation++;
await this.flush();
if (enabled === true && !this.closed) {
await this.prune();
this.enabled = true;
}
}
async clear() {
this.clearing = true;
this.generation++;
try {
await this.flush();
for (let index = 0; index < this.maxFiles; index++) await fs.rm(this.file(index), { force: true });
} finally { this.clearing = false; }
}
async close() { this.closed = true; this.enabled = false; await this.flush(); }
status() { return { droppedRecords: this.dropped, writeErrors: this.writeErrors, pendingBytes: this.pendingBytes }; }
}
class PluginLogger {
constructor(options) {
this.pluginId = assertPluginStorageSegment(options.pluginId, "ID");
this.directory = path.join(options.logsDirectory, this.pluginId);
this.maxBytes = options.maxBytes ?? PLUGIN_LOG_MAX_BYTES;
this.maxFiles = options.maxFiles ?? PLUGIN_LOG_MAX_FILES;
this.maxPendingBytes = options.maxPendingBytes ?? Math.min(this.maxBytes, 256 * 1024);
this.pendingBytes = 0;
this.queue = Promise.resolve();
constructor(options = {}) {
this.sink = options.sink;
this.identity = options.identity;
this.verbose = options.verbose ?? (() => false);
this.isCurrent = options.isCurrent;
this.closed = false;
this.audit = options.audit ?? (() => {});
this.bucket = new TokenBucket(DEFAULT_QUOTAS.logWritesPerMinute, DEFAULT_QUOTAS.logWritesPerMinute / 60000, options.clock ?? Date.now);
}
write(level, message, fields) {
const record = JSON.stringify({
if (this.closed || !this.sink?.enabled || !this.verbose()) return Promise.resolve();
if (!this.bucket.consume()) { this.sink.dropped++; return Promise.resolve(); }
this.sink.enqueue({
timestamp: new Date().toISOString(),
recordType: "plugin-diagnostic", trust: "plugin-authored-unverified",
...this.identity,
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
message: String(message ?? "").slice(0, 2_048),
...(fields === undefined ? {} : { fields: redactLogValue(fields) }),
});
const line = `${record}\n`;
const bytes = Buffer.byteLength(line);
if (this.pendingBytes + bytes > this.maxPendingBytes) return Promise.resolve();
this.pendingBytes += bytes;
this.queue = this.queue
.then(() => this.#append(line))
.catch(() => {})
.finally(() => { this.pendingBytes -= bytes; });
return this.queue;
}
async #append(line) {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
const activePath = path.join(this.directory, "runtime.log");
let size = 0;
try { size = (await fs.stat(activePath)).size; } catch {}
if (size + Buffer.byteLength(line) > this.maxBytes) await this.#rotate();
await fs.appendFile(activePath, line, { encoding: "utf8", mode: 0o600 });
}
async #rotate() {
await fs.rm(path.join(this.directory, `runtime.${this.maxFiles}.log`), { force: true });
for (let index = this.maxFiles - 1; index >= 1; index -= 1) {
try {
await fs.rename(
path.join(this.directory, index === 1 ? "runtime.log" : `runtime.${index - 1}.log`),
path.join(this.directory, `runtime.${index}.log`),
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
diagnostic: {
message: redactLogValue(typeof message === "string" ? message : "[INVALID MESSAGE]"),
fields: redactLogValue(fields),
},
}, this.isCurrent);
return Promise.resolve();
}
close() { this.closed = true; }
}
module.exports = { PluginLogger, redactLogValue };
module.exports = { PluginLogger, PluginLogSink, redactLogValue };
+245 -30
View File
@@ -1,43 +1,258 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginLogger, PluginLogSink, redactLogValue } = require("./pluginLogger.cjs");
const { PluginLogService } = require("./pluginLogService.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { RPC_ERRORS } = require("./rpcRouter.cjs");
const { PluginLogger, redactLogValue } = require("./pluginLogger.cjs");
async function fixture(t, limits = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-synthetic-plugin-logs-"));
const paths = { root, logs: path.join(root, "logs") };
let response = 0;
const service = new PluginLogService({ paths, limits, resolveProvenance: () => ({ sourceId: "synthetic-source", sourceKind: "local", namespace: "test" }),
electron: { shell: { openPath: async () => "" }, dialog: { showMessageBox: async () => ({ response }), showSaveDialog: async () => ({ filePath: path.join(root, "export.jsonl") }) } } });
await service.ready;
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
const identity = { pluginId: "com.example.synthetic", pluginVersion: "1.2.3", runtimeId: "host-runtime-1", runtimeKind: "browser" };
const logger = service.createLogger(identity, { archiveSha256: "a".repeat(64) });
const records = async () => {
await service.sink.flush();
const files = await fs.readdir(service.directory).catch(() => []);
const lines = (await Promise.all(files.map((name) => fs.readFile(path.join(service.directory, name), "utf8")))).join("");
return lines.trim() ? lines.trim().split("\n").map(JSON.parse) : [];
};
return { service, logger, identity, root, paths, records, confirm: () => { response = 1; } };
}
test("plugin logs redact structured secrets and remain bounded on disk", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-logs-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const logger = new PluginLogger({
pluginId: "com.example.logger",
logsDirectory: root,
maxBytes: 240,
maxFiles: 2,
});
test("persistence defaults off and disabled logger does not coerce or inspect text", async (t) => {
const f = await fixture(t);
await f.logger.write("error", { toString() { throw Error("must not execute"); } });
f.logger.audit({ operation: "storage.get", result: "ok" });
assert.deepEqual(await f.records(), []);
await assert.rejects(fs.stat(f.service.directory), { code: "ENOENT" });
assert.equal(f.service.status().saveToDisk, false);
});
for (let index = 0; index < 8; index += 1) {
await logger.write("info", `record-${index}`, {
nested: { apiToken: "must-not-appear", visible: index },
});
test("enabled host audit excludes bodies and binds host source, version, hash and runtime", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: false });
await f.logger.write("info", "SYNTHETIC_SECRET_PROMPT");
f.logger.audit({ operation: "network.request", authorization: "allowed", result: "ok", resources: ["https://user:password@example.test/private?api_key=SYNTHETIC_SECRET"],
body: "SYNTHETIC_SECRET_BODY", error: "SYNTHETIC_SECRET_ERROR", inputBytes: 24, outputBytes: 64, durationMs: 3, pluginId: "spoof" });
const records = await f.records();
assert.equal(records.length, 1);
assert.equal(records[0].pluginId, f.identity.pluginId);
assert.equal(records[0].sourceId, "synthetic-source");
assert.equal(records[0].archiveSha256, "a".repeat(64));
assert.equal(records[0].runtimeId, "host-runtime-1");
assert.equal(records[0].authorization, "allowed");
assert.equal(records[0].inputBytes, 24);
assert.match(records[0].targetIds[0], /^[a-f0-9]{64}$/u);
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|example.test|password|spoof/);
});
test("verbose diagnostics are opt-in, bounded and cannot impersonate host records", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: true });
await f.logger.write("bad", "message\u001b\n", { sourceId: "spoof", recordType: "host-audit", apiKey: "SYNTHETIC_SECRET", prompt: "SYNTHETIC_SECRET", Notes: "SYNTHETIC_SECRET", SQL: "SYNTHETIC_SECRET", results: "SYNTHETIC_SECRET", terminalContent: "SYNTHETIC_SECRET", credentialPayload: "SYNTHETIC_SECRET" });
const records = await f.records();
assert.equal(records[0].sourceId, "synthetic-source");
assert.equal(records[0].recordType, "plugin-diagnostic");
assert.equal(records[0].trust, "plugin-authored-unverified");
assert.equal(records[0].diagnostic.fields.sourceId, "spoof");
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET|\\u001b/);
const cyclic = {}; cyclic.self = cyclic;
await f.logger.write("error", { toString() { throw Error("coercion"); } }, cyclic);
await f.logger.write("info", "proxy", new Proxy({}, { ownKeys() { throw Error("proxy"); } }));
const getter = {};
Object.defineProperty(getter, "value", { enumerable: true, get() { throw Error("accessor"); } });
assert.equal(redactLogValue(getter).value, "[UNAVAILABLE]");
assert.equal((await f.records()).length, 3);
const restored = new PluginLogService({ paths: f.paths });
await restored.ready;
assert.equal(restored.status().saveToDisk, true);
assert.equal(restored.status().verbose, false);
await restored.close();
});
test("shared disk cap, queue cap, retention and drop accounting", async (t) => {
const f = await fixture(t, { maxBytes: 800, maxFiles: 2, maxPendingBytes: 1600 });
await f.service.configure({ saveToDisk: true, verbose: true });
for (let i = 0; i < 10; i++) {
f.logger.audit({ operation: "storage.get", result: "ok" });
await f.service.sink.flush();
}
const files = fs.readdirSync(path.join(root, "com.example.logger"));
let files = await fs.readdir(f.service.directory);
assert.ok(files.length <= 2);
const contents = files.map((file) => fs.readFileSync(
path.join(root, "com.example.logger", file),
"utf8",
)).join("\n");
assert.doesNotMatch(contents, /must-not-appear/);
assert.match(contents, /\[REDACTED\]/);
for (const name of files) assert.ok((await fs.stat(path.join(f.service.directory, name))).size <= 800);
for (let i = 0; i < 1000; i++) f.logger.audit({ operation: "storage.get", result: "ok" });
assert.ok(f.service.sink.pendingBytes <= 1600);
assert.ok(f.service.status().droppedRecords > 0);
await f.service.sink.flush();
f.logger.audit({ operation: "storage.get", result: "ok" });
assert.ok((await f.records()).some((record) => record.droppedRecords > 0));
files = await fs.readdir(f.service.directory);
const future = Date.now() + f.service.sink.retentionMs;
f.service.sink.clock = () => future;
await f.service.sink.prune();
assert.deepEqual(await fs.readdir(f.service.directory), []);
});
test("log redaction does not invoke accessors", () => {
const value = {};
Object.defineProperty(value, "secret", {
enumerable: true,
get() { throw new Error("accessor invoked"); },
});
assert.deepEqual(redactLogValue(value), { secret: "[REDACTED]" });
test("disable, revoke, clear confirmation and shutdown stop future diagnostic writes", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: true });
await f.logger.write("info", "before revoke");
await f.service.sink.flush();
f.logger.close();
await f.logger.write("info", "after revoke");
assert.equal((await f.records()).length, 1);
await f.service.clear();
assert.equal((await f.records()).length, 1);
f.confirm(); await f.service.clear();
assert.equal((await f.records()).length, 0);
const active = f.service.createLogger(f.identity, {});
await f.service.configure({ saveToDisk: false, verbose: false });
await active.write("info", "disabled");
assert.equal((await f.records()).length, 0);
await f.service.configure({ saveToDisk: true, verbose: true });
await active.write("info", "before shutdown");
await f.service.close();
await active.write("info", "after shutdown");
assert.equal((await f.records()).length, 1);
});
test("export is local and does not alter retained files", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: false });
f.logger.audit({ operation: "filesystem.stat", result: "ok", authorization: "allowed" });
await f.service.exportLogs();
const exported = await fs.readFile(path.join(f.root, "export.jsonl"), "utf8");
assert.equal(JSON.parse(exported).operation, "filesystem.stat");
assert.equal((await f.records()).length, 1);
});
test("RPC audit includes denied, malformed and public operations without error messages or bodies", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: false });
const registry = new PluginHostRpcRegistry();
const engine = new PluginPermissionEngine({ database: { listPermissionGrants: () => [] } });
t.after(() => engine.shutdown());
registry.use(engine.createMiddleware());
registry.registerRequest("synthetic.public", (params) => ({ data: params.data }), { metadata: { public: true } });
registry.registerRequest("synthetic.denied", () => { throw Error("not reached"); });
registry.registerRequest("synthetic.invalid", () => null, { validateParams() { throw new Error("SYNTHETIC_SECRET_ERROR"); } });
const routes = registry.createRoutes({ ...f.identity, logger: f.logger });
await routes.requestHandlers["synthetic.public"]({ data: "SYNTHETIC_SECRET_BODY" }, {});
await assert.rejects(routes.requestHandlers["synthetic.denied"]({}, {}), { code: RPC_ERRORS.permissionDenied });
await assert.rejects(routes.requestHandlers["synthetic.invalid"]({}, {}));
const records = await f.records();
assert.deepEqual(records.map((r) => r.authorization), ["not-required", "denied", "not-reached"]);
assert.equal(records[0].inputBytes, 21);
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
});
test("I/O failure is counted and never fails plugin execution", async (t) => {
const f = await fixture(t);
await f.service.configure({ saveToDisk: true, verbose: true });
await fs.writeFile(f.service.sink.file(0), "");
await fs.rm(f.service.sink.file(0));
await fs.symlink(path.join(f.root, "outside"), f.service.sink.file(0));
await f.logger.write("info", "synthetic");
await f.service.sink.flush();
assert.equal(f.service.status().writeErrors, 1);
assert.equal(f.service.status().droppedRecords, 1);
});
test("invalid sink limits fail early", () => {
assert.throws(() => new PluginLogSink({ directory: "/unused", maxFiles: -1 }));
const logger = new PluginLogger();
assert.doesNotThrow(() => logger.write("info", "disabled"));
});
test("unknown provenance stays unknown and a disabled restart prunes expired files", async (t) => {
const f = await fixture(t);
f.service.resolveProvenance = () => null;
const logger = f.service.createLogger({ ...f.identity, sourceId: "manifest-official-claim" }, { sourceId: "manifest-official-claim", archiveSha256: "c".repeat(64) });
await f.service.configure({ saveToDisk: true, verbose: false });
logger.audit({ operation: "storage.get", result: "ok" });
const records = await f.records();
assert.equal(records[0].sourceId, null);
assert.equal(records[0].namespace, null);
assert.doesNotMatch(JSON.stringify(records), /manifest-official-claim/);
await f.service.configure({ saveToDisk: false, verbose: false });
const expired = await fs.readFile(f.service.sink.file(0), "utf8");
await fs.writeFile(f.service.sink.file(0), expired.replace(/"timestamp":"[^"]+"/u, '"timestamp":"2000-01-01T00:00:00.000Z"'));
const restored = new PluginLogService({ paths: f.paths });
await restored.ready;
assert.equal(restored.status().saveToDisk, false);
assert.deepEqual(await fs.readdir(restored.directory), []);
await restored.close();
});
test("noisy and malformed plugin diagnostics remain bounded with visible drops", async (t) => {
const f = await fixture(t, { maxPendingBytes: 4096 });
await f.service.configure({ saveToDisk: true, verbose: true });
const wide = {};
for (let index = 0; index < 100; index++) wide[`key-${index}`] = Array(100).fill("synthetic ".repeat(100));
await f.logger.write(Symbol("invalid"), { toString() { throw Error("must not coerce"); } }, wide);
for (let index = 0; index < 1000; index++) void f.logger.write("info", "synthetic noise", { pluginId: "spoof", index });
assert.ok(f.service.sink.pendingBytes <= 4096);
assert.ok(f.service.status().droppedRecords > 0);
for (const record of await f.records()) {
assert.equal(record.pluginId, f.identity.pluginId);
assert.ok(Buffer.byteLength(JSON.stringify(record)) < 16384);
}
});
test("source resolver URLs and userinfo are not persisted as source identity", async (t) => {
const f = await fixture(t);
f.service.resolveProvenance = () => ({ sourceId: "https://user:SYNTHETIC_SECRET@example.test/source?key=SYNTHETIC_SECRET", sourceKind: "local", namespace: "user@SYNTHETIC_SECRET" });
const logger = f.service.createLogger(f.identity, {});
await f.service.configure({ saveToDisk: true, verbose: false });
logger.audit({ operation: "storage.get", result: "ok" });
const records = await f.records();
assert.equal(records[0].sourceId, null);
assert.equal(records[0].namespace, null);
assert.doesNotMatch(JSON.stringify(records), /SYNTHETIC_SECRET/);
});
test("31 days of low-volume appends never retain an active record for seven days", async (t) => {
let now = Date.UTC(2030, 0, 1);
const f = await fixture(t, { clock: () => now });
await f.service.configure({ saveToDisk: true, verbose: false });
for (let day = 0; day < 31; day++) {
now = Date.UTC(2030, 0, 1) + day * 86400000;
f.logger.audit({ operation: `synthetic.day-${day}`, authorization: "not-required", result: "ok" });
const records = await f.records();
assert.ok(records.length > 0);
for (const record of records) assert.ok(now - Date.parse(record.timestamp) < f.service.sink.retentionMs);
}
assert.doesNotMatch(JSON.stringify(await f.records()), /synthetic.day-0"/u);
});
test("export excludes an expired active segment even when its mtime is recent", async (t) => {
let now = Date.UTC(2030, 0, 1);
const f = await fixture(t, { clock: () => now });
await f.service.configure({ saveToDisk: true, verbose: false });
f.logger.audit({ operation: "synthetic.expired", result: "ok" });
await f.service.sink.flush();
now += 86400000;
f.logger.audit({ operation: "synthetic.day-one", result: "ok" });
await f.service.sink.flush();
// Appending refreshed mtime; export must use the first host record's age.
await fs.utimes(f.service.sink.file(0), new Date(now), new Date(now));
now += f.service.sink.retentionMs;
await f.service.exportLogs();
assert.equal(await fs.readFile(path.join(f.root, "export.jsonl"), "utf8"), "");
f.logger.audit({ operation: "synthetic.new-segment", result: "ok" });
await f.service.sink.flush();
await f.service.exportLogs();
const records = (await fs.readFile(path.join(f.root, "export.jsonl"), "utf8")).trim().split("\n").map(JSON.parse);
assert.equal(records.length, 1);
assert.equal(records[0].operation, "synthetic.new-segment");
});
+35
View File
@@ -12,6 +12,7 @@ class PluginManager {
};
this.beforeClose = options.beforeClose ?? null;
this.permissionEngine = options.permissionEngine ?? null;
this.secretStore = options.secretStore ?? null;
this.initialized = false;
this.initializePromise = null;
this.mutationTail = Promise.resolve();
@@ -46,16 +47,37 @@ class PluginManager {
return this.database.listPlugins();
}
changeMarketSource(operation, disableSourceId) {
return this.#mutate(async () => {
await this.#ready();
const result = operation();
if (disableSourceId) {
const errors = [];
for (const plugin of this.database.listPlugins()) {
if (plugin.source?.id !== disableSourceId) continue;
try { this.database.setEnabled(plugin.id, false); } catch (error) { errors.push(error); }
try { this.contributionService.onPluginDisabled(plugin.id); } catch (error) { errors.push(error); }
try { await this.runtimeSupervisor.stop(plugin.id); } catch (error) { errors.push(error); }
}
if (errors.length) throw new AggregateError(errors, "Source removed, but some plugin shutdown steps failed");
}
return result;
});
}
install(archivePath, options) {
return this.#mutate(async () => {
await this.#ready();
options?.sourceGuard?.();
let stoppedPlugin = null;
let plugin;
try {
plugin = await this.packageStore.install(archivePath, {
enable: options?.enable,
catalog: options?.catalog,
source: options?.source,
beforeActivate: async ({ pluginId, previousPlugin }) => {
options?.sourceGuard?.();
if (!previousPlugin?.enabled) return;
this.database.setEnabled(pluginId, false);
this.contributionService.onPluginDisabled(pluginId);
@@ -157,6 +179,19 @@ class PluginManager {
});
}
resetUninstalledSource(pluginId, expectedSourceId) {
return this.#mutate(async () => {
await this.#ready();
if (this.database.getActivePlugin(pluginId)) throw new Error("Uninstall the plugin before changing source");
if (this.database.getPluginSource(pluginId).id !== expectedSourceId) throw new Error("Plugin source changed; review the reset again");
await this.runtimeSupervisor.stop(pluginId);
this.permissionEngine?.revokeAll(pluginId);
this.secretStore?.clearPluginMemory(pluginId);
await this.packageStore.clearUninstalledPrivateFiles(pluginId);
this.database.clearUninstalledSourceData(pluginId, expectedSourceId);
});
}
uninstall(pluginId) {
return this.#mutate(async () => {
await this.#ready();
+22 -8
View File
@@ -35,11 +35,16 @@ class PluginSyncSidecarService {
sync: setting.sync === true,
scope: setting.scope,
}));
map.set(plugin.id, fields);
map.set(this.database.privateOwner(plugin.id), fields);
}
return map;
}
#isCurrentOwner(owner, publicId) {
return publicId !== null && this.database.publicPluginId(owner) === publicId
&& this.#declaredSettingsByPlugin().has(owner);
}
/**
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
*/
@@ -170,12 +175,14 @@ class PluginSyncSidecarService {
}
}
for (const entry of resetCandidates) {
const publicId = this.database.publicPluginId(entry.pluginId);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
if (typeof this.contributionService?.resetSetting === "function") {
try {
await this.contributionService.resetSetting(
entry.pluginId,
publicId,
parsed.settingId,
parsed.scopeId,
);
@@ -183,6 +190,7 @@ class PluginSyncSidecarService {
// Validation rejected the reset (e.g. required setting) — still try
// the stored-coordinate delete below for stale-scope rows.
}
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
// resetSetting normalizes against the *current* declared scope. After a
// scope migration (application → device), that may miss the stale row
// still stored under the old coordinates; always delete those too.
@@ -201,6 +209,8 @@ class PluginSyncSidecarService {
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
}
for (const entry of safe) {
const publicId = this.database.publicPluginId(entry.pluginId);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
if (entry.kind !== "settings") continue;
const fields = declared.get(entry.pluginId);
if (!fields) continue;
@@ -218,12 +228,13 @@ class PluginSyncSidecarService {
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
entry.pluginId,
publicId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
this.database.setSetting(
entry.pluginId,
@@ -312,12 +323,14 @@ class PluginSyncSidecarService {
*/
async hydrateInstalledPluginSettings(pluginId) {
if (typeof pluginId !== "string" || pluginId.length < 1) return;
const declared = this.#declaredSettingsByPlugin().get(pluginId);
const owner = this.database.privateOwner(pluginId);
const declared = this.#declaredSettingsByPlugin().get(owner);
if (!declared) return;
const rows = typeof this.database.listSyncSidecars === "function"
? this.database.listSyncSidecars(pluginId)
: [];
for (const entry of rows) {
if (!this.#isCurrentOwner(owner, pluginId)) return;
if (entry.kind !== "settings") continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
@@ -347,8 +360,9 @@ class PluginSyncSidecarService {
targetScopeId,
{ source: "host" },
);
if (!this.#isCurrentOwner(owner, pluginId)) return;
this.database.setSetting(
pluginId,
owner,
parsed.settingId,
targetScope,
targetScopeId,
@@ -358,7 +372,7 @@ class PluginSyncSidecarService {
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
pluginId,
owner,
parsed.settingId,
parsed.scope,
parsed.scopeId,
@@ -369,14 +383,14 @@ class PluginSyncSidecarService {
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
pluginId,
owner,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
this.database.deleteSyncSidecar(owner, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
@@ -33,7 +33,7 @@ test("sidecar table has no package cascade and survives removePlugin", (context)
database.removePlugin("com.example.sync");
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
pluginId: "com.example.sync",
pluginId: database.privateOwner("com.example.sync"),
kind: "account_baseline",
key: "account",
value: { id: "acct" },
@@ -68,7 +68,7 @@ test("collectForSync excludes secrets and preserves missing-plugin baselines", (
const service = new PluginSyncSidecarService({ database, contributionService });
const bundle = service.collectForSync();
assert.equal(bundle.version, 1);
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === database.privateOwner("com.missing.plugin")));
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
// Collected settings must land in the non-cascade table for post-uninstall collect.
@@ -187,7 +187,7 @@ test("collectForSync omits deleted settings for installed plugins but keeps miss
const bundle = service.collectForSync();
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
assert.ok(bundle.entries.some((e) => e.value === "ok"));
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
assert.ok(bundle.entries.some((e) => e.pluginId === database.privateOwner("com.missing.plugin") && e.value === "keep"));
});
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
@@ -210,7 +210,7 @@ test("applyFromSync preserves remote updatedAt when writing settings", async (co
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.sync",
pluginId: database.privateOwner("com.example.sync"),
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
@@ -234,7 +234,7 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.remote.plugin",
pluginId: database.privateOwner("com.remote.plugin"),
kind: "settings",
key: "com.remote.plugin.theme\0application\0application",
value: "light",
@@ -243,8 +243,8 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
});
const all = database.listAllSyncSidecars();
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.local.only")));
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.remote.plugin") && entry.value === "light"));
});
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
@@ -449,7 +449,7 @@ test("applyFromSync does not resurrect missing-plugin keys deleted on another de
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.missing.plugin",
pluginId: database.privateOwner("com.missing.plugin"),
kind: "settings",
key: "com.missing.plugin.kept\0application\0application",
value: "remote-new",
@@ -498,7 +498,7 @@ test("applyFromSync keeps newer local baseline over older remote (LWW)", async (
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.plugin",
pluginId: database.privateOwner("com.example.plugin"),
kind: "crdt_baseline",
key: "crdt",
value: { clock: 50 },
+17 -13
View File
@@ -1,20 +1,21 @@
"use strict";
const { createHash } = require("node:crypto");
const { PluginCatalogService, fetchBounded, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const REGISTRY_URL = "https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json";
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
function prepareSnapshot(value) {
const entries = structuredClone(validateCatalog(value));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor(entry) }));
return { entries, publicEntries };
function prepareSnapshot(value, resources = new Map(), source) {
const entries = structuredClone(validateCatalog(value, source?.origin));
const publicEntries = structuredClone(entries).map((entry) => ({ ...entry, selectionToken: tokenFor([source?.id ?? "official", entry, resources.get(tuple(entry)) ?? null]) }));
return { entries, publicEntries, resources: structuredClone(resources) };
}
class RegistryCatalogService extends PluginCatalogService {
constructor(options) {
super(options);
this.publicEntries = prepareSnapshot(this.entries).publicEntries;
this.publicEntries = prepareSnapshot(this.entries, new Map(), this.marketSource).publicEntries;
this.resources = new Map();
this.source = "builtin";
this.error = null;
this.refreshing = null;
@@ -24,11 +25,12 @@ class RegistryCatalogService extends PluginCatalogService {
this.refreshing = (async () => {
try {
// No redirects: only this exact registry endpoint can replace the catalog.
const bytes = await fetchBounded(REGISTRY_URL, MAX_CATALOG_BYTES, this.fetch, 0);
const next = prepareSnapshot(JSON.parse(bytes.toString("utf8")));
const distribution = await fetchDistribution(this.fetch, this.origin);
const next = prepareSnapshot(distribution.entries, distribution.resources, this.marketSource);
// Publish only after full validation, clones and every token succeeded.
this.entries = next.entries;
this.publicEntries = next.publicEntries;
this.resources = next.resources;
this.source = "registry";
this.error = null;
return true;
@@ -50,15 +52,17 @@ class RegistryCatalogService extends PluginCatalogService {
}
selected(id, token) {
const entry = this.get(id);
if (typeof token !== "string" || token !== tokenFor(entry)) {
if (typeof token !== "string" || token !== this.publicEntries.find((item) => item.id === id)?.selectionToken) {
throw new Error("Catalog entry changed. Refresh and review this version before continuing.");
}
return entry;
}
artifactSize(entry) { return this.resources.get(tuple(entry))?.artifact?.size; }
async readme(id, token) {
this.selected(id, token);
// Base method captures the pinned entry synchronously before its first await.
return super.readme(id);
const entry = this.selected(id, token);
const resource = this.resources.get(tuple(entry))?.readme;
if (!resource) throw new Error("No published README snapshot is available. Refresh the catalog or open its source link.");
return (await fetchVerified(resource, this.fetch, this.origin)).toString("utf8");
}
async install(id, token) {
this.selected(id, token);
+54 -114
View File
@@ -1,153 +1,93 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { RegistryCatalogService, REGISTRY_URL, MAX_CATALOG_BYTES } = require("./registryCatalogService.cjs");
const builtin = require("./catalog.json");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { catalog, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("registry uses a fixed bounded anonymous endpoint and retains only validated snapshots", async () => {
let response = () => new Response(JSON.stringify([builtin[0]]));
test("registry starts at a fixed anonymous pointer and retains only complete validated snapshots", async () => {
let files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url, options) => {
assert.equal(url, REGISTRY_URL);
assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
assert.equal(options.referrerPolicy, "no-referrer"); assert.ok(options.signal);
return response();
return responseFor(files, url);
} });
const online = await service.list();
assert.equal(online.source, "registry"); assert.equal(online.entries.length, 1);
assert.match(online.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", '{}', JSON.stringify([{ ...builtin[0], author: {} }]), JSON.stringify([{ ...builtin[0], commit: "main" }])]) {
response = () => new Response(invalid);
const good = await service.list();
assert.equal(good.source, "registry"); assert.equal(good.entries.length, 2);
assert.match(good.entries[0].selectionToken, /^[a-f0-9]{64}$/);
for (const invalid of ["{", "{}", JSON.stringify({ schemaVersion: 2 }), '{"schemaVersion":1e999}', " ".repeat(16 * 1024 + 1)]) {
files = realFiles(); files.set("/v1/snapshot.json", Buffer.from(invalid));
const cached = await service.list();
assert.equal(cached.source, "cached"); assert.ok(cached.error);
assert.deepEqual(cached.entries, online.entries);
assert.deepEqual(cached.entries, good.entries);
}
response = () => new Response("[]", { headers: { "content-length": String(MAX_CATALOG_BYTES + 1) } });
assert.match((await service.list()).error, /size limit/);
response = () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/other/repo/main/catalog.json" } });
assert.match((await service.list()).error, /redirects/);
response = () => new Response("[]");
const empty = await service.list();
assert.equal(empty.error, null); assert.deepEqual(empty.entries, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
});
test("offline first launch returns bundled source examples and never mutates installed plugins", async () => {
const service = new RegistryCatalogService({ fetch: async () => { throw new Error("offline"); }, manager: {
test("offline first launch returns bundled source examples with no GitHub README fallback", async () => {
let calls = 0;
const service = new RegistryCatalogService({ fetch: async (url) => { calls++; assert.equal(url, REGISTRY_URL); throw new Error("offline"); }, manager: {
install() { assert.fail("offline must not install"); },
} });
const snapshot = await service.list();
assert.equal(snapshot.source, "builtin"); assert.equal(snapshot.entries.length, 2);
assert.match(snapshot.error, /offline/);
await assert.rejects(service.readme(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /No published README/);
assert.equal(calls, 1);
await assert.rejects(service.install(snapshot.entries[0].id, snapshot.entries[0].selectionToken), /browse-only/);
});
test("selection binds manifest, README, version and artifact; refresh changes or withdrawal cannot silently install", async () => {
let entries = structuredClone(builtin);
let requests = 0;
const service = new RegistryCatalogService({ fetch: async (url) => {
requests++;
if (url === REGISTRY_URL) return new Response(JSON.stringify(entries));
return new Response("# Pinned README");
}, manager: { install() { assert.fail("changed selection must not install"); } } });
test("selection binds source and delivery metadata; changes and withdrawal cannot silently install", async () => {
let files = realFiles(); const requests = [];
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); return responseFor(files, url); }, manager: { install() { assert.fail("source-only must not install"); } } });
const selected = (await service.list()).entries[0];
assert.equal(await service.readme(selected.id, selected.selectionToken), "# Pinned README");
assert.match(await service.readme(selected.id, selected.selectionToken), /Starter/);
await assert.rejects(service.readme(selected.id, "bad"), /changed/);
const before = requests;
await assert.rejects(service.install(selected.id), /changed/);
assert.equal(requests, before);
entries[0].description += " updated";
const before = requests.length;
await assert.rejects(service.install(selected.id), /changed/); assert.equal(requests.length, before);
await assert.rejects(service.install(selected.id, selected.selectionToken), /no published package/);
const changed = structuredClone(catalog); changed[0].description += " changed";
files = makeFiles(changed);
await assert.rejects(service.install(selected.id, selected.selectionToken), /changed/);
await assert.rejects(service.readme(selected.id, selected.selectionToken), /changed/);
entries = [];
const next = (await service.list()).entries;
assert.deepEqual(next, []);
files = makeFiles([]);
assert.deepEqual((await service.list()).entries, []);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
});
test("concurrent list refreshes share one request", async () => {
let finish; let calls = 0;
const service = new RegistryCatalogService({ fetch: async () => { calls++; return new Promise((resolve) => { finish = resolve; }); } });
test("concurrent list refreshes share one pointer request and complete document set", async () => {
let finish; const requests = []; const files = realFiles();
const service = new RegistryCatalogService({ fetch: async (url) => { requests.push(url); if (url === REGISTRY_URL) return new Promise((resolve) => { finish = resolve; }); return responseFor(files, url); } });
const first = service.list(); const second = service.list();
finish(new Response(JSON.stringify(builtin)));
assert.deepEqual(await first, await second); assert.equal(calls, 1);
finish(responseFor(files, REGISTRY_URL));
assert.deepEqual(await first, await second); assert.equal(requests.length, 4);
});
test("fresh unchanged selection installs only its pinned bytes and stays disabled", async (context) => {
const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path");
const { createHash } = require("node:crypto");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "registry-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic validated-by-manager archive");
const entry = { ...structuredClone(builtin[0]), artifact: { url: "https://github.com/example/plugin/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
const requests = []; let installs = 0;
const service = new RegistryCatalogService({
getTempFilePath: (name) => path.join(root, name),
fetch: async (url) => { requests.push(url); return new Response(url === REGISTRY_URL ? JSON.stringify([entry]) : bytes); },
manager: { async install(archive, options) {
installs++; assert.deepEqual(await fs.readFile(archive), bytes); assert.equal(options.enable, false);
assert.deepEqual(options.catalog, entry); return { id: entry.id };
} },
});
const selected = (await service.list()).entries[0];
await service.install(selected.id, selected.selectionToken);
assert.deepEqual(requests, [REGISTRY_URL, REGISTRY_URL, entry.artifact.url]);
assert.equal(installs, 1); assert.deepEqual(await fs.readdir(root), []);
});
test("URL-stripped README controls cannot escape a selected commit or replace the good snapshot", async () => {
let entries = structuredClone(builtin);
const requested = [];
const service = new RegistryCatalogService({ fetch: async (url) => {
requested.push(url); return new Response(JSON.stringify(entries));
} });
test("invalid sources and unknown JSON fields cannot poison good offline snapshots", async () => {
let files = realFiles(); let offline = false;
const service = new RegistryCatalogService({ fetch: async (url) => { if (offline) throw new Error("offline"); return responseFor(files, url); } });
const good = await service.list();
for (const readme of ["\t../main/README.md", ".\n./main/README.md", "\r../main/README.md", "a/\t../../main/README.md", "README\u007f.md"]) {
entries = [{ ...builtin[0], readme }];
const snapshot = await service.list();
assert.equal(snapshot.source, "cached"); assert.match(snapshot.error, /unpinned/);
assert.deepEqual(snapshot.entries, good.entries);
}
entries = [{ ...builtin[0], repository: "example/.." }];
assert.match((await service.list()).error, /escapes the pinned commit/);
assert.ok(requested.every((url) => url === REGISTRY_URL));
});
test("whole-catalog JSON budgets and unknown fields cannot poison a good offline snapshot", async () => {
let response = JSON.stringify(builtin);
const service = new RegistryCatalogService({ fetch: async () => {
if (response === null) throw new Error("offline");
return new Response(response);
} });
const good = await service.list();
let nested = null;
for (let depth = 0; depth < 130; depth++) nested = { child: nested };
const nonfinite = structuredClone(builtin[0]);
nonfinite.manifest.contributes.settings[0].default = "finite-marker";
for (const invalid of [
JSON.stringify([{ ...builtin[0], unknownNestedField: nested }]),
JSON.stringify([{ ...builtin[0], unknownField: true }]),
JSON.stringify([nonfinite]).replace('"finite-marker"', '1e999'),
JSON.stringify([{ ...builtin[0], artifact: { url: "https://github.com/a/b", sha256: "a".repeat(64), unexpected: true } }]),
]) {
response = invalid;
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
let nested = null; for (let i = 0; i < 130; i++) nested = { child: nested };
const bad = [
{ ...catalog[0], readme: "\t../main/README.md" }, { ...catalog[0], repository: "example/.." },
{ ...catalog[0], unknownNestedField: nested }, { ...catalog[0], unknownField: true },
{ ...catalog[0], readmeAsset: { url: "https://plugins.netcatty.app/a", sha256: "a".repeat(64) } },
];
for (const entry of bad) {
files = makeFiles([entry]); offline = false;
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries);
response = null;
const offline = await service.list();
assert.deepEqual(offline.entries, good.entries); assert.match(offline.error, /offline/);
offline = true; assert.deepEqual((await service.list()).entries, good.entries);
}
});
test("snapshot preparation failure leaves both private entries and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async () => new Response(JSON.stringify(builtin)) });
const good = await service.list();
test("snapshot preparation failure leaves private resources and public tokens unchanged", async () => {
const service = new RegistryCatalogService({ fetch: async (url) => responseFor(realFiles(), url) });
const good = await service.list(); const resources = service.resources;
const originalClone = global.structuredClone;
try {
global.structuredClone = () => { throw new Error("synthetic clone failure"); };
assert.equal(await service.refresh(), false);
} finally { global.structuredClone = originalClone; }
try { global.structuredClone = () => { throw new Error("synthetic clone failure"); }; assert.equal(await service.refresh(), false); }
finally { global.structuredClone = originalClone; }
assert.equal(service.resources, resources);
service.fetch = async () => { throw new Error("offline"); };
assert.deepEqual((await service.list()).entries, good.entries);
assert.equal(service.selected(good.entries[0].id, good.entries[0].selectionToken).id, good.entries[0].id);
});
+10 -5
View File
@@ -51,6 +51,7 @@ function resolveDefaultRuntimeKind({ plugin }) {
class RuntimeSupervisor {
constructor(options) {
this.electron = options.electron;
this.logService = options.logService;
this.database = options.database;
this.packageStore = options.packageStore;
this.protocol = options.protocol;
@@ -202,6 +203,7 @@ class RuntimeSupervisor {
quarantinedAt: details.quarantinedAt,
});
}
if (identity) this.logService?.audit(identity.logger.identity, { operation: `runtime.${status}`, authorization: "not-required", result: ["error", "quarantined"].includes(status) ? "error" : "ok" });
this.#emitRuntimeState(identity, status, details);
}
@@ -278,7 +280,7 @@ class RuntimeSupervisor {
) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin changed while runtime placement was resolved");
}
const logger = new PluginLogger({ pluginId, logsDirectory: this.paths.logs });
let logger;
let runtime;
const identity = Object.freeze({
pluginId,
@@ -288,7 +290,7 @@ class RuntimeSupervisor {
securityPrincipal,
manifest: freezeJson(structuredClone(plugin.manifest)),
packageRoot,
logger,
get logger() { return logger; },
assertCurrent: () => {
const active = this.database.getActivePlugin(pluginId);
if (
@@ -301,11 +303,13 @@ class RuntimeSupervisor {
}
},
});
logger = this.logService?.createLogger(identity, plugin) ?? new PluginLogger();
const routes = this.rpcRegistry.createRoutes(identity);
const onExit = (details) => { void this.#handleExit(pluginId, runtime, identity, details); };
const onProtocolError = (error) => logger.write("error", "Plugin protocol violation", {
error: error?.message ?? String(error),
});
const onProtocolError = (error) => {
logger.audit({ operation: "runtime.protocol", authorization: "not-reached", result: "error", errorCode: error?.code });
return logger.write("error", "Plugin protocol violation", { error: error?.message });
};
runtime = kind === "browser"
? this.runtimeFactories.browser({
electron: this.electron,
@@ -541,6 +545,7 @@ class RuntimeSupervisor {
}
async #cleanupRuntime(identity) {
identity?.logger?.close();
if (!identity || !this.runtimeCleanup) return;
await this.runtimeCleanup(Object.freeze({
pluginId: identity.pluginId,
@@ -1002,3 +1002,30 @@ test('data-only themes never create a runtime on startup or explicit start',asyn
await assert.rejects(fixture.supervisor.start(manifest.id),/no executable runtime/);assert.equal(fixture.runtimeOptions.length,0);
await fixture.supervisor.shutdown();
});
test("runtime cleanup closes diagnostic loggers before stale runtime callbacks can write", async (t) => {
const { PluginLogger } = require("./pluginLogger.cjs");
const loggers = [];
const events = [];
const fixture = createFixture(t, () => ({
async start(config) { return { pluginId: config.pluginId, pluginVersion: config.pluginVersion, apiVersion: config.apiVersion, enabledFeatures: config.enabledFeatures }; },
async stop() {},
}), { logService: {
createLogger(identity) {
const logger = new PluginLogger({ identity });
loggers.push(logger);
return logger;
},
audit(_identity, event) { events.push(event.operation); },
} });
t.after(() => fixture.supervisor.shutdown());
await fixture.supervisor.start(fixture.manifest.id);
assert.equal(loggers[0].closed, false);
await fixture.supervisor.stop(fixture.manifest.id);
assert.equal(loggers[0].closed, true);
assert.deepEqual(events.slice(0, 3), ["runtime.starting", "runtime.running", "runtime.stopped"]);
await fixture.runtimeOptions[0].logger.write("info", "synthetic stale callback");
await fixture.supervisor.start(fixture.manifest.id);
await fixture.supervisor.shutdown();
assert.equal(loggers[1].closed, true);
});
+7 -2
View File
@@ -41,7 +41,12 @@ class PluginSecretStore {
}
#stashKey(pluginId, key) {
return `${pluginId}\0${key}`;
return `${this.database.privateOwner?.(pluginId) ?? pluginId}\0${key}`;
}
clearPluginMemory(pluginId) {
const prefix = `${this.database.privateOwner?.(pluginId) ?? pluginId}\0`;
for (const key of this.#overwriteStash.keys()) if (key.startsWith(prefix)) this.#overwriteStash.delete(key);
}
#assertAvailable() {
@@ -163,7 +168,7 @@ class PluginSecretStore {
} else {
deleted = this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
}
const pluginPrefix = `${pluginId}\0`;
const pluginPrefix = `${this.database.privateOwner?.(pluginId) ?? pluginId}\0`;
for (const stashKey of [...this.#overwriteStash.keys()]) {
if (!stashKey.startsWith(pluginPrefix)) continue;
const secretKey = stashKey.slice(pluginPrefix.length);
+59
View File
@@ -0,0 +1,59 @@
"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 { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { thirdPartySource } = require("./sourceIdentity.cjs");
const { catalog, realFiles, makeFiles, digest } = require("./fixtures/staticDistributionFixture.cjs");
const first = thirdPartySource("First", "https://first.example.com");
const second = thirdPartySource("Netcatty", "https://second.example.com");
test("identical mirrored catalogs never share selection tokens or offline cache", async () => {
const service = (source) => new RegistryCatalogService({ source, fetch: async (url) => {
assert.equal(new URL(url).origin, source.origin);
return new Response(realFiles().get(new URL(url).pathname));
} });
const a = service(first); const b = service(second);
const entryA = (await a.list()).entries[0];
const entryB = (await b.list()).entries[0];
assert.notEqual(entryA.selectionToken, entryB.selectionToken);
await assert.rejects(b.readme(entryB.id, entryA.selectionToken), /changed/);
const offline = new RegistryCatalogService({ source: second, fetch: async () => { throw new Error("offline"); } });
assert.deepEqual((await offline.list()).entries, [], "third-party failure never substitutes the official bundled catalog");
});
test("delivery v2 binds package bytes, size, source and fresh non-withdrawn index before install", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "source-catalog-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic package bytes; manager is stubbed");
const sha256 = digest(bytes);
const asset = `/assets/${sha256}.ncpkg`;
const entry = { ...structuredClone(catalog[0]), artifact: { url: `${first.origin}${asset}`, sha256 } };
let files = makeFiles([entry], (index, delivery) => {
index.plugins[0].versions[0].distribution = "release";
delivery.schemaVersion = 2;
delivery.plugins[0].artifact = { path: asset, sha256, size: bytes.length };
});
files.set(asset, bytes);
let calls = 0;
const service = new RegistryCatalogService({ source: first, getTempFilePath: (name) => path.join(root, name), fetch: async (url) => {
assert.equal(new URL(url).origin, first.origin);
const body = files.get(new URL(url).pathname);
return body ? new Response(body) : new Response("missing", { status: 404 });
}, manager: { install(archive, options) {
assert.deepEqual(fs.readFileSync(archive), bytes);
assert.equal(options.enable, false);
assert.equal(options.source.id, first.id);
assert.equal(options.catalog.artifact.sha256, sha256);
calls++;
} } });
const selected = (await service.list()).entries[0];
assert.ok(selected, service.error);
await service.install(selected.id, selected.selectionToken);
assert.equal(calls, 1);
files = makeFiles([]);
await assert.rejects(service.install(selected.id, selected.selectionToken), /curated catalog/);
assert.equal(calls, 1);
});
+59
View File
@@ -0,0 +1,59 @@
"use strict";
const dns = require("node:dns/promises");
const https = require("node:https");
const { BlockList, isIPv4 } = require("node:net");
const { Readable } = require("node:stream");
const { canonicalSourceOrigin } = require("./sourceIdentity.cjs");
const blocked = new BlockList();
for (const [address, prefix] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]]) blocked.addSubnet(address, prefix, "ipv4");
function isPublicAddress(address) { return isIPv4(address) && !blocked.check(address, "ipv4"); }
// This transport is deliberately separate from plugin network capability access.
// No cookies, credentials, redirects or proxy. DNS is validated and pinned to
// the actual connection, rather than checked separately before another lookup.
function createSourceFetch(origin, options = {}) {
canonicalSourceOrigin(origin);
const resolve4 = options.resolve4 ?? dns.resolve4;
const request = options.request ?? https.get;
return async (value, init = {}) => {
const url = new URL(value);
if (url.origin !== origin || url.username || url.password || url.search || url.hash
|| /[\\%\s]/u.test(value) || url.href !== value
|| !/^\/(?:v1\/snapshot\.json|snapshots\/[a-f0-9]{64}\/(?:catalog|index|delivery)\.json|assets\/[a-f0-9]{64}\.(?:md|png|webp|ncpkg))$/u.test(url.pathname)) throw new Error("Source resource is outside its static origin or protocol paths");
init.signal?.throwIfAborted();
// IPv4-only initial transport: IPv6-only sources fail visibly, never fall
// back to an unchecked resolver. This keeps address classification explicit.
const addresses = await new Promise((resolve, reject) => {
const abort = () => reject(init.signal.reason);
init.signal?.addEventListener("abort", abort, { once: true });
Promise.resolve().then(() => resolve4(url.hostname)).then(resolve, reject)
.finally(() => init.signal?.removeEventListener("abort", abort));
});
init.signal?.throwIfAborted();
if (!addresses.length || addresses.some((address) => !isPublicAddress(address))) throw new Error("Source DNS resolves to a non-public address");
const address = addresses[0];
return new Promise((resolve, reject) => {
const req = request(url, {
agent: false, signal: init.signal,
lookup(_host, opts, callback) {
if (opts?.all) callback(null, [{ address, family: 4 }]);
else callback(null, address, 4);
},
headers: { accept: "application/json, text/plain, application/octet-stream" },
}, (response) => {
try {
const status = response.statusCode ?? 502;
// fetchBounded rejects redirects/non-2xx and cancels the stream.
const headers = new Headers();
for (const [key, value] of Object.entries(response.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(", ") : value);
if ([204, 205, 304].includes(status)) { response.destroy(); resolve(new Response(null, { status, headers })); }
else resolve(new Response(Readable.toWeb(response), { status, headers }));
} catch (error) { response.destroy(); reject(error); }
});
req.once("error", reject);
});
};
}
module.exports = { createSourceFetch, isPublicAddress };
+47
View File
@@ -0,0 +1,47 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createSourceFetch, isPublicAddress } = require("./sourceFetch.cjs");
const { EventEmitter } = require("node:events");
const { Readable } = require("node:stream");
test("source transport refuses non-public resolution before any HTTP request", async () => {
let requests = 0;
for (const address of ["127.0.0.1", "10.1.2.3", "169.254.169.254", "172.16.1.1", "192.168.1.1", "100.64.1.1", "224.0.0.1", "::1", "::ffff:127.0.0.1"]) {
assert.equal(isPublicAddress(address), false);
const fetch = createSourceFetch("https://example.com", { resolve4: async () => [address], request() { requests++; } });
await assert.rejects(fetch("https://example.com/v1/snapshot.json"), /non-public/);
}
assert.equal(requests, 0);
assert.equal(isPublicAddress("1.1.1.1"), true);
});
test("static resources cannot redirect requests to unrelated origins or arbitrary paths", async () => {
let resolutions = 0;
const fetch = createSourceFetch("https://example.com", { resolve4: async () => { resolutions++; return ["1.1.1.1"]; } });
for (const value of ["https://other.com/v1/snapshot.json", "https://example.com/admin", "https://example.com/assets/%2e%2e/secret", "https://example.com/v1/snapshot.json?token=secret", "https://user:pass@example.com/v1/snapshot.json"]) await assert.rejects(fetch(value));
assert.equal(resolutions, 0);
});
test("the HTTPS connection uses the checked address without a second DNS lookup or credentials", async () => {
let resolutions = 0;
const fetch = createSourceFetch("https://example.com", {
resolve4: async () => { resolutions++; return resolutions === 1 ? ["1.1.1.1"] : ["127.0.0.1"]; },
request(url, options, callback) {
assert.equal(url.hostname, "example.com");
assert.equal(options.agent, false);
assert.equal(options.headers.cookie, undefined);
assert.equal(options.headers.authorization, undefined);
options.lookup("example.com", { all: true }, (error, addresses) => {
assert.equal(error, null);
assert.deepEqual(addresses, [{ address: "1.1.1.1", family: 4 }]);
});
const response = Readable.from([Buffer.from("synthetic metadata")]);
response.statusCode = 200; response.headers = {};
callback(response);
return new EventEmitter();
},
});
assert.equal(await (await fetch("https://example.com/v1/snapshot.json")).text(), "synthetic metadata");
assert.equal(resolutions, 1);
});
+44
View File
@@ -0,0 +1,44 @@
"use strict";
const { createHash } = require("node:crypto");
const { isIP } = require("node:net");
const OFFICIAL_ORIGIN = "https://plugins.netcatty.app";
const OFFICIAL_SOURCE = Object.freeze({ id: "official", kind: "official", name: "Netcatty marketplace", origin: OFFICIAL_ORIGIN });
const LOCAL_SOURCE = Object.freeze({ id: "local", kind: "local", name: "Local import", origin: null });
const hash = (value) => createHash("sha256").update(value).digest("hex");
// Source identity is host-owned. Neither a manifest nor a catalog supplies kind/id.
function canonicalSourceOrigin(value) {
if (typeof value !== "string" || value.length > 2048 || /[\s\\%?#]/u.test(value)) throw new Error("Use a public HTTPS origin without credentials, query or fragment");
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || url.port
|| !["/", "/v1/snapshot.json"].includes(url.pathname)) throw new Error("Use an HTTPS origin or its /v1/snapshot.json endpoint");
const hostname = url.hostname.toLowerCase();
if (isIP(hostname) || hostname.includes(":") || !hostname.includes(".") || hostname.endsWith(".")
|| /(?:^|\.)(?:localhost|local|internal|invalid|test)$/u.test(hostname)) throw new Error("Market sources must use a public DNS hostname");
// Explicitly reject textual credentials/normalization tricks before canonicalizing case.
if (!/^https:\/\/[^/@]+(?:\/|\/v1\/snapshot\.json)?$/iu.test(value)) throw new Error("Invalid market source address");
return url.origin;
}
function thirdPartySource(name, address) {
if (typeof name !== "string" || !name.trim() || name.length > 100 || /[\u0000-\u001f\u007f]/u.test(name)) throw new Error("Source name must contain 1-100 printable characters");
const origin = canonicalSourceOrigin(address);
if (origin === OFFICIAL_ORIGIN) throw new Error("The official source is already built in");
return Object.freeze({ id: `third-party:${hash(origin)}`, kind: "third-party", name: name.trim(), origin });
}
function assertSource(source) {
if (!source || typeof source !== "object") throw new Error("Missing host source identity");
const trusted = source.id === "official" ? OFFICIAL_SOURCE : source.id === "local" ? LOCAL_SOURCE : thirdPartySource(source.name, source.origin);
if (["id", "kind", "origin"].some((key) => trusted[key] !== source[key])) throw new Error("Invalid host source identity");
return { ...trusted };
}
function privateOwner(source, pluginId) {
if (typeof pluginId !== "string" || !/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) throw new Error("Invalid plugin identity");
return `source:${hash(assertSource(source).id)}:${pluginId}`;
}
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner };
+27
View File
@@ -0,0 +1,27 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner } = require("./sourceIdentity.cjs");
test("renames preserve source identity; public plugin IDs do not define private ownership", () => {
const a = thirdPartySource("First", "https://EXAMPLE.com/v1/snapshot.json");
const renamed = thirdPartySource("Renamed", "https://example.com/");
assert.equal(a.id, renamed.id);
const owners = [a, thirdPartySource("Other", "https://other.example.com"), OFFICIAL_SOURCE, LOCAL_SOURCE].map((source) => privateOwner(source, "example.plugin"));
assert.equal(new Set(owners).size, 4);
assert.equal(privateOwner(a, "example.plugin"), privateOwner(renamed, "example.plugin"));
assert.throws(() => privateOwner(a, owners[0]));
});
test("remote metadata cannot promote a source to official or local", () => {
const source = thirdPartySource("Netcatty", "https://example.com");
for (const kind of ["official", "local"]) assert.throws(() => assertSource({ ...source, kind }));
assert.throws(() => assertSource({ ...source, id: "official" }));
assert.throws(() => thirdPartySource("Mirror", "https://plugins.netcatty.app"));
});
test("source addresses exclude secrets, ambiguous paths and local literals", () => {
for (const url of ["http://example.com", "https://user:secret@example.com", "https://example.com?token=x", "https://example.com/#x", "https://127.0.0.1", "https://[::1]", "https://localhost", "https://a.local", "https://example.com:8443", "https://example.com/../v1/snapshot.json", "https://example.com/%2e", "https://example.com\\@other.com", "https://example.com."]) {
assert.throws(() => canonicalSourceOrigin(url), url);
}
});
+237
View File
@@ -0,0 +1,237 @@
"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 { thirdPartySource, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
const { settingsSidecarKey } = require("./pluginSyncSidecarHelpers.cjs");
const id = "example.plugin";
const first = thirdPartySource("First", "https://first.example.com");
const second = thirdPartySource("Second", "https://second.example.com");
function install(db, source) {
db.installVersion({ pluginId: id, version: "1.0.0", manifest: {}, archiveSha256: "a".repeat(64), packageRelativePath: "example.plugin/1.0.0", source });
}
test("schema-4 local provenance survives upgrade and uninstall until explicit reset", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-upgrade-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
let db = new PluginDatabase(file);
install(db, LOCAL_SOURCE);
db.setSetting(id, "private", "application", "application", "retained");
db.db.exec("DROP TABLE plugin_source_bindings; PRAGMA user_version = 4");
db.close();
db = new PluginDatabase(file);
t.after(() => db.close());
db.removePlugin(id);
assert.equal(db.getRetainedPluginSource(id).id, "local");
assert.throws(() => install(db, first), /source differs/);
assert.equal(db.getSetting(id, "private", "application", "application"), "retained");
db.clearUninstalledSourceData(id, "local");
install(db, first);
assert.equal(db.getSetting(id, "private", "application", "application"), undefined);
});
test("failed provenance backfill does not publish the new schema version", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-upgrade-failure-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
const db = new PluginDatabase(file);
t.after(() => db.close());
install(db, LOCAL_SOURCE);
db.db.exec("DELETE FROM plugin_source_bindings; PRAGMA user_version = 5; CREATE TRIGGER fail_source_backfill BEFORE INSERT ON plugin_source_bindings BEGIN SELECT RAISE(ABORT, 'synthetic backfill failure'); END;");
assert.throws(() => new PluginDatabase(file, { database: db.db }), /synthetic backfill failure/);
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 5);
db.db.exec("DROP TRIGGER fail_source_backfill");
new PluginDatabase(file, { database: db.db });
db.removePlugin(id);
assert.equal(db.getRetainedPluginSource(id).id, "local");
assert.throws(() => install(db, first), /source differs/);
});
test("same ID cannot change source during install, uninstall or database restart without explicit private reset", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-isolation-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
let db = new PluginDatabase(file);
install(db, first);
db.setValue(id, "key", "old KV");
assert.deepEqual(db.listKeys(id), ["key"]);
db.setSetting(id, "greeting", "application", "application", "old setting");
db.setViewState(id, "view", "self", { private: true });
db.upsertPermissionGrant({ pluginId: id, permission: "views", resource: "self", resourceKind: "exact", declarationHash: "same-code" });
db.upsertSecret({ pluginId: id, key: "secret", secretRef: "old-ref", ciphertext: Buffer.from("ciphertext") });
db.setSyncSidecar(id, "account_baseline", "account", "old baseline");
assert.equal(db.getSecretByRef(id, "old-ref").pluginId, id);
assert.deepEqual(db.listPluginIdsWithSecretKeyPrefix("secret"), [id], "host provider recovery receives the current public plugin ID, not its opaque storage owner");
assert.equal(db.listPermissionGrants(id)[0].pluginId, id);
assert.throws(() => install(db, second), /source differs/);
assert.throws(() => db.clearUninstalledSourceData(id, first.id), /Uninstall/);
db.removePlugin(id);
db.close();
db = new PluginDatabase(file);
t.after(() => db.close());
assert.throws(() => install(db, second), /source differs/);
assert.throws(() => install(db, LOCAL_SOURCE), /source differs/);
assert.throws(() => db.clearUninstalledSourceData(id, second.id), /changed/);
db.clearUninstalledSourceData(id, first.id);
install(db, second);
assert.equal(db.getActivePlugin(id).source.id, second.id);
assert.equal(db.getValue(id, "key"), undefined);
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
assert.equal(db.getViewState(id, "view", "self"), undefined);
assert.deepEqual(db.listPermissionGrants(id), []);
assert.equal(db.getSecretByRef(id, "old-ref"), null);
assert.equal(db.getSyncSidecar(id, "account_baseline", "account"), undefined);
});
test("old-source cloud settings remain orphaned and cannot hydrate a new source", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-sync-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
install(db, first);
const oldOwner = db.privateOwner(id);
db.removePlugin(id);
db.clearUninstalledSourceData(id, first.id);
install(db, second);
const calls = [];
const service = new PluginSyncSidecarService({ database: db, contributionService: {
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
updateSetting: (...args) => calls.push(args),
} });
const entry = { pluginId: oldOwner, kind: "settings", key: settingsSidecarKey("greeting", "application", "application"), value: "old remote data", updatedAt: 1000 };
await service.applyFromSync({ version: 1, entries: [entry] });
await service.hydrateInstalledPluginSettings(id);
assert.deepEqual(calls, []);
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
await service.applyFromSync({ version: 1, entries: [{ ...entry, pluginId: db.privateOwner(id), value: "new source" }] });
assert.equal(calls[0][0], id);
assert.equal(db.getSetting(id, "greeting", "application", "application"), "new source");
});
test("in-flight sync cannot apply local B after A yields and B changes source", { timeout: 10000 }, async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-sync-race-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
const a = "aa.plugin", b = "bb.plugin";
const put = (pluginId, source) => db.installVersion({ pluginId, version: "1.0.0", manifest: {
id: pluginId, version: "1.0.0", main: { browser: "index.js" },
contributes: { settings: [{ id: `${pluginId}.greeting`, label: "Greeting", control: "text", scope: "application", sync: true }] },
}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
put(a, LOCAL_SOURCE); put(b, LOCAL_SOURCE);
let release, entered;
const waiting = new Promise(resolve => { entered = resolve; });
const paused = new Promise(resolve => { release = resolve; });
const calls = [];
const contributionService = new PluginContributionService({ database: db, runtimeSupervisor: {
async notify(pluginId) {
calls.push(pluginId);
if (pluginId === a) { entered(); await paused; }
},
} });
const service = new PluginSyncSidecarService({ database: db, contributionService });
const entries = [a, b].map(pluginId => ({ pluginId: db.privateOwner(pluginId), kind: "settings", key: settingsSidecarKey(`${pluginId}.greeting`, "application", "application"), value: `OLD ${pluginId}`, updatedAt: 1000 }));
const applying = service.applyFromSync({ version: 1, entries });
await waiting;
db.removePlugin(b); db.clearUninstalledSourceData(b, "local"); put(b, first);
release(); await applying;
assert.deepEqual(calls, [a]);
assert.equal(db.getSetting(b, `${b}.greeting`, "application", "application"), undefined);
});
for (const mode of ["apply", "hydrate", "reset"]) {
test(`${mode} drops post-await writes after the current plugin changes source`, async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-await-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
install(db, LOCAL_SOURCE);
const oldOwner = db.privateOwner(id);
const key = settingsSidecarKey("greeting", "application", "application");
db.setSyncSidecar(id, "settings", key, "OLD", 1000);
db.setSetting(id, "greeting", "application", "application", "OLD", 1000);
let release, entered;
const waiting = new Promise(resolve => { entered = resolve; });
const paused = new Promise(resolve => { release = resolve; });
const service = new PluginSyncSidecarService({ database: db, contributionService: {
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
async updateSetting(pluginId, settingId, value) {
db.setSetting(pluginId, settingId, "application", "application", value);
entered(); await paused;
},
async resetSetting(pluginId, settingId) {
db.deleteSetting(pluginId, settingId, "application", "application");
entered(); await paused;
},
} });
const applying = mode === "hydrate" ? service.hydrateInstalledPluginSettings(id)
: service.applyFromSync({ version: 1, entries: mode === "reset" ? [] : [{ pluginId: oldOwner, kind: "settings", key, value: "OLD REMOTE", updatedAt: 2000 }] });
await waiting;
db.removePlugin(id); db.clearUninstalledSourceData(id, "local"); install(db, first);
db.setSetting(id, "greeting", "application", "application", "NEW", 3000);
release(); await applying;
assert.equal(db.getSetting(id, "greeting", "application", "application"), "NEW");
assert.equal(db.listAllSettings().some(row => row.pluginId === oldOwner), false, "a completed reset must not be undone by stale LWW writes");
});
}
test("local owners are encoded and raw remote IDs never resolve into another source", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-local-owner-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
install(db, LOCAL_SOURCE);
const owner = db.privateOwner(id);
assert.notEqual(owner, id);
assert.equal(db.publicPluginId(id), null);
assert.equal(db.publicPluginId(owner), id);
db.removePlugin(id); db.clearUninstalledSourceData(id, "local"); install(db, first);
assert.equal(db.publicPluginId(owner), null);
const calls = [];
const service = new PluginSyncSidecarService({ database: db, contributionService: {
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
updateSetting: (...args) => calls.push(args),
} });
await service.applyFromSync({ version: 1, entries: [{ pluginId: id, kind: "settings", key: settingsSidecarKey("greeting", "application", "application"), value: "RAW OLD LOCAL", updatedAt: 1000 }] });
assert.deepEqual(calls, []);
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
});
test("schema-6 raw local data converts atomically without a runtime fallback", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-owner-upgrade-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
let db = new PluginDatabase(file);
install(db, LOCAL_SOURCE);
db.setSetting(id, "greeting", "application", "application", "RETAIN");
db.setValue(id, "key", "KEEP KV");
const owner = db.privateOwner(id);
db.db.prepare("UPDATE plugin_settings SET plugin_id = ? WHERE plugin_id = ?").run(id, owner);
db.db.prepare("UPDATE plugin_kv SET key = 'key' WHERE plugin_id = ?").run(id);
db.db.exec("PRAGMA user_version = 6");
db.close();
db = new PluginDatabase(file); t.after(() => db.close());
assert.equal(db.getSetting(id, "greeting", "application", "application"), "RETAIN");
assert.equal(db.getValue(id, "key"), "KEEP KV");
assert.deepEqual(db.listKeys(id), ["key"]);
assert.equal(db.listAllSettings()[0].pluginId, owner);
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 7);
});
test("owner conversion conflict preserves schema-6 rows and does not publish schema 7", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-owner-conflict-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
const db = new PluginDatabase(file); t.after(() => db.close());
install(db, LOCAL_SOURCE);
db.setSetting(id, "greeting", "application", "application", "ENCODED");
db.db.prepare("INSERT INTO plugin_settings(plugin_id,setting_id,scope,scope_id,value_json,updated_at) VALUES(?, 'greeting', 'application', 'application', ?, 1)").run(id, JSON.stringify("RAW"));
db.db.exec("PRAGMA user_version = 6");
assert.throws(() => new PluginDatabase(file, { database: db.db }), /UNIQUE constraint/);
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 6);
assert.equal(db.db.prepare("SELECT count(*) AS count FROM plugin_settings").get().count, 2);
});
+62
View File
@@ -0,0 +1,62 @@
"use strict";
const { OFFICIAL_SOURCE, thirdPartySource, assertSource } = require("./sourceIdentity.cjs");
const PREFERENCE_KEY = "market-sources-v1";
// Only this host service creates source records. Remote catalogs cannot mutate them.
class PluginSourceRegistry {
constructor(database) { this.database = database; }
#read() {
const row = this.database.db.prepare("SELECT value_json FROM plugin_host_preferences WHERE key = ?").get(PREFERENCE_KEY);
if (!row) return [];
const values = JSON.parse(row.value_json);
if (!Array.isArray(values) || values.length > 32) throw new Error("Invalid saved plugin sources");
const ids = new Set();
return values.map((value) => {
const source = assertSource(value);
if (source.kind !== "third-party" || ids.has(source.id) || typeof value.enabled !== "boolean") throw new Error("Invalid saved plugin source");
ids.add(source.id);
return { ...source, enabled: value.enabled };
});
}
#write(values) {
this.database.db.prepare("INSERT INTO plugin_host_preferences(key,value_json,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at")
.run(PREFERENCE_KEY, JSON.stringify(values), this.database.clock());
}
list() { return [{ ...OFFICIAL_SOURCE, enabled: true }, ...this.#read()]; }
get(id) {
const source = this.list().find((item) => item.id === id);
if (!source || !source.enabled) throw new Error("This market source was removed or disabled; no alternate source will be used");
return source;
}
add(name, address) {
const source = thirdPartySource(name, address);
this.database.transaction(() => {
const values = this.#read();
if (values.some((item) => item.id === source.id)) throw new Error("This market source already exists");
if (values.length >= 32) throw new Error("At most 32 third-party sources are supported");
this.#write([...values, { ...source, enabled: true }]);
});
return this.get(source.id);
}
update(id, name, enabled) {
if (typeof enabled !== "boolean") throw new Error("Source enabled state must be boolean");
this.database.transaction(() => {
const values = this.#read();
const index = values.findIndex((source) => source.id === id);
if (index < 0) throw new Error("Third-party source does not exist");
values[index] = { ...thirdPartySource(name, values[index].origin), enabled };
this.#write(values);
});
return this.list().find((source) => source.id === id);
}
remove(id) {
this.database.transaction(() => {
const values = this.#read();
if (!values.some((source) => source.id === id)) throw new Error("Third-party source does not exist");
this.#write(values.filter((source) => source.id !== id));
});
// Package lifetime and optional disabling belong to serialized PluginManager.
}
}
module.exports = { PluginSourceRegistry };
+33
View File
@@ -0,0 +1,33 @@
"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 { PluginSourceRegistry } = require("./sourceRegistry.cjs");
test("sources persist, rename without changing identity, and fail closed when disabled or removed", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-sources-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
let db = new PluginDatabase(file);
let registry = new PluginSourceRegistry(db);
assert.equal(registry.list().length, 1);
const source = registry.add("Example", "https://example.com");
assert.throws(() => registry.add("Duplicate", "https://example.com/v1/snapshot.json"));
registry.update(source.id, "Renamed", false);
assert.throws(() => registry.get(source.id), /removed or disabled/);
db.close();
db = new PluginDatabase(file);
t.after(() => db.close());
registry = new PluginSourceRegistry(db);
assert.equal(registry.list()[1].name, "Renamed");
registry.update(source.id, "Again", true);
assert.equal(registry.get(source.id).origin, source.origin);
registry.remove(source.id);
assert.throws(() => registry.get(source.id), /removed or disabled/);
assert.throws(() => registry.remove("official"));
assert.throws(() => registry.update("official", "Impersonated", false));
assert.equal(registry.get("official").origin, "https://plugins.netcatty.app");
});
@@ -0,0 +1,106 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { assertDownloadUrl, fetchBounded, validateCatalog } = require("./catalogService.cjs");
const { RegistryCatalogService, REGISTRY_URL } = require("./registryCatalogService.cjs");
const { fetchVerified, assertAsset } = require("./distributionSnapshot.cjs");
const { read, digest, catalog, delivery, realFiles, makeFiles, responseFor } = require("./fixtures/staticDistributionFixture.cjs");
test("exact registry PR3 bootstrap hashes and all README/icon assets verify without GitHub requests", async () => {
assert.equal(digest(read("snapshot.json")), "2878885057a01585e34897c226bcfd81615345d843ea94297980de7e397b4617");
assert.equal(digest(read("delivery.json")), "53e5189d2cb58a02826931d5d6321ad3e556b924ef80d38636dc9e66a7df8c55");
const requests = []; const files = realFiles();
const fetch = async (url, options) => {
requests.push(url); assert.equal(new URL(url).origin, "https://plugins.netcatty.app");
assert.equal(options.credentials, "omit"); assert.equal(options.redirect, "manual");
return responseFor(files, url);
};
const service = new RegistryCatalogService({ fetch });
const snapshot = await service.list();
assert.equal(snapshot.error, null); assert.equal(snapshot.source, "registry");
assert.equal(requests.length, 4); assert.equal(requests[0], REGISTRY_URL);
for (const entry of snapshot.entries) {
const resource = delivery.plugins.find((item) => item.id === entry.id);
assert.equal(await service.readme(entry.id, entry.selectionToken), read(resource.readme.path.slice(1)).toString());
for (const icon of [resource.iconLight, resource.iconDark]) if (icon) {
assertAsset(icon, ["png", "webp"]);
const bytes = await fetchVerified(icon, fetch);
assert.equal(bytes.length, icon.size); assert.equal(digest(bytes), icon.sha256);
}
assert.equal(entry.artifact, undefined);
assert.equal(resource.avatar, null); assert.equal(resource.artifact, null);
}
assert.ok(requests.every((url) => !/github/u.test(url)));
});
test("GitHub, tracking and noncanonical URLs are rejected before any upstream request", async () => {
for (const url of [
"https://api.github.com/repos/a/b", "https://raw.githubusercontent.com/a/b/main/README.md",
"https://avatars.githubusercontent.com/u/1", "https://github.com/a/b/releases/download/v1/a.ncpkg",
"https://release-assets.githubusercontent.com/a", "https://objects.githubusercontent.com/a",
"https://plugins.netcatty.app.attacker.invalid/a", "https://plugins.netcatty.app@attacker.invalid/a",
"http://plugins.netcatty.app/a", "https://plugins.netcatty.app/a?track=1", "https://plugins.netcatty.app/a#fragment",
"https://plugins.netcatty.app/a/../b", "https://plugins.netcatty.app/%2e%2e/b", "https://plugins.netcatty.app/\ta",
]) {
assert.throws(() => assertDownloadUrl(url));
assert.throws(() => validateCatalog([{ ...catalog[0], artifact: { url, sha256: "a".repeat(64) } }]));
await assert.rejects(fetchBounded(url, 1024, () => assert.fail("invalid source must not be requested")));
}
});
test("official responses never redirect to GitHub or another official path", async () => {
for (const location of ["https://github.com/a", "https://raw.githubusercontent.com/a/b/main/catalog.json", "https://plugins.netcatty.app/another.json"]) {
const requests = [];
await assert.rejects(fetchBounded(REGISTRY_URL, 1024, async (url) => {
requests.push(url); return new Response(null, { status: 302, headers: { location } });
}), /redirects/);
assert.deepEqual(requests, [REGISTRY_URL]);
}
});
test("cross-document mismatches, withdrawn versions and unsupported assets preserve the complete good cache", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list(); const goodResources = service.resources;
const mutations = [
(index, sidecar) => { sidecar.registryCommit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins[0].commit = "a".repeat(40); },
(index, sidecar) => { sidecar.plugins.push(sidecar.plugins[0]); },
(index, sidecar) => { sidecar.plugins.pop(); },
(index, sidecar) => { sidecar.plugins[0].readme.path = "https://raw.githubusercontent.com/a/b/main/README.md"; },
(index, sidecar) => { sidecar.plugins[0].readme.path = `/assets/${sidecar.plugins[0].readme.sha256}.png`; },
(index, sidecar) => { sidecar.plugins[0].iconLight.path = `/assets/${sidecar.plugins[0].iconLight.sha256}.md`; },
(index, sidecar) => { sidecar.plugins[0].avatar = { path: "/assets/avatar.png" }; },
(index, sidecar) => { sidecar.plugins[0].artifact = { path: "/assets/package.ncpkg" }; },
(index) => { index.plugins[0].versions[0].withdrawal = { at: "2026-10-26T00:00:00Z", reason: "synthetic withdrawal", advisory: "https://github.com/example/advisory" }; },
(index) => { index.plugins[0].versions[0].entry.description += " mismatch"; },
(index) => { index.plugins[0].current = "9.0.0"; },
(index) => { index.plugins.push(index.plugins[0]); },
(index, sidecar) => { sidecar.unknown = true; },
];
for (const mutate of mutations) {
files = makeFiles(catalog, mutate);
// Preserve the pointer's trusted registry identity when testing delivery mismatch.
const pointer = JSON.parse(files.get("/v1/snapshot.json")); pointer.registryCommit = delivery.registryCommit;
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(pointer)));
const rejected = await service.list();
assert.equal(rejected.source, "cached"); assert.ok(rejected.error);
assert.deepEqual(rejected.entries, good.entries); assert.equal(service.resources, goodResources);
}
});
test("pointer/document/resource byte mismatches and partial availability never publish partial state", async () => {
let files = realFiles(); const service = new RegistryCatalogService({ fetch: async (url) => responseFor(files, url) });
const good = await service.list();
const pointer = JSON.parse(read("snapshot.json"));
for (const name of ["catalog", "index", "delivery"]) {
files = realFiles(); files.set(pointer[name].path, Buffer.from("{}"));
const rejected = await service.list(); assert.equal(rejected.source, "cached"); assert.deepEqual(rejected.entries, good.entries);
files = realFiles(); files.delete(pointer[name].path);
assert.deepEqual((await service.list()).entries, good.entries);
}
files = realFiles(); const altered = structuredClone(pointer); altered.catalog.path = "/catalog.json";
files.set("/v1/snapshot.json", Buffer.from(JSON.stringify(altered)));
assert.match((await service.list()).error, /resource reference/);
files = realFiles(); files.set(delivery.plugins[0].readme.path, Buffer.from("x".repeat(delivery.plugins[0].readme.size)));
await assert.rejects(service.readme(good.entries[0].id, good.entries[0].selectionToken), /SHA-256 mismatch/);
});
+14 -3
View File
@@ -99,9 +99,15 @@ function createPreloadApi(ctx) {
with (ctx) {
return {
getPluginRuntimeStatus: () => ipcRenderer.invoke("netcatty:plugins:status"),
listPluginCatalog: () => ipcRenderer.invoke("netcatty:plugins:catalog"),
getPluginCatalogReadme: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken }),
installCatalogPlugin: (id, selectionToken) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken }),
getPluginSourceBinding: (pluginId) => ipcRenderer.invoke("netcatty:plugins:source-binding", { pluginId }),
resetPluginSource: (pluginId, expectedSourceId) => ipcRenderer.invoke("netcatty:plugins:reset-source", { pluginId, expectedSourceId }),
listPluginSources: () => ipcRenderer.invoke("netcatty:plugins:sources"),
addPluginSource: (source) => ipcRenderer.invoke("netcatty:plugins:add-source", source),
updatePluginSource: (source) => ipcRenderer.invoke("netcatty:plugins:update-source", source),
removePluginSource: (id, disablePlugins) => ipcRenderer.invoke("netcatty:plugins:remove-source", { id, disablePlugins }),
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginInteractionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:interaction-ready", { ready }),
respondPluginInteraction: (response) => ipcRenderer.invoke("netcatty:plugins:interaction-response", response),
onPluginInteraction: (callback) => {
@@ -122,6 +128,11 @@ function createPreloadApi(ctx) {
}),
restartPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:restart", { pluginId }),
uninstallPlugin: (pluginId) => ipcRenderer.invoke("netcatty:plugins:uninstall", { pluginId }),
getPluginLogStatus: () => ipcRenderer.invoke("netcatty:plugins:log-status"),
configurePluginLogs: (settings) => ipcRenderer.invoke("netcatty:plugins:log-configure", settings),
openPluginLogFolder: () => ipcRenderer.invoke("netcatty:plugins:log-open"),
exportPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-export"),
clearPluginLogs: () => ipcRenderer.invoke("netcatty:plugins:log-clear"),
getPluginThemes: () => ipcRenderer.invoke("netcatty:plugins:theme-catalog"),
applyPluginTheme: (request) => ipcRenderer.invoke("netcatty:plugins:theme-apply", request),
onPluginThemesChanged: (callback) => {
Vendored
+32 -3
View File
@@ -364,7 +364,15 @@ declare global {
audit: Array<{ event: string; createdAt: number; permission?: string; scope?: string }>;
}
interface NetcattyPluginSource {
id: string;
kind: 'official' | 'third-party' | 'local';
name: string;
origin: string | null;
enabled?: boolean;
}
interface NetcattyInstalledPlugin {
source?: NetcattyPluginSource;
id: string;
enabled: boolean;
activeVersion: string | null;
@@ -439,11 +447,32 @@ declare global {
| NetcattyPluginAuthenticationChallengeOpenEvent
| NetcattyPluginAuthenticationChallengeCancelEvent;
interface NetcattyPluginLogStatus {
saveToDisk: boolean;
verbose: boolean;
droppedRecords: number;
writeErrors: number;
pendingBytes: number;
maxBytes: number;
retentionDays: number;
}
interface NetcattyBridge {
getPluginLogStatus?(): Promise<NetcattyPluginLogStatus>;
configurePluginLogs?(settings: { saveToDisk: boolean; verbose: boolean }): Promise<NetcattyPluginLogStatus>;
openPluginLogFolder?(): Promise<NetcattyPluginLogStatus>;
exportPluginLogs?(): Promise<NetcattyPluginLogStatus>;
clearPluginLogs?(): Promise<NetcattyPluginLogStatus>;
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
listPluginCatalog?(): Promise<NetcattyPluginCatalogSnapshot>;
getPluginCatalogReadme?(id: string, selectionToken?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string): Promise<NetcattyInstalledPlugin>;
getPluginSourceBinding?(pluginId: string): Promise<NetcattyPluginSource | null>;
resetPluginSource?(pluginId: string, expectedSourceId: string): Promise<void>;
listPluginSources?(): Promise<NetcattyPluginSource[]>;
addPluginSource?(source: { name: string; address: string }): Promise<NetcattyPluginSource>;
updatePluginSource?(source: { id: string; name: string; enabled: boolean }): Promise<NetcattyPluginSource>;
removePluginSource?(id: string, disablePlugins: boolean): Promise<void>;
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;