Compare commits

...
50 changed files with 2670 additions and 201 deletions

No files matched your search

+26
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const enCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Check for updates",
"settings.plugins.landing.checkingUpdates": "Checking…",
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Unavailable sources: {count}.",
"settings.plugins.landing.updateReview": "Review updates in the plugin manager before installing.",
"settings.plugins.landing.updateAvailable": "Version {version} available",
"settings.plugins.landing.loading": "Loading installed plugins…",
"settings.plugins.landing.settingsUnavailable": "No plugin settings are available in this context.",
"settings.plugins.landing.options": "Plugin options",
"settings.plugins.landing.manager": "Plugin manager",
"settings.plugins.landing.managerDescription": "Browse plugins and manage installed packages and sources.",
"settings.plugins.landing.browse": "Browse",
"settings.plugins.landing.refresh": "Refresh installed plugins",
"settings.plugins.landing.unavailable": "Plugin management is unavailable in this session.",
"settings.plugins.landing.empty": "No plugins installed. Browse the plugin manager to get started.",
"settings.plugins.landing.search": "Search installed plugins",
"settings.plugins.landing.settingsFor": "Settings for {name}",
"settings.plugins.landing.enableSettings": "Enable this plugin to open its available settings.",
"settings.plugins.landing.sourceUnknown": "Source unknown",
"settings.plugins.landing.status.running": "Running",
"settings.plugins.landing.status.starting": "Starting",
"settings.plugins.landing.status.stopping": "Stopping",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "Quarantined",
"settings.plugins.landing.status.unknown": "Status unknown",
"settings.plugins.landing.status.idle": "Enabled, idle",
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
+26
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const esCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
"settings.plugins.landing.checkingUpdates": "Buscando…",
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
"settings.plugins.landing.updateSourcesFailed": "Fuentes no disponibles: {count}.",
"settings.plugins.landing.updateReview": "Revisa las actualizaciones en el gestor de plugins antes de instalarlas.",
"settings.plugins.landing.updateAvailable": "Versión {version} disponible",
"settings.plugins.landing.loading": "Cargando plugins instalados…",
"settings.plugins.landing.settingsUnavailable": "No hay ajustes del plugin disponibles en este contexto.",
"settings.plugins.landing.options": "Opciones de plugins",
"settings.plugins.landing.manager": "Gestor de plugins",
"settings.plugins.landing.managerDescription": "Explora plugins y gestiona los paquetes instalados y sus fuentes.",
"settings.plugins.landing.browse": "Explorar",
"settings.plugins.landing.refresh": "Actualizar la lista de plugins instalados",
"settings.plugins.landing.unavailable": "La gestión de plugins no está disponible en esta sesión.",
"settings.plugins.landing.empty": "No hay plugins instalados. Abre el gestor de plugins para empezar.",
"settings.plugins.landing.search": "Buscar plugins instalados",
"settings.plugins.landing.settingsFor": "Ajustes de {name}",
"settings.plugins.landing.enableSettings": "Activa este plugin para abrir sus ajustes disponibles.",
"settings.plugins.landing.sourceUnknown": "Fuente desconocida",
"settings.plugins.landing.status.running": "En ejecución",
"settings.plugins.landing.status.starting": "Iniciando",
"settings.plugins.landing.status.stopping": "Deteniendo",
"settings.plugins.landing.status.error": "Error",
"settings.plugins.landing.status.quarantined": "En cuarentena",
"settings.plugins.landing.status.unknown": "Estado desconocido",
"settings.plugins.landing.status.idle": "Activado, en espera",
// Common
'common.save': 'Guardar',
'common.cancel': 'Cancelar',
+26
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhCNCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "检查更新",
"settings.plugins.landing.checkingUpdates": "正在检查…",
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "不可用来源:{count}。",
"settings.plugins.landing.updateReview": "安装前请在插件管理器中查看更新详情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在载入已安装插件…",
"settings.plugins.landing.settingsUnavailable": "当前上下文中没有可用的插件设置。",
"settings.plugins.landing.options": "插件选项",
"settings.plugins.landing.manager": "插件管理器",
"settings.plugins.landing.managerDescription": "浏览插件,管理已安装的插件包及来源。",
"settings.plugins.landing.browse": "浏览",
"settings.plugins.landing.refresh": "刷新已安装插件",
"settings.plugins.landing.unavailable": "当前会话无法使用插件管理。",
"settings.plugins.landing.empty": "尚未安装插件。可在插件管理器中浏览或导入插件。",
"settings.plugins.landing.search": "搜索已安装插件",
"settings.plugins.landing.settingsFor": "{name} 的设置",
"settings.plugins.landing.enableSettings": "启用此插件后可打开其可用设置。",
"settings.plugins.landing.sourceUnknown": "来源未知",
"settings.plugins.landing.status.running": "运行中",
"settings.plugins.landing.status.starting": "启动中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "错误",
"settings.plugins.landing.status.quarantined": "已隔离",
"settings.plugins.landing.status.unknown": "状态未知",
"settings.plugins.landing.status.idle": "已启用,空闲",
// Common
'common.save': '保存',
'common.cancel': '取消',
+26
View File
@@ -1,6 +1,32 @@
import type { Messages } from '../types';
export const zhTWCoreMessages: Messages = {
"settings.plugins.landing.checkUpdates": "檢查更新",
"settings.plugins.landing.checkingUpdates": "正在檢查…",
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
"settings.plugins.landing.updateSourcesFailed": "無法使用的來源:{count}。",
"settings.plugins.landing.updateReview": "安裝前請在外掛管理器中查看更新詳情。",
"settings.plugins.landing.updateAvailable": "可更新至 {version}",
"settings.plugins.landing.loading": "正在載入已安裝外掛…",
"settings.plugins.landing.settingsUnavailable": "目前內容中沒有可用的外掛設定。",
"settings.plugins.landing.options": "外掛選項",
"settings.plugins.landing.manager": "外掛管理器",
"settings.plugins.landing.managerDescription": "瀏覽外掛,管理已安裝的外掛套件及來源。",
"settings.plugins.landing.browse": "瀏覽",
"settings.plugins.landing.refresh": "重新整理已安裝外掛",
"settings.plugins.landing.unavailable": "目前工作階段無法使用外掛管理。",
"settings.plugins.landing.empty": "尚未安裝外掛。可在外掛管理器中瀏覽或匯入外掛。",
"settings.plugins.landing.search": "搜尋已安裝外掛",
"settings.plugins.landing.settingsFor": "{name} 的設定",
"settings.plugins.landing.enableSettings": "啟用此外掛後可開啟其可用設定。",
"settings.plugins.landing.sourceUnknown": "來源不明",
"settings.plugins.landing.status.running": "執行中",
"settings.plugins.landing.status.starting": "啟動中",
"settings.plugins.landing.status.stopping": "停止中",
"settings.plugins.landing.status.error": "錯誤",
"settings.plugins.landing.status.quarantined": "已隔離",
"settings.plugins.landing.status.unknown": "狀態不明",
"settings.plugins.landing.status.idle": "已啟用,閒置",
// Common
'common.save': '儲存',
'common.cancel': '取消',
+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 };
}
+2 -1
View File
@@ -10,7 +10,8 @@ export function usePluginManager() {
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const refresh = useCallback(async (options?: { clearError?: boolean }) => {
if (options?.clearError) setError(null);
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { checkPluginUpdates, type PluginUpdateCheckResult } from '../../infrastructure/services/pluginUpdateCheck';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
/** Manual catalog checks only; no scheduler, install or permission mutation. */
export function usePluginUpdateCheck(plugins: readonly NetcattyInstalledPlugin[]) {
const [result, setResult] = useState<PluginUpdateCheckResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
const generation = useRef(0);
const identityKey = useMemo(() => JSON.stringify(plugins.map((plugin) => [
plugin.id, plugin.activeVersion, plugin.archiveSha256, plugin.source?.id,
plugin.source?.kind, plugin.source?.origin,
])), [plugins]);
useEffect(() => {
generation.current++;
setResult(null);
setError(null);
const requests = generation;
return () => { requests.current++; };
}, [identityKey]);
useEffect(() => {
const invalidate = () => {
generation.current++;
setResult(null);
setError(null);
};
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(invalidate);
window.addEventListener('netcatty:plugin-packages-changed', invalidate);
return () => { unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', invalidate); };
}, []);
const check = useCallback(async () => {
if (pending.current) return;
pending.current = true;
const current = generation.current;
setBusy(true); setError(null); setResult(null);
try {
const value = await checkPluginUpdates(netcattyBridge.require());
if (mounted.current && current === generation.current) setResult(value);
} catch (cause) {
if (mounted.current && current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
pending.current = false;
// This component may still be mounted with a refreshed package list.
if (mounted.current) setBusy(false);
}
}, []);
return { result, error, busy, check };
}
+5 -2
View File
@@ -201,8 +201,9 @@ export const SettingCard: React.FC<SettingCardProps> = ({
);
interface SettingRowProps {
label?: string;
description?: string;
leading?: React.ReactNode;
label?: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
align?: "center" | "start";
/** Stable catalog id for settings search jump targets. */
@@ -210,6 +211,7 @@ interface SettingRowProps {
}
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
description,
children,
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
align === "start" ? "items-start" : "items-center",
)}
>
{leading && <div className="shrink-0">{leading}</div>}
<div className="flex-1 min-w-0">
{label && <div className="text-sm font-medium">{label}</div>}
{description && (
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('local logging controls default off, disclose verbose risk, and call host-only management actions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'NodeFilter', 'DocumentFragment', 'CustomEvent', 'getComputedStyle']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginLogSettings } = await import('./PluginLogSettings');
const root = createRoot(document.getElementById('root')!);
let status = { saveToDisk: false, verbose: false, droppedRecords: 2, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 };
const settings: Array<{ saveToDisk: boolean; verbose: boolean }> = [];
const actions: string[] = [];
let failConfigure = false;
Object.assign(dom.window, { netcatty: {
async getPluginLogStatus() { return status; },
async configurePluginLogs(value: { saveToDisk: boolean; verbose: boolean }) { settings.push(value); status = { ...status, ...value }; if (failConfigure) throw new Error('synthetic preferences write failed'); return status; },
async openPluginLogFolder() { actions.push('open'); return status; },
async exportPluginLogs() { actions.push('export'); return status; },
async clearPluginLogs() { actions.push('clear-confirmed-by-native-host'); return status; },
} });
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const switches = () => [...document.querySelectorAll<HTMLButtonElement>('[role="switch"]')];
try {
await act(async () => root.render(<React.StrictMode><I18nProvider locale="en"><PluginLogSettings /></I18nProvider></React.StrictMode>));
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
const configure = button('Configure');
configure.focus();
await act(async () => configure.click());
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
assert.equal(switches()[0].getAttribute('aria-checked'), 'false');
assert.equal(switches()[0].disabled, false, 'StrictMode initialization completes');
assert.equal(switches()[1].disabled, true);
assert.match(document.body.textContent!, /no upload or sync/);
assert.match(document.body.textContent!, /Dropped: 2/);
await act(async () => switches()[0].click());
assert.deepEqual(settings, [{ saveToDisk: true, verbose: false }]);
await act(async () => switches()[1].click());
assert.equal(settings.length, 1, 'opening disclosure does not enable verbose logs');
assert.match(document.body.textContent!, /cannot make arbitrary text safe/);
await act(async () => button('Cancel').click());
assert.equal(settings.length, 1, 'cancelling risk consent leaves verbose off');
await act(async () => switches()[1].click());
await act(async () => button('Enable temporarily').click());
assert.deepEqual(settings[1], { saveToDisk: true, verbose: true });
await act(async () => button('Open folder').click());
await act(async () => button('Export').click());
await act(async () => button('Clear logs…').click());
assert.deepEqual(actions, ['open', 'export', 'clear-confirmed-by-native-host']);
failConfigure = true;
await act(async () => switches()[0].click());
assert.deepEqual(settings.at(-1), { saveToDisk: false, verbose: false });
assert.equal(switches()[0].getAttribute('aria-checked'), 'false', 'failed configuration refreshes actual OFF host state');
assert.equal(switches()[1].getAttribute('aria-checked'), 'false');
assert.match(document.querySelector('[role=alert]')!.textContent!, /synthetic preferences write failed/);
await act(async () => button('Close').click());
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, configure);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { usePluginLogs } from '../../../application/state/usePluginLogs';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { ConfirmDialog } from '../../ui/confirm-dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
import { SettingRow, Toggle } from '../settings-ui';
/** Host settings row; detailed controls stay inside the standard host dialog. */
export function PluginLogSettings() {
const { t, resolvedLocale } = useI18n();
const zh = resolvedLocale.startsWith('zh');
const { status, error, busy, run } = usePluginLogs();
const [open, setOpen] = useState(false);
const [confirmVerbose, setConfirmVerbose] = useState(false);
const title = zh ? '本地插件日志' : 'Local plugin logs';
const saveLabel = zh ? '将宿主审计日志保存到本地' : 'Save host audit logs locally';
const verboseLabel = zh ? '包含插件详细诊断文字' : 'Include verbose plugin text';
const disclosure = zh
? '插件编写的日志文字可能包含敏感数据。截断和字段过滤不能保证任意文字安全。只为排查问题临时开启,导出前请检查。重启应用后自动关闭详细日志。'
: 'Plugin-authored text may contain sensitive data. Truncation and field filtering cannot make arbitrary text safe. Enable temporarily for troubleshooting and review files before sharing. Verbose diagnostics turn off after app restart.';
return <div data-host-security-surface="">
<Dialog open={open} onOpenChange={setOpen}>
<SettingRow label={title} description={zh ? '选择是否在此设备保存审计记录和诊断日志。' : 'Choose whether to keep audit records and diagnostics on this device.'}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground" role="status">{status ? (status.saveToDisk ? t('common.enabled') : t('common.disabled')) : error ? (zh ? '不可用' : 'Unavailable') : (zh ? '载入中' : 'Loading')}</span>
<DialogTrigger asChild><Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm">{zh ? '配置' : 'Configure'}</Button></DialogTrigger>
</div>
</SettingRow>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{zh ? '默认不保存。仅保存在此设备,不上传或同步。权限面板中的有限安全历史独立保留。' : 'Saving is off by default. Files stay on this device with no upload or sync. The bounded security history in plugin permissions is separate.'}</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="divide-y divide-border">
<SettingRow label={saveLabel} description={zh ? '保存宿主确认的操作和授权结果,不保存敏感正文。' : 'Save host-observed operations and authorization outcomes without sensitive bodies.'}>
<Toggle ariaLabel={saveLabel} checked={status?.saveToDisk ?? false} disabled={busy || !status} onChange={(saveToDisk) => void run('configure', { saveToDisk, verbose: false })} />
</SettingRow>
<SettingRow label={verboseLabel} description={zh ? '插件文字可能包含敏感数据;只为排查问题临时开启。' : 'Plugin text may contain sensitive data. Enable temporarily for troubleshooting.'}>
<Toggle ariaLabel={verboseLabel} checked={status?.verbose ?? false} disabled={busy || !status?.saveToDisk} onChange={(verbose) => verbose ? setConfirmVerbose(true) : void run('configure', { saveToDisk: true, verbose: false })} />
</SettingRow>
</div>
{status && <p className="text-xs text-muted-foreground">{zh ? `最多 ${status.maxBytes / 1048576} MiB,最长保留 ${status.retentionDays} 天。丢弃 ${status.droppedRecords} 条,写入错误 ${status.writeErrors} 次。` : `Up to ${status.maxBytes / 1048576} MiB, up to ${status.retentionDays} days. Dropped: ${status.droppedRecords}. Write errors: ${status.writeErrors}.`}</p>}
<DialogFooter className="gap-2 sm:justify-between">
<div className="flex flex-wrap gap-2">
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('open')}>{zh ? '打开文件夹' : 'Open folder'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={busy || !status} onClick={() => void run('export')}>{zh ? '导出' : 'Export'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy || !status} onClick={() => void run('clear')}>{zh ? '清除日志…' : 'Clear logs…'}</Button>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="sm" disabled={busy} onClick={() => void run('status')}>{zh ? '刷新' : 'Refresh'}</Button>
</div>
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={() => setOpen(false)}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog open={confirmVerbose} title={zh ? '开启插件详细诊断?' : 'Enable verbose plugin diagnostics?'} message={disclosure} confirmLabel={zh ? '临时开启' : 'Enable temporarily'} onOpenChange={setConfirmVerbose} onConfirm={() => { setConfirmVerbose(false); void run('configure', { saveToDisk: true, verbose: true }); }} />
</div>;
}
@@ -17,7 +17,7 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act, useEffect } = await import('react');
const { default: React, act, useEffect, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
@@ -69,6 +69,28 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
const opener = useRef<HTMLElement | null>(null);
return <>
<button ref={browse}>Fallback browse</button>
<button onClick={(event) => { opener.current = event.currentTarget; setOpen(true); }}>External plugin settings</button>
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={browse}><input aria-label="Controlled search" /></PluginManagementDialog>
</>;
}
await act(async () => root.render(<I18nProvider locale="en"><ControlledEntry /></I18nProvider>));
const external = [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === 'External plugin settings')!;
external.focus();
await click(external);
assert.equal(document.querySelectorAll('[role=dialog]').length, 1);
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement, external, 'controlled entry restores the actual row opener');
await click(external);
external.disabled = true;
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.activeElement?.textContent, 'Fallback browse', 'a disabled or removed opener uses the stable browse action');
} finally {
await act(async () => root.unmount());
dom.window.close();
@@ -4,18 +4,25 @@ import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children }: {
export function PluginManagementDialog({ children, trigger, open: controlledOpen, onOpenChange, restoreFocusTo, fallbackFocusTo }: {
trigger?: React.ReactNode | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
@@ -24,6 +31,13 @@ export function PluginManagementDialog({ children }: {
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
@@ -49,9 +49,9 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
async function fixture() {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
const listeners = new Set<() => void>();
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [installed]; },
onPluginContributionsChanged(callback: () => void) { listeners.add(callback); return () => listeners.delete(callback); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const perform = async (action: () => void) => { await act(async () => { action(); await settle(); }); };
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
await f.close();
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Uses the real settings primitives and verifies that the host manager owns state.
test('landing rows expose real sources/status/settings and quick toggles preserve host decisions', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const root = createRoot(document.getElementById('root')!);
const plugins = [{
id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0',
source: { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, description: 'Synthetic description', contributes: { settings: [{ id: 'com.example.alpha.value', label: 'Value', control: 'text', scope: 'application' }] } },
runtime: { status: 'running', kind: 'browser', lastError: null, quarantinedAt: null },
}, {
id: 'com.example.beta', enabled: false, activeVersion: '2.0.0',
source: { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com' },
manifest: { manifestVersion: 1, id: 'com.example.beta', name: 'Beta', version: '2.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
}] as NetcattyInstalledPlugin[];
const changes: Array<[string, boolean]> = [];
const opened: Array<string | null> = [];
let refreshes = 0;
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null as string | null, credentialStorageAvailable: true,
async refresh() { refreshes++; }, async setEnabled(id: string, enabled: boolean) { changes.push([id, enabled]); }, async importPackage() {}, async uninstall() {} };
Object.assign(dom.window, { netcatty: { async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; } } });
const render = async (value = manager, settingsAvailable = new Set(['com.example.alpha'])) => act(async () => root.render(<I18nProvider locale="en"><PluginSettingsLanding manager={value} settingsAvailable={settingsAvailable} onManage={(id) => opened.push(id)} /></I18nProvider>));
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
const byLabel = (label: string) => document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!;
try {
await render();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
assert.match(document.body.textContent!, /Synthetic source/);
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
await act(async () => button('Browse').click());
await act(async () => byLabel('Settings for Alpha').click());
assert.deepEqual(opened, [null, 'com.example.alpha']);
await act(async () => byLabel('Disable Alpha').click());
assert.deepEqual(changes, [['com.example.alpha', false]]);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true', 'no optimistic flip before host approval');
await render({ ...manager, error: 'Synthetic permission denied' });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic permission denied/);
assert.equal(byLabel('Disable Alpha').getAttribute('aria-checked'), 'true');
await act(async () => byLabel('Refresh installed plugins').click());
assert.equal(refreshes, 1);
await render({ ...manager, statusKnown: false });
assert.equal(byLabel('Disable Alpha').disabled, true);
await act(async () => byLabel('Disable Alpha').click());
assert.equal(changes.length, 1);
await render(manager, new Set());
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
await render({ ...manager, plugins: [] });
assert.match(document.body.textContent!, /No plugins installed/);
await render({ ...manager, plugins: [], statusKnown: false });
assert.match(document.body.textContent!, /Loading/);
await render({ ...manager, plugins: [], available: false });
assert.match(document.body.textContent!, /unavailable in this session/);
await render({ ...manager, plugins: [{ ...plugins[0], source: undefined }] });
assert.match(document.body.textContent!, /Source unknown/);
assert.doesNotMatch(document.body.textContent!, /Source: Local/);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,89 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
import { PluginLogSettings } from './PluginLogSettings';
type Manager = ReturnType<typeof usePluginManager>;
export function PluginSettingsLanding({ manager, settingsAvailable, settingsError, managerButtonRef, updates, onManage }: {
manager: Manager;
settingsAvailable: ReadonlySet<string>;
settingsError?: string;
managerButtonRef?: React.Ref<HTMLButtonElement>;
updates?: ReturnType<typeof usePluginUpdateCheck>;
onManage: (pluginId: string | null, opener: HTMLElement) => void;
}) {
const { t, resolvedLocale } = useI18n();
const [search, setSearch] = useState('');
const localized = useCallback((value: string | Record<string, string> | undefined) => typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '', [resolvedLocale]);
const nameOf = (plugin: NetcattyInstalledPlugin) => localized(plugin.manifest?.displayName) || plugin.manifest?.name || plugin.id;
const visible = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
return manager.plugins.filter((plugin) => [localized(plugin.manifest?.displayName), plugin.manifest?.name, plugin.id, plugin.manifest?.publisher, localized(plugin.manifest?.description), plugin.source?.name].some((value) => String(value ?? '').toLocaleLowerCase().includes(term)));
}, [manager.plugins, search, localized]);
const statusOf = (plugin: NetcattyInstalledPlugin) => {
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
const state = plugin.runtime.status;
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
};
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
const source = plugin.source;
if (!source) return t('settings.plugins.landing.sourceUnknown');
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
};
return <>
<SettingsSection title={t('settings.plugins.landing.options')}>
<SettingCard divided>
<SettingRow label={t('settings.plugins.landing.manager')} description={t('settings.plugins.landing.managerDescription')}>
<Button ref={managerButtonRef} className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" onClick={(event) => onManage(null, event.currentTarget)}>{t('settings.plugins.landing.browse')}</Button>
</SettingRow>
<PluginLogSettings />
</SettingCard>
</SettingsSection>
<SettingsSection title={`${t('settings.plugins.manager.installed')} · ${manager.plugins.length}`} actions={<>
{updates && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="outline" size="sm" disabled={updates.busy || manager.busy || !manager.available || !manager.statusKnown || manager.plugins.length === 0} onClick={() => void updates.check()}>{t(updates.busy ? 'settings.plugins.landing.checkingUpdates' : 'settings.plugins.landing.checkUpdates')}</Button>}
<Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy} aria-label={t('settings.plugins.landing.refresh')} onClick={() => void manager.refresh({ clearError: true }).catch(() => {})}><RefreshCw size={16} /></Button>
</>}>
{updates?.error && <p role="alert" className="text-sm text-destructive">{updates.error}</p>}
{updates?.result && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.landing.updateSummary').replace('{checked}', String(updates.result.checked)).replace('{updates}', String(updates.result.updates.length)).replace('{skipped}', String(updates.result.skipped))}{updates.result.failedSources > 0 && ` ${t('settings.plugins.landing.updateSourcesFailed').replace('{count}', String(updates.result.failedSources))}`} {t('settings.plugins.landing.updateReview')}</p>}
{settingsError && <p role="alert" className="text-sm text-destructive">{settingsError}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.statusKnown && !manager.error && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.loading')}</p>}
{manager.statusKnown && !manager.available && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.landing.unavailable')}</p>}
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
{manager.plugins.length > 0 && <>
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
<span className="block break-words">{[plugin.manifest?.publisher, sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
})}
</SettingCard>}
</>}
</SettingsSection>
</>;
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('manual update action coalesces clicks, never runs on mount, and reports incomplete source checks honestly', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { usePluginUpdateCheck } = await import('../../../application/state/usePluginUpdateCheck');
const root = createRoot(document.getElementById('root')!);
const source: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
const plugin = { id: 'com.example.alpha', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64), source,
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } } as NetcattyInstalledPlugin;
const plugins = [plugin];
const manager = { plugins, available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true, async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let catalogReads = 0; let mutations = 0; let offline = false;
let release: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
Object.assign(dom.window, { netcatty: {
async listPlugins() { return plugins; }, async listPluginSources() { return [source]; },
async listPluginCatalog(id: string) { assert.equal(id, source.id); catalogReads++; await gate; return { entries: [{ id: plugin.id, version: '1.1.0', artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }], source: offline ? 'cached' : 'registry', error: offline ? 'synthetic offline' : null }; },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
} });
function Page() { const updates = usePluginUpdateCheck(plugins); return <PluginSettingsLanding manager={manager} updates={updates} settingsAvailable={new Set()} onManage={() => {}} />; }
const check = () => [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Check for updates')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><Page /></I18nProvider>));
assert.equal(catalogReads, 0, 'rendering settings does not schedule checks');
await act(async () => { const button = check(); button.click(); button.click(); });
assert.equal(catalogReads, 1);
assert.equal([...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Checking…')!.disabled, true);
await act(async () => release());
assert.match(document.body.textContent!, /Published updates: 1/);
assert.match(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
await act(async () => window.dispatchEvent(new Event('netcatty:plugin-packages-changed')));
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/, 'source/package changes invalidate prior results');
offline = true;
await act(async () => check().click());
assert.match(document.body.textContent!, /Checked: 0/);
assert.match(document.body.textContent!, /Not checked: 1/);
assert.match(document.body.textContent!, /Unavailable sources: 1/);
assert.doesNotMatch(document.body.textContent!, /Version 1.1.0 available/);
assert.equal(mutations, 0);
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('landing settings entry targets the declared plugin and a manager tab roundtrip clears selection consistently', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const root = createRoot(document.getElementById('root')!);
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true, experimental: true, credentialStorageAvailable: true }; },
async listPlugins() { return [plugin]; },
async setPluginEnabled() { throw new Error('Synthetic management failure'); },
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions() { return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', description: 'Synthetic plugin', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] }; },
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
} });
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
const selectTab = async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
assert.ok(tab, name);
await act(async () => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await settle(); });
};
try {
await act(async () => { root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>); await settle(); });
const settings = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
assert.equal(toggle.getAttribute('aria-checked'), 'true', 'failed mutation retains actual host state');
const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh installed plugins"]')!;
await act(async () => { refresh.click(); await settle(); });
assert.equal(document.querySelector('[role=alert]'), null, 'explicit successful refresh clears an acknowledged manager error');
} finally {
await act(async () => root.unmount()); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+57 -14
View File
@@ -1,11 +1,13 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import React, { useEffect, useMemo, useState } from 'react';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
@@ -285,11 +287,15 @@ export function PluginSettingField({
);
}
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
const { t } = useI18n();
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
@@ -302,6 +308,31 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
@@ -311,7 +342,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -327,7 +358,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -352,7 +383,7 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} className="space-y-3" aria-labelledby={`plugin-settings-${plugin.id}`}>
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
@@ -394,15 +425,27 @@ function PluginManagementContent({ handoff }: { handoff: (action: () => void) =>
export default function SettingsPluginsTab() {
const { t } = useI18n();
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
@@ -0,0 +1,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.
+181
View File
@@ -0,0 +1,181 @@
# Local plugin logging
Local file persistence defaults to off. The existing permission panel keeps its
bounded security history independently. Changing or clearing local log files does
not clear that history, grant permissions, or change a plugin's source.
## Saved records
`PluginLogService` extends the existing `PluginLogger`. It owns one shared sink at
`<userData>/plugins/logs/host-v1`, outside plugin storage. Files contain JSON Lines.
They stay on the device. No cloud upload or sync path includes these files or the
local preference at `<userData>/plugins/local-logging.json`.
Host audit records contain these facts:
- Host-assigned plugin ID, installed version, archive SHA-256, runtime ID and kind.
- Source ID, source kind, and namespace from an injected host provenance resolver.
Missing provenance is `null`. Manifest claims never imply an official source.
- Registered host RPC operation, authorization outcome, result, numeric error code,
elapsed milliseconds, and available input/output `data` field byte counts.
- Target identifiers from host authorization descriptors, replaced with a keyed
SHA-256 digest. The random key stays in memory, so target correlation lasts only
for the current app process.
- Cumulative dropped-record and write-error counts at enqueue time.
Runtime start, running, stop, failure, protocol rejection, and permission revocation
produce separate host facts. Required and advanced activation permission failures
produce a fixed `runtime.authorization` event even before a runtime ID exists. The registry records known RPC attempts even when
validation or authorization fails. Authorization has separate `allowed`, `denied`,
`not-required`, and `not-reached` states. A handler error after approval preserves
`allowed` and records the failed result. Unknown values remain explicit.
The byte counts measure UTF-8 bytes in a string `data` field, or byte length for a
binary `data` field. They are not total wire traffic or decoded base64 size.
Missing measurements are `null`. Streaming traffic outside these calls does not
become a complete per-byte trace. Host facts describe what the host observed;
plugin-returned success cannot prove an external side effect occurred.
Host audit records never copy request bodies, response bodies, error messages,
credentials, API keys, passwords, prompts, Notes, SQL, query results, or terminal
content. Error codes come from the host RPC error set. No audit field accepts
plugin-supplied source identity.
## Optional plugin text
Verbose diagnostics require a separate confirmation and turn off after app
restart. Console messages, utility stdout/stderr, and `logger.write` text use
`recordType: plugin-diagnostic` and `trust: plugin-authored-unverified`. The host
adds identity outside the nested `diagnostic` object. Plugin fields named
`pluginId`, `sourceId`, or `recordType` cannot replace reserved metadata.
Text and structured fields have traversal, length, depth and total-record limits.
Control characters are removed and known sensitive field names are replaced.
These bounds do **not** make arbitrary plugin text safe. A plugin can put a secret
under an unexpected key or inside a sentence. Review files before sharing them.
No plugin text is persisted when verbose diagnostics are off.
## Limits and lifecycle
The shared sink retains at most three files of 1 MiB each, for at most seven days.
Expiry uses the first host timestamp in each segment, read within the existing
16 KiB record bound. Appending cannot extend a segment's lifetime. Export uses
the same cutoff. A segment is removed when its first record expires; capacity
rotation or segment expiry can remove later records sooner. Retention runs on
startup, writes and hourly while the app runs, including after saving is disabled. The app cannot delete expired files while it is closed.
The asynchronous queue accepts at most 256 KiB and records drops when full.
Plugin text can occupy only a quarter of that queue, leaving capacity for host
facts. Diagnostics also reuse the existing per-runtime log token bucket (240
records per minute, with a 240-record initial burst), including console output. Each
record is limited to 16 KiB. Oversized records are dropped. Write failures increase
both drop and error counts and never fail the plugin operation.
Turning saving off stops ingress, discards queued records that have not started,
and waits for any current write. Runtime cleanup closes its diagnostic logger.
App shutdown drains accepted writes through the existing manager shutdown path,
including when another cleanup stage rejects. A failed settings write refreshes
the checkbox from actual host state while retaining the original error.
Clearing pauses ingress, discards queued records, waits for a current write and
removes only the sink's known files. Cancellation leaves them intact. Export takes
a bounded snapshot on the write queue and uses the native Save dialog.
Files use mode 0600 and the directory uses 0700 on systems that support them.
The host filesystem broker rejects writes to the audit directory and preferences,
including canonical paths reached through symlinks. Plugin views cannot invoke
log management IPC. No plugin API exposes audit mutation. These local files are
not a tamper-evident ledger against the OS account or the development-only utility
runtime, which already has full Node and filesystem authority.
## Integration boundaries
`PluginLogSettings` presents a compact settings row on `PluginSettingsLanding`.
Its standard host dialog contains the save and verbose switches plus local file
actions. The landing page also shows installed-plugin quick controls using the
existing host manager. The Installed, Official and Third-party manager pages keep
their existing layout; declared settings links open the relevant installed detail.
The logging backend and source database are unchanged by this UI restructuring.
`createPluginHostService` now injects the host-owned
`createPluginLogProvenanceResolver(database)` by default. It consumes the source
API confirmed on #3981 at `59721f97172acffffe960a6268a2916a90a6c554`:
- `getActivePlugin(pluginId)` supplies the installed version, hash and source.
- `getRetainedPluginSource(pluginId)` must agree with the installed source.
- `privateOwner(pluginId)` supplies the opaque namespace.
- `publicPluginId(owner)` must round-trip to the host-assigned public ID.
The adapter receives host activation identity only, after the existing caller
guards. It never accepts source or owner fields from plugin request parameters.
Missing APIs, an absent installation, changed version/hash, a retained-source
mismatch or an orphaned owner leave provenance explicitly unknown. There is no
bare-plugin-ID fallback. The current host API encodes every owner, including
local imports; a bare ID returned as an owner remains unknown.
Each logger snapshots the source binding and supplies a host-only `isCurrent`
check. The sink checks it at enqueue, after asynchronous preflight, before
rotation mutations and immediately before appending. Records from a replaced or
uninstalled source are dropped and counted. They are never relabelled as the new
source. Already saved records keep their original identity.
The optional `resolvePluginProvenance` override remains host-owned. Its result
contains `sourceId`, `sourceKind`, `namespace`, and an optional synchronous
`isCurrent` function. Source IDs and kinds accept 1-128 ASCII letters, digits,
periods, underscores, colons and hyphens. Opaque namespaces accept the same
characters up to 256 bytes. URLs, display names and plugin claims are not source
identity. `official` describes the host-bound source, not official maintenance.
The source database, marketplace page and sync implementation are unchanged by
this adapter. It also needs no new shared host-registration change for transport.
## Native QA with synthetic data
Use the parent's working cloud GUI machine, a fresh isolated profile, and the
synthetic fixture below. Do not retry the blocked local Electron launch.
Do not open real logs or credentials. Capture screenshots of the settings card,
verbose disclosure, and native Clear confirmation. Record a short flow:
1. Open Plugins settings and choose **Configure** in the local logging row.
Confirm both options are off and no host-v1 files exist.
2. Enable local saving. Invoke a synthetic plugin host operation and deny another.
3. Open the folder and export to a synthetic destination. Inspect parsed JSONL for
host identity, outcomes, target digests, and absence of synthetic secret bodies.
4. Open verbose disclosure and cancel. Confirm no plugin text is saved. Confirm
the disclosure, write synthetic text, and verify the unverified record type.
5. Disable/revoke the plugin. Confirm its later text cannot append. Restart the
app and verify that local saving remains selected but verbose text is off.
6. Cancel Clear, then confirm it. Confirm the files are removed and the permission
panel's separate security history remains. Turn local saving off.
The fixture is `electron/plugins/fixtures/local-logging-plugin`. Build it with:
```sh
node --input-type=module -e 'import { buildPluginPackage } from "@netcatty/plugin-cli"; await buildPluginPackage("electron/plugins/fixtures/local-logging-plugin", "/tmp/netcatty-local-logging-fixture.ncpkg");'
```
Package: 3,270 bytes. SHA-256:
`c3770a55676ed708f01ec1e0e0b9968ffb5e03c02303ee7cb9f739bde27c9de0`.
It declares only `commands` and `menus`; its command-palette entries are under
**Logging QA**. **Emit synthetic diagnostic** tests redaction and nested spoofed
identity fields. **Emit synthetic diagnostic burst** emits 400 console messages
to exercise drop accounting. **Test undeclared network denial** must return denial
without a prompt or network I/O because the manifest declares no network access.
No request body or credential is supplied. Do not grant additional permissions.
Capture the compact settings card, verbose disclosure and native Clear dialog,
plus a short recording of the enable/emit/export/clear flow. On a #3981 integration
build, a normal local import must log the source and opaque owner returned by its
host database. On the older base, both stay explicitly unknown. Do not forge an
Official source through fixture metadata. The real-source replacement case is
covered by `pluginLogProvenance.integration.test.cjs` on the documented source
integration worktree; it uses only temporary synthetic database state.
The execution environment for this change could not launch Electron: its SUID
sandbox helper is not root-owned with mode 4755. Electron aborted before opening
any profile. No sandbox-disable retry or system-permission change was attempted.
`npm run pack:dir` also failed while rebuilding the serialport native dependency:
node-gyp tried to create `/home/agent/.electron-gyp` and received ENOENT. No
system-directory change or packaging retry was attempted.
Native screenshots and recording therefore remain pending in the parent desktop
QA queue. Component interaction tests are separate evidence and do not replace
that acceptance.
+46
View File
@@ -0,0 +1,46 @@
# Plugin settings
Settings > Plugins uses the same sections, divided cards, rows and toggles as
other Netcatty settings. The first two rows open the plugin manager and configure
[local logging](local-logging.md). Logging details live in a standard host dialog;
opening it does not enable saving or verbose diagnostics.
The installed list shows each plugin's icon, name, version, publisher, description,
host-bound source and runtime status. Search filters this metadata. Quick toggles
call the existing host manager, which retains compatibility checks, permission
prompts and runtime lifecycle rules. Failed mutations leave the actual enabled
state visible. A settings button appears only for declared settings or settings
views, and is enabled only when the host exposes those contributions.
## Manual update checks
**Check for updates** reads installed packages, registered sources and each bound
source's catalog through the existing host bridge. It never installs code or
changes permissions. Open **Browse** to review and install an update through the
existing manager flow.
Only a fresh registry response with a published artifact and valid semantic
version counts as checked. Cached, built-in, failed and incomplete responses do
not imply that installed plugins are current. Local, unknown, removed and disabled
sources are not checked. The summary reports checked packages, available updates,
unchecked packages and unavailable sources separately.
The checker matches source ID, kind and origin, then revalidates source, installed
version and archive hash after asynchronous reads. Source or package changes
invalidate displayed results. Checks run only when requested; there is no automatic
checking preference, automatic installation or global pause switch.
## Native acceptance
Use a fresh isolated profile and synthetic plugins. Capture the compact landing
page and logging dialog at normal and narrow window sizes, including keyboard
focus, scrolling, loading, empty and error states. Verify a denied quick-enable
request leaves the switch off, and a successful request shows the real host state.
Open declared plugin settings, switch manager tabs, return to Installed and reopen
the settings. Close the dialog and verify focus returns to its opener.
Run a manual update check against a synthetic source, then an unavailable source;
verify the summary distinguishes unchecked packages and never installs anything.
Complete the separate local-logging acceptance flow, including verbose consent,
native Clear cancellation/confirmation and export. Do not use real logs or
credentials.
+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 }),
+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" }
]
}
}
+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]) => [
+60 -26
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;
@@ -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,
@@ -380,19 +408,24 @@ function createPluginHostService(options) {
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 MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
@@ -423,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);
});
+12 -2
View File
@@ -639,11 +639,21 @@ class PluginPermissionEngine {
createMiddleware() {
return async (context, next) => {
if (context.metadata.public === true) return next();
if (context.metadata.public === true) {
context.recordAuthorization?.("not-required");
return next();
}
if (!context.authorization) {
context.recordAuthorization?.("denied");
throw permissionDenied(`Plugin host method has no authorization policy: ${context.method}`);
}
await this.authorize(context, context.authorization);
try {
await this.authorize(context, context.authorization);
} catch (error) {
if (error?.code === RPC_ERRORS.permissionDenied) context.recordAuthorization?.("denied");
throw error;
}
context.recordAuthorization?.("allowed");
return next();
};
}
+13
View File
@@ -20,6 +20,11 @@ const CHANNELS = Object.freeze({
themeApply: "netcatty:plugins:theme-apply",
themesChanged: "netcatty:plugins:themes-changed",
status: "netcatty:plugins:status",
logStatus: "netcatty:plugins:log-status",
logConfigure: "netcatty:plugins:log-configure",
logOpen: "netcatty:plugins:log-open",
logExport: "netcatty:plugins:log-export",
logClear: "netcatty:plugins:log-clear",
interactionReady: "netcatty:plugins:interaction-ready",
interactionResponse: "netcatty:plugins:interaction-response",
list: "netcatty:plugins:list",
@@ -696,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");
});
+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);
});
+5
View File
@@ -128,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
+15
View File
@@ -447,7 +447,22 @@ declare global {
| NetcattyPluginAuthenticationChallengeOpenEvent
| NetcattyPluginAuthenticationChallengeCancelEvent;
interface NetcattyPluginLogStatus {
saveToDisk: boolean;
verbose: boolean;
droppedRecords: number;
writeErrors: number;
pendingBytes: number;
maxBytes: number;
retentionDays: number;
}
interface NetcattyBridge {
getPluginLogStatus?(): Promise<NetcattyPluginLogStatus>;
configurePluginLogs?(settings: { saveToDisk: boolean; verbose: boolean }): Promise<NetcattyPluginLogStatus>;
openPluginLogFolder?(): Promise<NetcattyPluginLogStatus>;
exportPluginLogs?(): Promise<NetcattyPluginLogStatus>;
clearPluginLogs?(): Promise<NetcattyPluginLogStatus>;
getPluginRuntimeStatus?(): Promise<NetcattyPluginRuntimeStatus>;
getPluginSourceBinding?(pluginId: string): Promise<NetcattyPluginSource | null>;
resetPluginSource?(pluginId: string, expectedSourceId: string): Promise<void>;
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { checkPluginUpdates } from './pluginUpdateCheck';
const official: NetcattyPluginSource = { id: 'official', kind: 'official', name: 'Official source', origin: 'https://plugins.example.com', enabled: true };
const third: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Synthetic source', origin: 'https://synthetic.example.com', enabled: true };
const local: NetcattyPluginSource = { id: 'local', kind: 'local', name: 'Local import', origin: null };
const plugin = (id: string, version: string, source?: NetcattyPluginSource): NetcattyInstalledPlugin => ({ id, activeVersion: version, source, archiveSha256: 'a'.repeat(64), enabled: false, manifest: null, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } });
const entry = (id: string, version: string) => ({ id, version, artifact: { url: 'https://plugins.example.com/synthetic.ncpkg', sha256: 'b'.repeat(64) } }) as NetcattyPluginCatalogEntry;
const catalog = (entries: NetcattyPluginCatalogEntry[]): NetcattyPluginCatalogSnapshot => ({ source: 'registry', error: null, entries });
test('update checks compare semantic versions within each host-bound source and never mutate or fetch directly', async () => {
const installed = [plugin('com.example.alpha', '1.9.0', official), plugin('com.example.beta', '2.0.0-beta.1', third), plugin('com.example.local', '1.0.0', local), plugin('com.example.unknown', '1.0.0')];
const requested: string[] = [];
let mutations = 0;
const bridge = {
async listPlugins() { return installed; }, async listPluginSources() { return [official, third]; },
async listPluginCatalog(sourceId?: string) {
requested.push(sourceId!);
return sourceId === official.id ? catalog([entry(installed[0].id, '1.10.0'), entry(installed[1].id, '99.0.0')]) : catalog([entry(installed[1].id, '2.0.0')]);
},
async installCatalogPlugin() { mutations++; }, async setPluginEnabled() { mutations++; }, async checkForUpdates() { mutations++; },
};
const previous = globalThis.fetch;
globalThis.fetch = async () => { throw new Error('Direct fetching is forbidden'); };
try {
const result = await checkPluginUpdates(bridge);
assert.deepEqual(requested, [official.id, third.id]);
assert.equal(result.checked, 2);
assert.equal(result.skipped, 2);
assert.equal(result.failedSources, 0);
assert.deepEqual(result.updates, [
{ pluginId: installed[0].id, sourceId: official.id, installedVersion: '1.9.0', availableVersion: '1.10.0' },
{ pluginId: installed[1].id, sourceId: third.id, installedVersion: '2.0.0-beta.1', availableVersion: '2.0.0' },
]);
assert.equal(mutations, 0);
} finally { globalThis.fetch = previous; }
});
test('cached, failed, disabled and unpublished sources do not claim an update check succeeded', async () => {
const disabled = { ...third, enabled: false };
const installed = [plugin('com.example.alpha', '1.0.0', official), plugin('com.example.beta', '1.0.0', disabled)];
const calls: string[] = [];
const result = await checkPluginUpdates({ async listPlugins() { return installed; }, async listPluginSources() { return [official, disabled]; },
async listPluginCatalog(sourceId) { calls.push(sourceId!); return { source: 'cached', error: 'synthetic offline', entries: [entry(installed[0].id, '2.0.0')] }; } });
assert.deepEqual(calls, [official.id]);
assert.equal(result.checked, 0); assert.equal(result.skipped, 2); assert.equal(result.failedSources, 1); assert.deepEqual(result.updates, []);
const unpublished = await checkPluginUpdates({ async listPlugins() { return [installed[0]]; }, async listPluginSources() { return [official]; }, async listPluginCatalog() { return catalog([{ ...entry(installed[0].id, '2.0.0'), artifact: undefined }]); } });
assert.equal(unpublished.checked, 0); assert.equal(unpublished.skipped, 1);
});
test('source or installed package replacement during an asynchronous catalog read invalidates results', async () => {
for (const change of ['source', 'hash', 'disabled']) {
const before = plugin('com.example.alpha', '1.0.0', official);
let installed = [before]; let sources = [official];
const result = await checkPluginUpdates({ async listPlugins() { return installed; }, async listPluginSources() { return sources; }, async listPluginCatalog() {
if (change === 'source') { installed = [{ ...before, source: third }]; sources = [third]; }
if (change === 'hash') installed = [{ ...before, archiveSha256: 'c'.repeat(64) }];
if (change === 'disabled') sources = [{ ...official, enabled: false }];
return catalog([entry(before.id, '2.0.0')]);
} });
assert.deepEqual(result.updates, [], change); assert.equal(result.checked, 0, change); assert.equal(result.skipped, 1, change);
}
});
test('a failing source leaves other fresh source results intact and missing APIs fail explicitly', async () => {
const installed = [plugin('com.example.alpha', '1.0.0', official), plugin('com.example.beta', '1.0.0', third)];
const result = await checkPluginUpdates({ async listPlugins() { return installed; }, async listPluginSources() { return [official, third]; }, async listPluginCatalog(id) { if (id === third.id) throw new Error('synthetic source unavailable'); return catalog([entry(installed[0].id, '1.1.0')]); } });
assert.equal(result.failedSources, 1); assert.equal(result.checked, 1); assert.equal(result.skipped, 1); assert.equal(result.updates[0].sourceId, official.id);
await assert.rejects(checkPluginUpdates({}), /unavailable/);
});
@@ -0,0 +1,64 @@
import { gt, valid } from 'semver';
export interface PluginUpdateCandidate {
pluginId: string;
sourceId: string;
installedVersion: string;
availableVersion: string;
}
export interface PluginUpdateCheckResult {
checkedAt: number;
checked: number;
updates: PluginUpdateCandidate[];
skipped: number;
failedSources: number;
}
type UpdateBridge = Pick<NetcattyBridge, 'listPlugins' | 'listPluginSources' | 'listPluginCatalog'>;
const sameSource = (left?: NetcattyPluginSource, right?: NetcattyPluginSource) => Boolean(left && right
&& left.id === right.id && left.kind === right.kind && left.origin === right.origin);
const sameInstallation = (left: NetcattyInstalledPlugin, right?: NetcattyInstalledPlugin) => Boolean(right
&& left.activeVersion === right.activeVersion && left.archiveSha256 === right.archiveSha256
&& sameSource(left.source, right.source));
/** Read-only: host catalogs own fetching, source consent, bounds and cache status. */
export async function checkPluginUpdates(bridge: UpdateBridge): Promise<PluginUpdateCheckResult> {
if (!bridge.listPlugins || !bridge.listPluginSources || !bridge.listPluginCatalog) throw new Error('Plugin update checks are unavailable');
const installed = await bridge.listPlugins();
const sources = await bridge.listPluginSources();
const groups = new Map<string, NetcattyInstalledPlugin[]>();
for (const plugin of installed) {
const source = sources.find((item) => sameSource(plugin.source, item));
// Local/unknown/removed/disabled bindings never fall back to another market.
if (!plugin.activeVersion || !valid(plugin.activeVersion) || !source || source.kind === 'local' || source.enabled === false) continue;
groups.set(source.id, [...(groups.get(source.id) ?? []), plugin]);
}
const checked = new Set<string>();
const candidates: PluginUpdateCandidate[] = [];
let failedSources = 0;
// At most one catalog read at a time; the host supports at most 32 custom sources.
for (const [sourceId, plugins] of groups) {
try {
const catalog = await bridge.listPluginCatalog(sourceId);
if (catalog.error || catalog.source !== 'registry') { failedSources++; continue; }
for (const plugin of plugins) {
const entry = catalog.entries.find((item) => item.id === plugin.id && item.artifact);
if (!entry || !valid(entry.version)) continue;
checked.add(plugin.id);
if (gt(entry.version, plugin.activeVersion!)) candidates.push({
pluginId: plugin.id, sourceId, installedVersion: plugin.activeVersion!, availableVersion: entry.version,
});
}
} catch { failedSources++; }
}
// Revalidate host-owned source/package binding after every asynchronous read.
// This publishes metadata only; installation stays in the existing manager flow.
const current = await bridge.listPlugins();
const currentSources = await bridge.listPluginSources();
for (const plugin of installed) {
const source = currentSources.find((item) => item.id === plugin.source?.id);
if (!sameInstallation(plugin, current.find((item) => item.id === plugin.id)) || !sameSource(plugin.source, source) || source?.enabled === false) checked.delete(plugin.id);
}
const updates = candidates.filter((candidate) => checked.has(candidate.pluginId));
return { checkedAt: Date.now(), checked: checked.size, updates, skipped: current.length - checked.size, failedSources };
}
+1
View File
@@ -32,6 +32,7 @@
"jsqr": "^1.4.0",
"node-pty": "1.1.0",
"re2js": "2.8.6",
"semver": "^7.7.3",
"serialport": "^13.0.0",
"socks-proxy-agent": "^8.0.5",
"ssh2-sftp-client": "^12.0.1",
+1
View File
@@ -96,6 +96,7 @@
"jsqr": "^1.4.0",
"node-pty": "1.1.0",
"re2js": "2.8.6",
"semver": "^7.7.3",
"serialport": "^13.0.0",
"socks-proxy-agent": "^8.0.5",
"ssh2-sftp-client": "^12.0.1",
+17 -1
View File
@@ -43,11 +43,27 @@ async function checkDevImport() {
const themeResponse = await fetch(new URL("/infrastructure/config/pluginThemeRegistry.ts", origin));
assert.equal(themeResponse.status, 200);
const managerSource = await managerResponse.text() + "\n" + await themeResponse.text();
// The lazy settings entry also links the permission panel before it renders.
// Probe its actual Vite-served import: Node's CJS interop and production
// CommonJS conversion otherwise hide missing browser ESM exports.
const settingsResponse = await fetch(new URL("/components/settings/tabs/SettingsPluginsTab.tsx", origin));
assert.equal(settingsResponse.status, 200);
assert.match(await settingsResponse.text(), /\/components\/settings\/tabs\/PluginPackageManager\.tsx/);
assert.match(managerSource, /\/components\/settings\/tabs\/PluginPermissionPanel\.tsx/);
const panelResponse = await fetch(new URL("/components/settings/tabs/PluginPermissionPanel.tsx", origin));
assert.equal(panelResponse.status, 200);
const panelSource = await panelResponse.text();
const permissionImport = panelSource.match(/^import\s+.*from "[^"\n]*pluginPermissionDescriptions[^"\n]*";[^\n]*$/m);
assert.ok(permissionImport, "Missing Vite-served permission helper import");
const panelProbe = new SourceTextModule(`${permissionImport[0]}\nexport { describePluginPermission };`, { context, identifier: new URL("/permission-panel-probe", origin).href });
await panelProbe.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
await panelProbe.evaluate();
assert.equal(panelProbe.namespace.describePluginPermission("network").label, "Network requests");
for (const [helper, exported] of [["pluginRuntimePolicy", "describePluginRuntime"], ["pluginPermissionDescriptions", "describePluginPermission"], ["pluginThemePack", "resolveThemePalette"]]) {
const match = managerSource.match(new RegExp('from "([^"\\n]*' + helper + '[^"\\n]*)"'));
assert.ok(match, `Missing prebundled ${helper} import`);
const module = await load(new URL(match[1], origin).href);
await module.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
if (module.status === "unlinked") await module.link((specifier, importer) => load(new URL(specifier, importer.identifier).href));
await module.evaluate();
assert.equal(typeof module.namespace.default[exported], "function");
}