mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 19:32:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4eb33856b | ||
|
|
b14b65679f | ||
|
|
0c05a96fa1 | ||
|
|
da16b90e9e | ||
|
|
d40b0d3c25 | ||
|
|
714e2397a4 | ||
|
|
c4aedfa9ed | ||
|
|
a0cf2b487c | ||
|
|
e3e2dcfe02 | ||
|
|
2433cf0130 | ||
|
|
1b21c41fa2 | ||
|
|
02049102b6 | ||
|
|
cfa13680e6 | ||
|
|
7856c664c3 | ||
|
|
7d8a354404 | ||
|
|
5d2ff1cb86 | ||
|
|
9aa899059f | ||
|
|
d33c0193cc | ||
|
|
e325742abf | ||
|
|
9ca70f3290 | ||
|
|
092438803b |
No files matched your search
@@ -1262,6 +1262,8 @@ Highlight the focused split pane:
|
||||
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.manager.installed": "Installed plugins",
|
||||
"settings.plugins.author.official": "Official maintainer",
|
||||
"settings.plugins.author.officialMeaning": "Maintained by the Netcatty project. This is not a safety certification.",
|
||||
"settings.plugins.manager.import": "Import .ncpkg",
|
||||
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
|
||||
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
|
||||
@@ -1419,7 +1421,7 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||
'plugins.authentication.continue': 'Continue',
|
||||
'plugins.hostActions.select': 'Share selected host metadata',
|
||||
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
|
||||
'plugins.hostActions.selectionNote': 'Only the selected hosts\' saved labels, addresses, usernames, protocols, groups and tags are shared. Group selection includes only the hosts selected now. Later edit or connection requests follow this plugin\'s permission settings.',
|
||||
'plugins.hostActions.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
|
||||
@@ -1261,6 +1261,8 @@ Resalta el panel dividido enfocado:
|
||||
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
|
||||
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
|
||||
'settings.tab.plugins': 'Plugins',
|
||||
"settings.plugins.author.official": "Mantenimiento oficial",
|
||||
"settings.plugins.author.officialMeaning": "Mantenido por el proyecto Netcatty. No es una certificación de seguridad.",
|
||||
"settings.plugins.manager.installed": "Plugins instalados",
|
||||
"settings.plugins.manager.import": "Importar .ncpkg",
|
||||
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
|
||||
@@ -1419,7 +1421,7 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
|
||||
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
|
||||
'plugins.hostActions.selectionNote': 'Solo se comparten los nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados de los equipos seleccionados. Los grupos solo incluyen los equipos seleccionados ahora. Las solicitudes posteriores de edición o conexión siguen los permisos de este complemento.',
|
||||
'plugins.hostActions.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
|
||||
@@ -1130,6 +1130,8 @@ export const ruCoreMessages: Messages = {
|
||||
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
|
||||
|
||||
'settings.tab.plugins': 'Плагины',
|
||||
"settings.plugins.author.official": "Официальная поддержка",
|
||||
"settings.plugins.author.officialMeaning": "Поддерживается проектом Netcatty. Это не сертификат безопасности.",
|
||||
"settings.plugins.manager.installed": "Установленные плагины",
|
||||
"settings.plugins.manager.import": "Импорт .ncpkg",
|
||||
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
|
||||
@@ -1284,7 +1286,7 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
|
||||
'plugins.hostActions.selectionNote': 'Передаются только метаданные выбранных хостов: сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Дальнейшие запросы изменения или подключения зависят от настроек разрешений плагина.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
|
||||
@@ -29,6 +29,16 @@ const ALL_SETTINGS_LOCALES = [
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
];
|
||||
|
||||
test("Host selection disclosure describes later permission-controlled actions without promising another confirmation", () => {
|
||||
const permissionWords: Record<string, RegExp> = { en: /permission settings/i, es: /permisos/i, ru: /разрешени/u, 'zh-CN': /权限设置/u, 'zh-TW': /權限設定/u };
|
||||
const oldPromise = /requires a separate confirmation|requiere otra confirmación|требует отдельного подтверждения|需要单独确认|需要另外確認/iu;
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const note = locale.messages['plugins.hostActions.selectionNote'];
|
||||
assert.match(note, permissionWords[locale.name], locale.name);
|
||||
assert.doesNotMatch(note, oldPromise, locale.name);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
|
||||
@@ -1177,6 +1177,8 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'qs.default': '默认',
|
||||
'settings.tab.plugins': '插件',
|
||||
"settings.plugins.manager.installed": "已安装插件",
|
||||
"settings.plugins.author.official": "官方维护",
|
||||
"settings.plugins.author.officialMeaning": "由 Netcatty 项目维护。这不是安全认证。",
|
||||
"settings.plugins.manager.import": "导入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
|
||||
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
|
||||
@@ -1334,7 +1336,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
|
||||
'plugins.hostActions.selectionNote': '仅向此插件共享所选主机的元数据:名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。后续编辑或连接请求遵循此插件的权限设置。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
|
||||
@@ -1179,6 +1179,8 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'qs.default': '預設',
|
||||
|
||||
'settings.tab.plugins': '外掛程式',
|
||||
"settings.plugins.author.official": "官方維護",
|
||||
"settings.plugins.author.officialMeaning": "由 Netcatty 專案維護。這不是安全認證。",
|
||||
"settings.plugins.manager.installed": "已安裝插件",
|
||||
"settings.plugins.manager.import": "導入 .ncpkg",
|
||||
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
|
||||
@@ -1337,7 +1339,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
|
||||
'plugins.hostActions.selectionNote': '僅向此外掛分享所選主機的中繼資料:名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。後續編輯或連線請求遵循此外掛的權限設定。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getNotesSnapshot } from './notesStore';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import { getAppAppLockRuntime } from './appRuntimeBridge';
|
||||
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
|
||||
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { getConfiguredPluginModel, prepareConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
|
||||
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -29,6 +29,7 @@ export function usePluginAIContextHost({ sessions, workspaces }: { sessions: Ter
|
||||
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
|
||||
const runtime = createTrustedPluginAIHostRuntime({
|
||||
getModel: getConfiguredPluginModel,
|
||||
prepareModel: prepareConfiguredPluginModel,
|
||||
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
|
||||
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
|
||||
if (chooser) { reject(Error('A host selection is already pending')); return; }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// All visible rows share one IPC subscription. No catalog fetch or polling here.
|
||||
const generations = new Map<string, number>();
|
||||
const listeners = new Set<() => void>();
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) unsubscribe = netcattyBridge.get()?.onPluginCatalogChanged?.(({ sourceId }) => {
|
||||
generations.set(sourceId, (generations.get(sourceId) ?? 0) + 1);
|
||||
for (const notify of listeners) notify();
|
||||
});
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) { unsubscribe?.(); unsubscribe = undefined; generations.clear(); }
|
||||
};
|
||||
}
|
||||
|
||||
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
|
||||
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
|
||||
}) {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [loaded, setLoaded] = useState<{ key: string; value: NetcattyPluginAuthorPresentation } | null>(null);
|
||||
const pluginId = plugin?.id;
|
||||
const entryId = entry?.id;
|
||||
const selectionToken = entry?.selectionToken;
|
||||
const request = useMemo<NetcattyPluginAuthorRequest | null>(() => pluginId
|
||||
? { kind: 'installed', pluginId }
|
||||
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
|
||||
[pluginId, entryId, selectionToken, sourceId]);
|
||||
const identitySource = plugin?.source?.id ?? sourceId;
|
||||
const generation = useSyncExternalStore(subscribe, () => generations.get(identitySource) ?? 0, () => 0);
|
||||
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt, generation]);
|
||||
const installed = Boolean(plugin);
|
||||
const version = plugin?.activeVersion;
|
||||
const archiveSha256 = plugin?.archiveSha256 ?? null;
|
||||
const installedSourceId = plugin?.source?.id ?? 'local';
|
||||
useEffect(() => {
|
||||
if (!element || !request) return;
|
||||
let active = true;
|
||||
let started = false;
|
||||
const load = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void netcattyBridge.get()?.getPluginAuthorPresentation?.(request).then((value) => {
|
||||
if (!active || !value) return;
|
||||
if (installed && (value.version !== version || value.archiveSha256 !== archiveSha256 || value.sourceId !== installedSourceId)) return;
|
||||
setLoaded({ key, value });
|
||||
}).catch(() => { /* The visible author placeholder remains usable offline. */ });
|
||||
};
|
||||
const observer = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) { load(); observer?.disconnect(); }
|
||||
}, { rootMargin: '64px' });
|
||||
if (observer) observer.observe(element); else load();
|
||||
return () => { active = false; observer?.disconnect(); };
|
||||
}, [element, request, key, installed, version, archiveSha256, installedSourceId]);
|
||||
return { ref: setElement, identity: loaded?.key === key ? loaded.value : null };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
|
||||
test('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
|
||||
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
|
||||
// evidence. All state is synthetic, with no real provider, key, Note or host.
|
||||
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -42,6 +42,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const { createPreloadApi } = require('../electron/preload/api.cjs');
|
||||
const aiBridge = require('../electron/bridges/aiBridge.cjs');
|
||||
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
|
||||
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
|
||||
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
|
||||
let service: ReturnType<typeof createPluginHostService> | undefined;
|
||||
@@ -51,6 +52,30 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
const lockListeners = new Set<(state: { locked: boolean }) => void>();
|
||||
const reviews: Array<Record<string, unknown>> = [];
|
||||
const trustedPayloads: unknown[] = [];
|
||||
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
|
||||
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
|
||||
const sdkErrorNames: string[] = [];
|
||||
let diagnosticPhase = 'setup';
|
||||
const originalConsoleError = console.error;
|
||||
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
|
||||
// asynchronously through console.error. Let module warnings reach normal
|
||||
// stderr before observing SDK/renderer errors; do not suppress that warning.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The expected pre-fix SDK error contains request details. Keep diagnostics
|
||||
// categorical even on the failing-before path; never print payloads/headers.
|
||||
console.error = (value: unknown) => {
|
||||
let category = 'unknown';
|
||||
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
|
||||
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
|
||||
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
|
||||
else if (typeof value === 'string') category = 'other-string';
|
||||
else if (value && typeof value === 'object') {
|
||||
const name = (value as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
|
||||
}
|
||||
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
|
||||
};
|
||||
t.after(() => { console.error = originalConsoleError; });
|
||||
try {
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
@@ -62,25 +87,50 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
ipcRenderer.emit(channel, {}, structuredClone(payload));
|
||||
} });
|
||||
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const providerContext = {
|
||||
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
|
||||
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
|
||||
validateSender: (event: { sender: unknown }) => event.sender === sender,
|
||||
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
|
||||
// Only credential injection is synthetic. Admission, sync and the HTTP
|
||||
// transport are the production implementations, not a direct-fetch seam.
|
||||
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
|
||||
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
|
||||
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
|
||||
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
|
||||
},
|
||||
shouldSkipTLSVerify: () => false,
|
||||
streamRequest: aiBridge._streamRequestForTests,
|
||||
};
|
||||
registerProviderHandlers(providerContext);
|
||||
assert.equal(providerContext.providerConfigs.length, 0);
|
||||
ipcRenderer.invoke = async (channel, payload) => {
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
|
||||
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
|
||||
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
|
||||
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
|
||||
return { ok: status.statusCode === 200, ...status };
|
||||
}
|
||||
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
|
||||
const handler = ipcHandlers.get(channel);
|
||||
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
|
||||
return handler({ sender }, structuredClone(payload));
|
||||
const result = await handler({ sender }, structuredClone(payload));
|
||||
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
|
||||
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
|
||||
if (channel === 'netcatty:ai:chat:stream') {
|
||||
const status = result as { ok: boolean; statusCode?: number; error?: string };
|
||||
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
Object.defineProperty(dom.window, 'netcatty', { value: api });
|
||||
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
|
||||
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
|
||||
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
|
||||
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
|
||||
]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
|
||||
service = createPluginHostService({
|
||||
@@ -116,6 +166,7 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
|
||||
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
|
||||
@@ -129,23 +180,33 @@ test('default host adapter, real preload, mounted host chooser and installed ass
|
||||
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
|
||||
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
|
||||
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
|
||||
diagnosticPhase = 'deny-content';
|
||||
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
|
||||
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
|
||||
contentAllowed = true;
|
||||
send(); await waitFor(() => element('status').textContent === 'Complete');
|
||||
diagnosticPhase = 'complete-stream';
|
||||
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
|
||||
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
|
||||
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
|
||||
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(reviews.length, 0, 'content policy must not open a second native sharing review');
|
||||
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
diagnosticPhase = 'stop-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
element('stop').dispatchEvent(new panel.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
|
||||
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
|
||||
diagnosticPhase = 'lock-stream';
|
||||
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
|
||||
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
|
||||
for (const listener of lockListeners) listener({ locked: true });
|
||||
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
|
||||
assert.deepEqual(sdkErrorNames, []);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
panel?.window.close();
|
||||
await act(async () => root.unmount());
|
||||
await service?.manager.shutdown(); aiBridge.cleanup();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserRound } from 'lucide-react';
|
||||
import { usePluginAuthorIdentity } from '../../application/state/usePluginAuthorIdentity';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Badge } from '../ui/badge';
|
||||
|
||||
export function PluginAuthorRow({ entry, plugin, sourceId }: {
|
||||
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
const author = identity?.author ?? entry?.author ?? plugin?.manifest?.publisher ?? '';
|
||||
const imageKey = JSON.stringify([entry?.id, entry?.selectionToken, plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, sourceId, identity?.avatar]);
|
||||
const avatar = identity?.avatar;
|
||||
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
|
||||
return <div ref={ref} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground" data-plugin-author-row>
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted/60" aria-hidden="true">
|
||||
{showImage ? <img src={avatar} alt="" width={20} height={20} className="h-full w-full object-cover" onError={() => setFailed(imageKey)} />
|
||||
: <UserRound size={13} data-plugin-author-placeholder />}
|
||||
</span>
|
||||
<span className="min-w-0 truncate" title={author}>{author}</span>
|
||||
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
|
||||
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { PluginBrandIcon } from './PluginBrandIcon';
|
||||
|
||||
/** Shared installed-plugin composition; navigation never wraps the quick actions. */
|
||||
export function PluginListRow({ plugin, name, description, metadata, actions, openLabel, onOpen }: {
|
||||
plugin: NetcattyInstalledPlugin;
|
||||
name: string;
|
||||
description: string;
|
||||
metadata?: React.ReactNode;
|
||||
actions: React.ReactNode;
|
||||
openLabel?: string;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const heading = <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 max-w-full text-sm font-medium leading-5 [overflow-wrap:anywhere]">{name}</span>
|
||||
{plugin.activeVersion && <span className="inline-flex max-w-full items-center rounded-full border-0 bg-muted/60 px-2 py-0 text-[11px] font-normal leading-5 text-muted-foreground hover:bg-muted/60 [overflow-wrap:anywhere]" data-plugin-row-tag>{plugin.activeVersion}</span>}
|
||||
</span>;
|
||||
return <article aria-label={name} data-plugin-row={plugin.id} className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)_auto] items-start gap-x-3 rounded-lg py-4 sm:gap-x-4">
|
||||
<div data-plugin-icon-slot className="flex h-12 w-12 shrink-0 items-center justify-center text-muted-foreground"><PluginBrandIcon plugin={plugin} size={32} /></div>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
{onOpen ? <button type="button" data-plugin-card={plugin.id} aria-label={openLabel} aria-expanded={false} onClick={onOpen}
|
||||
className="group block w-full min-w-0 rounded-md text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
|
||||
<span className="flex min-w-0 items-center gap-2"><span className="min-w-0 flex-1">{heading}</span><ChevronRight size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" aria-hidden="true" /></span>
|
||||
<span className="mt-1 block line-clamp-2 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]" title={description}>{description}</span>
|
||||
</button> : <div>{heading}<p className="mt-1 line-clamp-2 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]" title={description}>{description}</p></div>}
|
||||
{metadata && <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [overflow-wrap:anywhere]">{metadata}</div>}
|
||||
</div>
|
||||
<div className="flex min-h-12 shrink-0 items-center justify-end gap-2 self-center" data-plugin-row-actions>{actions}</div>
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { usePluginAuthorIdentity } from '../../../application/state/usePluginAuthorIdentity';
|
||||
import { checkPluginUpdates } from '../../../infrastructure/services/pluginUpdateCheck';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { PluginDatabase } = require('../../../electron/plugins/database.cjs');
|
||||
const { MarketSourcesService } = require('../../../electron/plugins/marketSourcesService.cjs');
|
||||
const { OFFICIAL_SOURCE } = require('../../../electron/plugins/sourceIdentity.cjs');
|
||||
const { registerPluginBridge, CHANNELS } = require('../../../electron/plugins/pluginBridge.cjs');
|
||||
const { createPreloadApi } = require('../../../electron/preload/api.cjs');
|
||||
const { catalog, delivery, makeFiles, read, digest } = require('../../../electron/plugins/fixtures/staticDistributionFixture.cjs');
|
||||
|
||||
test('mounted installed authors follow actual catalog/update/source entrypoints without changing package identity', async () => {
|
||||
const rootPath = mkdtempSync(join(tmpdir(), 'netcatty-author-refresh-'));
|
||||
const database = new PluginDatabase(join(rootPath, 'plugins.sqlite'));
|
||||
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document,
|
||||
navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element,
|
||||
Node: dom.window.Node, IntersectionObserver: undefined, IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const entry = structuredClone(catalog[0]);
|
||||
entry.artifact = { sha256: 'a'.repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${'a'.repeat(64)}.ncpkg` };
|
||||
const bytes = read(delivery.plugins[0].iconLight.path.slice(1));
|
||||
const avatar = { path: `/assets/${digest(bytes)}.png`, sha256: digest(bytes), size: bytes.length };
|
||||
const filesFor = (entries = [entry], image = true) => {
|
||||
const files = makeFiles(entries, (index: { plugins: Array<{ id: string; versions: Array<{ distribution: string }> }> }, sidecar: { schemaVersion: number; plugins: Array<{ id: string; avatar: typeof avatar | null; artifact: { path: string; sha256: string; size: number } }> }) => {
|
||||
sidecar.schemaVersion = 2;
|
||||
for (const item of sidecar.plugins) {
|
||||
item.avatar = image ? avatar : null;
|
||||
item.artifact = { path: `/assets/${entry.artifact.sha256}.ncpkg`, sha256: entry.artifact.sha256, size: 1 };
|
||||
index.plugins.find((plugin) => plugin.id === item.id).versions[0].distribution = 'release';
|
||||
}
|
||||
});
|
||||
files.set(avatar.path, bytes); return files;
|
||||
};
|
||||
let files = filesFor();
|
||||
database.installVersion({ pluginId: entry.id, version: entry.version, runtimeKind: "browser", apiVersion: "0.1.0-internal.1", manifest: entry.manifest, archiveSha256: entry.artifact.sha256,
|
||||
packageRelativePath: 'packages/synthetic', source: OFFICIAL_SOURCE }, { enable: true });
|
||||
const manager = { initialize: async () => {}, list: async () => database.listPlugins(),
|
||||
changeMarketSource: async (change: () => unknown) => change() };
|
||||
let avatarReads = 0;
|
||||
const service = new MarketSourcesService({ database, manager,
|
||||
fetch: async (url: string) => { assert.equal(new URL(url).origin, OFFICIAL_SOURCE.origin); return new Response(files.get(new URL(url).pathname)); },
|
||||
createSourceFetch: (origin: string) => async (url: string) => { assert.equal(new URL(url).origin, origin); return new Response(files.get(new URL(url).pathname)); },
|
||||
resolveAvatar: async () => { avatarReads++; return 'data:image/png;base64,eA=='; } });
|
||||
const ipcMain = { handlers: new Map(), handle(channel: string, handler: unknown) { this.handlers.set(channel, handler); }, on() {} };
|
||||
const events = new EventEmitter();
|
||||
registerPluginBridge(ipcMain, { manager, catalogService: service, isTrustedSender: () => true,
|
||||
broadcast: (channel: string, payload: unknown) => events.emit(channel, {}, payload) });
|
||||
const api = createPreloadApi({ ipcRenderer: { on: events.on.bind(events), removeListener: events.removeListener.bind(events),
|
||||
invoke: (channel: string, payload: unknown) => ipcMain.handlers.get(channel)({}, payload) } });
|
||||
let authorCalls = 0;
|
||||
const original = api.getPluginAuthorPresentation;
|
||||
api.getPluginAuthorPresentation = (request: NetcattyPluginAuthorRequest) => { authorCalls++; return original(request); };
|
||||
Object.assign(window, { netcatty: api });
|
||||
let plugin = database.getActivePlugin(entry.id) as NetcattyInstalledPlugin;
|
||||
function Row() {
|
||||
const { ref, identity } = usePluginAuthorIdentity({ plugin });
|
||||
return <div ref={ref} data-author={identity?.author} data-avatar={identity?.avatar} data-official={identity?.officialMaintained ?? false} />;
|
||||
}
|
||||
const { act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const row = () => document.querySelector<HTMLElement>('[data-official]')!;
|
||||
try {
|
||||
await act(async () => root.render(<><Row /><Row /></>));
|
||||
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
|
||||
assert.equal(avatarReads, 0, 'opening installed rows never fetches a catalog/avatar');
|
||||
await act(async () => { await checkPluginUpdates(api); });
|
||||
assert.equal(row().dataset.author, entry.author); assert.equal(row().dataset.official, 'true');
|
||||
assert.match(row().dataset.avatar!, /^data:image\/png/);
|
||||
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 1, 'rows share one preload subscription');
|
||||
const count = authorCalls;
|
||||
await act(async () => { await api.listPluginCatalog('official'); root.render(<><Row /><Row /></>); });
|
||||
assert.equal(authorCalls, count, 'equivalent catalog and installation retain request dedupe');
|
||||
files = filesFor([{ ...entry, author: 'Updated project name' }], false);
|
||||
await act(async () => { await api.listPluginCatalog('official'); });
|
||||
assert.equal(row().dataset.author, 'Updated project name'); assert.equal(row().dataset.avatar, undefined);
|
||||
files = filesFor([]);
|
||||
await act(async () => { await api.listPluginCatalog('official'); });
|
||||
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
|
||||
assert.equal(row().dataset.avatar, undefined, 'withdrawal removes enriched identity without a remount');
|
||||
|
||||
const source = await api.addPluginSource({ name: 'Community', address: 'https://community.example.com' });
|
||||
const community = structuredClone(entry);
|
||||
community.id = community.manifest.id = 'com.community.identity';
|
||||
community.artifact.url = `${source.origin}/assets/${community.artifact.sha256}.ncpkg`;
|
||||
files = filesFor([community]);
|
||||
database.installVersion({ pluginId: community.id, version: community.version, manifest: community.manifest,
|
||||
archiveSha256: community.artifact.sha256, packageRelativePath: 'packages/community', source }, { enable: true });
|
||||
await act(async () => { await api.listPluginCatalog(source.id); });
|
||||
assert.equal(authorCalls, count + 4, 'unrelated source changes do not reload official rows');
|
||||
plugin = database.getActivePlugin(community.id);
|
||||
await act(async () => root.render(<><Row /><Row /></>));
|
||||
assert.equal(row().dataset.author, community.author); assert.match(row().dataset.avatar!, /^data:image\/png/);
|
||||
assert.equal(row().dataset.official, 'false', 'copied catalog never gains official maintenance authority');
|
||||
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: false }); });
|
||||
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
|
||||
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: true }); });
|
||||
assert.equal(row().dataset.author, community.author);
|
||||
await act(async () => { await api.removePluginSource(source.id, false); });
|
||||
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
|
||||
await act(async () => root.unmount());
|
||||
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 0, 'last row releases the shared IPC listener');
|
||||
} finally {
|
||||
database.close(); dom.window.close(); rmSync(rootPath, { recursive: true, force: true });
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
|
||||
const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty', version: '0.1.2',
|
||||
repository: 'binaricat/Netcatty', commit: 'a'.repeat(40), selectionToken: 'b'.repeat(64),
|
||||
manifest: { publisher: 'netcatty' } } as NetcattyPluginCatalogEntry;
|
||||
const identity = { author: 'Netcatty', avatar: 'data:image/png;base64,eA==', officialMaintained: true,
|
||||
sourceId: 'official', version: entry.version, archiveSha256: null };
|
||||
|
||||
test('untrusted entry strings and absent imagery produce a compact placeholder without an official badge', () => {
|
||||
const claimed = { ...entry, author: '<img src="https://avatars.githubusercontent.com/u/1">',
|
||||
officialMaintained: true, avatar: 'https://avatars.githubusercontent.com/u/1' };
|
||||
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow entry={claimed} /></I18nProvider>);
|
||||
assert.match(html, /data-plugin-author-placeholder/);
|
||||
assert.doesNotMatch(html, /<img|data-plugin-official-maintainer/);
|
||||
assert.match(html, /<img/);
|
||||
assert.match(html, /items-center/);
|
||||
});
|
||||
|
||||
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true });
|
||||
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 });
|
||||
};
|
||||
const observers: Array<{ target: Element | null; callback: IntersectionObserverCallback }> = [];
|
||||
class VisibilityObserver {
|
||||
record: typeof observers[number];
|
||||
constructor(callback: IntersectionObserverCallback) { this.record = { callback, target: null }; observers.push(this.record); }
|
||||
observe(target: Element) { this.record.target = target; }
|
||||
disconnect() { this.record.target = null; }
|
||||
}
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('IntersectionObserver', VisibilityObserver);
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const { act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const calls: NetcattyPluginAuthorRequest[] = [];
|
||||
const pending: Array<(value: NetcattyPluginAuthorPresentation | null) => void> = [];
|
||||
Object.assign(window, { netcatty: { getPluginAuthorPresentation: (request: NetcattyPluginAuthorRequest) => {
|
||||
calls.push(request); return new Promise((resolve) => pending.push(resolve));
|
||||
} } });
|
||||
const render = async (value = entry, sourceId = 'official') => {
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow entry={value} sourceId={sourceId} /></I18nProvider>));
|
||||
};
|
||||
const visible = async () => {
|
||||
await act(async () => {
|
||||
for (const observer of [...observers]) if (observer.target) observer.callback([{ target: observer.target, isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
};
|
||||
try {
|
||||
await render(); assert.equal(calls.length, 0); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
|
||||
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
|
||||
assert.ok(document.querySelector('[data-plugin-author-placeholder]'), 'loading uses the default placeholder');
|
||||
await act(async () => pending.shift()!(identity));
|
||||
const image = document.querySelector('img')!;
|
||||
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 20); assert.equal(image.height, 20);
|
||||
const badge = document.querySelector<HTMLElement>('[data-plugin-official-maintainer]')!;
|
||||
assert.equal(badge.textContent, 'Official maintainer'); assert.match(badge.title, /not a safety certification/);
|
||||
assert.ok(badge.classList.contains('border-0'));
|
||||
assert.ok(badge.classList.contains('bg-amber-500/15')); assert.ok(badge.classList.contains('dark:bg-amber-400/15'));
|
||||
assert.ok(badge.classList.contains('text-amber-800')); assert.ok(badge.classList.contains('dark:text-amber-300'));
|
||||
document.documentElement.classList.add('dark');
|
||||
assert.ok(document.querySelector('[data-plugin-author-row]')?.classList.contains('items-center'));
|
||||
await act(async () => image.dispatchEvent(new Event('error')));
|
||||
assert.equal(document.querySelector('img'), null); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
|
||||
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
|
||||
|
||||
const next = { ...entry, selectionToken: 'c'.repeat(64) };
|
||||
await render(next); assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
|
||||
await visible(); const stale = pending.shift()!;
|
||||
const copied = { ...entry, author: 'Community author', selectionToken: 'd'.repeat(64) };
|
||||
await render(copied, 'third-party:copy'); await visible();
|
||||
await act(async () => stale(identity));
|
||||
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
|
||||
assert.match(document.querySelector('[data-plugin-author-row]')!.textContent!, /Community author/);
|
||||
await act(async () => pending.shift()!({ ...identity, author: copied.author, sourceId: 'third-party:copy', officialMaintained: false, avatar: 'https://avatars.githubusercontent.com/u/1' }));
|
||||
assert.equal(document.querySelector('img'), null, 'renderer never uses a remote avatar URL');
|
||||
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null);
|
||||
|
||||
await render({ ...entry, selectionToken: 'f'.repeat(64) }); await visible();
|
||||
await act(async () => pending.shift()!({ ...identity, avatar: null, officialMaintained: false }));
|
||||
assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
|
||||
const installed = { id: entry.id, activeVersion: entry.version, archiveSha256: 'a'.repeat(64),
|
||||
source: { id: 'official', kind: 'official', origin: 'https://plugins.netcatty.app', name: 'Official' },
|
||||
manifest: entry.manifest, runtime: {} } as NetcattyInstalledPlugin;
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={installed} /></I18nProvider>));
|
||||
await visible(); assert.deepEqual(calls.at(-1), { kind: 'installed', pluginId: installed.id });
|
||||
await act(async () => pending.shift()!({ ...identity, version: '9.0.0', archiveSha256: installed.archiveSha256! }));
|
||||
assert.equal(document.querySelector('[data-plugin-official-maintainer]'), null, 'a stale installed version cannot gain a badge');
|
||||
const count = calls.length;
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginAuthorRow plugin={{ ...installed }} /></I18nProvider>));
|
||||
await visible(); assert.equal(calls.length, count, 'unchanged installed identity does not reload on a new object reference');
|
||||
|
||||
await render({ ...entry, selectionToken: 'e'.repeat(64) }); await visible();
|
||||
const late = pending.shift()!;
|
||||
await act(async () => root.unmount());
|
||||
await act(async () => late(identity));
|
||||
assert.equal(document.querySelector('[data-plugin-author-row]'), null);
|
||||
assert.ok(observers.every((observer) => observer.target === null));
|
||||
} finally {
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { PluginListRow } from '../../plugins/PluginListRow';
|
||||
import { Toggle } from '../settings-ui';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
|
||||
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
|
||||
test('installed rows filter, open one detail and restore search, scroll and row focus without toggle navigation', async () => {
|
||||
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
@@ -32,12 +36,17 @@ test('installed grid filters, opens one detail and restores search, scroll and c
|
||||
assert.equal(cards().length, 2);
|
||||
const alphaCard = renderer.root.findAllByType('article')[0];
|
||||
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
|
||||
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
|
||||
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
|
||||
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
|
||||
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
|
||||
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
|
||||
assert.ok(alphaCard.findAllByType('span').some((node) => node.props.className?.includes('line-clamp-2')), 'row description is bounded');
|
||||
assert.ok(alphaCard.props['data-plugin-row'], 'installed entries use the shared row composition');
|
||||
assert.match(alphaCard.findByProps({ 'data-plugin-row-tag': true }).props.className, /border-0/);
|
||||
assert.doesNotMatch(alphaCard.props.className, /bg-card|border|divide/);
|
||||
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
|
||||
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
|
||||
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
|
||||
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
|
||||
assert.equal(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')), false, 'installed rows no longer inherit the marketplace grid');
|
||||
const pill = renderer.root.findAllByType('button').find((node) => node.props.role === 'switch')!;
|
||||
await act(async () => pill.props.onClick());
|
||||
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
|
||||
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
|
||||
@@ -55,9 +64,39 @@ test('installed grid filters, opens one detail and restores search, scroll and c
|
||||
assert.equal(cards().length, 0);
|
||||
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
|
||||
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
|
||||
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
|
||||
const unknown = renderer.root.findAllByType('button').filter((node) => node.props.role === 'switch');
|
||||
assert.equal(unknown.length, 2);
|
||||
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
|
||||
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-label'].startsWith('Status unknown')));
|
||||
await act(async () => renderer.unmount());
|
||||
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
|
||||
});
|
||||
|
||||
|
||||
test('long installed-row text keeps baseline tags and independent native-button actions', () => {
|
||||
const name = `Long plugin ${'unbroken'.repeat(30)}`;
|
||||
const description = `Description ${'long description '.repeat(30)}`;
|
||||
const plugin = { id: 'com.test.long', activeVersion: '1.2.3-preview.4', enabled: false, runtime: {} } as NetcattyInstalledPlugin;
|
||||
const html = renderToStaticMarkup(<PluginListRow plugin={plugin} name={name} description={description}
|
||||
metadata={<span>{'https://source.example/'.repeat(20)}</span>} openLabel={`Details ${name}`} onOpen={() => {}}
|
||||
actions={<Toggle checked={false} ariaLabel={`Enable ${name}`} onChange={() => {}} />} />);
|
||||
const dom = new JSDOM(html);
|
||||
try {
|
||||
const row = dom.window.document.querySelector('[data-plugin-row]')!;
|
||||
const navigation = row.querySelector<HTMLButtonElement>('[data-plugin-card]')!;
|
||||
const toggle = row.querySelector<HTMLButtonElement>('[role=switch]')!;
|
||||
const tag = row.querySelector('[data-plugin-row-tag]')!;
|
||||
assert.equal(navigation.type, 'button');
|
||||
assert.equal(toggle.type, 'button');
|
||||
assert.equal(navigation.contains(toggle), false, 'Enter/Space on the switch cannot invoke row navigation');
|
||||
assert.match(navigation.className, /focus-visible:ring-2/);
|
||||
assert.match(toggle.className, /focus-visible:ring-2/);
|
||||
assert.match(tag.parentElement!.className, /items-baseline/);
|
||||
assert.equal(tag.previousElementSibling!.textContent, name, 'long names stay available without title truncation');
|
||||
assert.match(tag.previousElementSibling!.className, /overflow-wrap:anywhere/);
|
||||
assert.match(tag.className, /border-0/);
|
||||
assert.equal(navigation.querySelector('[title]')!.getAttribute('title'), description, 'clamped descriptions retain the full text');
|
||||
assert.match(row.className, /minmax\(0,1fr\)/);
|
||||
assert.doesNotMatch(row.querySelector('[data-plugin-icon-slot]')!.className, /border|ring|bg-/);
|
||||
assert.equal(row.querySelector('button button'), null);
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/
|
||||
import { PluginInfo } from './PluginInfo';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
|
||||
@@ -82,7 +83,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
|
||||
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
|
||||
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
|
||||
<PluginAuthorRow entry={item} sourceId={sourceId} />
|
||||
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded bg-muted/40 px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
|
||||
@@ -91,7 +92,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
|
||||
</div>}
|
||||
{entry && <article key={entry.id} aria-label={entry.name} className="plugin-marketplace-detail mx-auto w-full max-w-[920px] min-w-0 space-y-6 focus:outline-none">
|
||||
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
|
||||
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
|
||||
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><PluginAuthorRow entry={entry} sourceId={sourceId} /><p className="text-xs text-muted-foreground">{entry.version} · {entry.license}</p>
|
||||
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
|
||||
</div>
|
||||
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PluginPermissionPanel } from './PluginPermissionPanel';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
|
||||
import { PluginListRow } from '../../plugins/PluginListRow';
|
||||
import { Toggle } from '../settings-ui';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
@@ -79,13 +82,25 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
|
||||
if (!manager.available) return null;
|
||||
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
|
||||
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
|
||||
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
|
||||
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
|
||||
<div className={selectedPlugin ? 'space-y-4' : 'space-y-1'} data-plugin-row-list={!selectedPlugin || undefined}>
|
||||
{visible.map((plugin) => !selectedPlugin ? <PluginListRow key={plugin.id} plugin={plugin} name={nameOf(plugin)} description={descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}
|
||||
openLabel={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`} onOpen={() => choose(plugin.id)}
|
||||
metadata={<>
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<span>{plugin.source ? `${t('settings.plugins.sources.source')}: ${t(plugin.source.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}${plugin.source.origin ? ` · ${plugin.source.origin}` : ''}` : t('settings.plugins.landing.sourceUnknown')}</span>
|
||||
{plugin.manifest?.license && <span>{plugin.manifest.license}</span>}
|
||||
{manager.statusKnown === false && <span>{t('settings.plugins.permissions.statusUnknown')}</span>}
|
||||
{plugin.runtime.lastError && <span role="status" className="w-full text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</span>}
|
||||
</>}
|
||||
actions={<Toggle checked={plugin.enabled} disabled={manager.busy || manager.statusKnown === false}
|
||||
ariaLabel={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${nameOf(plugin)}`}
|
||||
onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />}
|
||||
/> : <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className="min-w-0 space-y-4 rounded-lg focus:outline-none">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
|
||||
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
|
||||
@@ -103,12 +118,9 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
|
||||
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
|
||||
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
|
||||
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
|
||||
<p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
|
||||
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
|
||||
{selected === plugin.id && <>
|
||||
<PluginPackageDetails plugin={plugin} />
|
||||
|
||||
@@ -41,6 +41,13 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
|
||||
await render();
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.querySelector('input[type=checkbox]'), null);
|
||||
assert.equal(document.querySelectorAll('[data-plugin-row]').length, 2);
|
||||
assert.equal(document.querySelectorAll('[data-plugin-icon-slot]').length, 2);
|
||||
assert.equal(document.querySelector('[data-plugin-row-list]')!.className.includes('border'), false);
|
||||
const alphaRow = document.querySelector('[data-plugin-row="com.example.alpha"]')!;
|
||||
assert.ok(alphaRow.querySelector('[data-plugin-row-tag]'));
|
||||
assert.equal(alphaRow.querySelector('[data-plugin-row-actions] [role=switch]')?.getAttribute('aria-label'), 'Disable Alpha');
|
||||
assert.equal(alphaRow.querySelector('button button'), null, 'quick actions are never nested inside navigation');
|
||||
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/);
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { usePluginUpdateCheck } from '../../../application/state/usePluginU
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { PluginListRow } from '../../plugins/PluginListRow';
|
||||
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
|
||||
import { PluginLogSettings } from './PluginLogSettings';
|
||||
|
||||
@@ -62,27 +63,24 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
|
||||
{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.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <div className="space-y-1" data-plugin-row-list>
|
||||
{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>}
|
||||
</>}>
|
||||
return <PluginListRow key={plugin.id} plugin={plugin} name={name} description={description || t('settings.plugins.manager.noDescription')} metadata={<>
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<span>{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
|
||||
{availableUpdate && <span className="text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
|
||||
</>} actions={
|
||||
<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>}
|
||||
</div>}
|
||||
</>}
|
||||
</SettingsSection>
|
||||
</>;
|
||||
|
||||
@@ -6,14 +6,14 @@ import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
|
||||
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
|
||||
test('detail state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
|
||||
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const calls: unknown[] = [];
|
||||
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
|
||||
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } }, runtime: {} } as NetcattyInstalledPlugin;
|
||||
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
|
||||
let renderer: ReactTestRenderer;
|
||||
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
|
||||
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager initialSelectedId={plugin.id} manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
|
||||
try {
|
||||
await act(async () => { renderer = create(render(true)); });
|
||||
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Unified plugin acceptance candidate
|
||||
|
||||
This file records the frozen 677 native baseline. For the subsequent permission
|
||||
policy and AI candidate, use [the new integration record](policy-ai-integration.md)
|
||||
and its package manifest. Historical consent flows below are not the revised UX.
|
||||
|
||||
The dedicated branch is `integration/plugin-native-acceptance`. Its Draft PR
|
||||
records the exact current head and CI runs. Native AI, database, Host/Vault, and
|
||||
settings/logging checks use this candidate rather than separate feature trees.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Plugin model transport cold start
|
||||
|
||||
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
|
||||
dependency on the internal AI sidebar. It does not establish the cause or outcome
|
||||
of the original native failure; that still requires an authorized native retest.
|
||||
|
||||
## Reproduced boundary
|
||||
|
||||
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
|
||||
selector reads the saved host provider, while main's provider registry starts
|
||||
empty. The existing sidebar synchronizes that registry when visible or sending.
|
||||
The plugin startup path did not. A nondefault configured loopback port can
|
||||
therefore fail main's provider allowlist before HTTP transport starts.
|
||||
|
||||
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
|
||||
mounts the trusted host component, uses the real application preload, and routes
|
||||
model requests through `registerProviderHandlers` and its actual allowlist. It
|
||||
does not mount the internal sidebar or manually alter the allowlist. Credential
|
||||
injection uses synthetic fixture markers; the HTTP edge and deterministic local
|
||||
fixture use the existing transport. No paid service, real key, Note, or host is
|
||||
used. This remains DOM/in-process evidence, not native sandbox acceptance.
|
||||
|
||||
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
|
||||
|
||||
```text
|
||||
ok: false
|
||||
allowlistRejected: true
|
||||
statusCode: undefined
|
||||
```
|
||||
|
||||
The corrected regression passes and preserves both the selected fixture provider
|
||||
and a second host-configured synthetic provider. It checks that provider markers
|
||||
and configuration do not enter plugin-facing model/context envelopes. Expected
|
||||
SDK error output is suppressed during this diagnostic so request payloads and
|
||||
headers are not logged.
|
||||
|
||||
## Correction
|
||||
|
||||
Before publishing a selected model handle, the trusted renderer synchronizes the
|
||||
current host-owned provider settings through the existing `aiSyncProviders`
|
||||
application API. It checks that the selected model revision matches the snapshot
|
||||
being synchronized. The runtime rechecks activity, cancellation, model revision,
|
||||
and pending-selection ownership after the await. Pending selections count toward
|
||||
the model-handle cap; release and shutdown invalidate late results.
|
||||
|
||||
Plugin requests supply no URL, credential, or provider configuration. The existing
|
||||
main sender validation, endpoint allowlist, source binding, and model invocation
|
||||
budgets are unchanged. No new schema feature, plugin permission, or consent
|
||||
dialog is added. The assistant package is unchanged and retains SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
|
||||
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
|
||||
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
|
||||
overlap host-selection work; preserve both when integrating the separate policy
|
||||
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
|
||||
correction.
|
||||
|
||||
## Native and policy status
|
||||
|
||||
The native open/close sidebar comparison was incomplete. Execution auto-review
|
||||
denied the Application-scope `context.selected.list` grant; that action did not
|
||||
execute. No post-sidebar inference was performed, the prompt expired, and the
|
||||
sample was disabled while the worker cleaned up. This is not native confirmation
|
||||
of the startup cause. The original `677aac33` app remains frozen for comparison;
|
||||
this fix does not authorize retrying the denied execution action.
|
||||
|
||||
In the earlier permission engine, **Application** grants are memory-only for the
|
||||
application lifetime; **Always** grants are persisted. An explicitly authorized
|
||||
Application grant can fit bounded temporary QA. A one-use prompt on every stream
|
||||
read is not an unavoidable target UX. The separate policy task implements the
|
||||
new direction: default Allow for declared supported capabilities, per-plugin
|
||||
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
|
||||
separate from execution-tool authorization and must not be used to evade a tool
|
||||
denial. This transport correction does not redesign that policy.
|
||||
|
||||
Native outcome, independent verification, and the sample's light-theme appearance
|
||||
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
|
||||
model invocation, main merge, or release was performed for this correction.
|
||||
|
||||
## Node 22 CI observer correction
|
||||
|
||||
The new cold-start test failed on exact head `9ca70f329` in test run
|
||||
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
|
||||
The sole failure was the empty SDK/renderer-error assertion receiving one
|
||||
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
|
||||
not an unrelated baseline failure.
|
||||
|
||||
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
|
||||
Reproduction on the exact CI version identified the safe category
|
||||
`setup:node-sqlite-experimental`, before any model operation. The existing
|
||||
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
|
||||
`console.error`. The newly added observer was installed before that warning
|
||||
drained. The same test passed on Node 24, which did not emit that warning.
|
||||
|
||||
The test now lets pending module warnings reach normal stderr before installing
|
||||
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
|
||||
assertion is unchanged. Safe phase/error categories make future failures
|
||||
diagnosable without request payloads or headers. No production behavior, timeout,
|
||||
permission, or cancellation assertion changed. Both Node versions pass the
|
||||
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
|
||||
16 tests with concurrency 4.
|
||||
|
||||
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
|
||||
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
|
||||
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
|
||||
records the parallel check. The appearance-only sample change is independently
|
||||
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Managed-window Ask acceptance candidate
|
||||
|
||||
The dedicated branch is `integration/plugin-ask-routing-acceptance`. Its Draft
|
||||
PR records the exact frozen integration head, CI and private renderer artifact
|
||||
handoff. It starts from frozen #4001 at
|
||||
`02049102b6732778adcd0f33b0a0ade6b4752327`; the old source checkout, QA workspace,
|
||||
profile and renderer artifact remain unchanged.
|
||||
|
||||
## Exact inputs and ownership
|
||||
|
||||
The only new input is policy owner's reviewed Ask correction
|
||||
`89beeb93749c751f255beb6d0e0f95d23bdd9a7b`, imported by ordinary merge
|
||||
`1b21c41fa26c0173084c7ae75e640ef3274285dd`. There were no merge conflicts or
|
||||
manual production edits. These seven production files exactly match that input:
|
||||
|
||||
| File | Owned production delta |
|
||||
| --- | --- |
|
||||
| `electron/bridges/windowManager.cjs` | Enumerate managed app windows, including Settings |
|
||||
| `electron/main/registerBridges.cjs` | Supply the managed permission-window getter |
|
||||
| `electron/plugins/hostService.cjs` | Pass that getter to the existing decision host |
|
||||
| `electron/plugins/permissionDecisionHost.cjs` | Bind ready/focused/visible/unlocked managed renderer selection and request lifecycle to the chosen receiver |
|
||||
| `electron/plugins/pluginBridge.cjs` | Guard permission readiness/replies by managed app window and main frame; reject plugin/foreign/subframe senders |
|
||||
| `components/SettingsPage.tsx` | Mount the existing permission host for standalone Settings |
|
||||
| `components/plugins/PluginPermissionHost.tsx` | Mark Ask with the existing host-security surface so attached native views yield |
|
||||
|
||||
The input also owns the Settings-installed-entrypoint regression, renderer test
|
||||
updates and policy/security documentation. AI d33 startup wiring, sample assets,
|
||||
the combined cold-start assertions and all earlier policy/transport history are
|
||||
preserved from 020. `ModelSelector.tsx` is unchanged from 020; the separate
|
||||
model-selector failure-loop repair is not imported. Diagnostic #3999 remains
|
||||
excluded. No broker resource/source rules, stream restrictions, package/lock
|
||||
inputs, native dependencies or sandbox configuration change in this integration.
|
||||
|
||||
## Frozen 020 native evidence
|
||||
|
||||
The parent closed that QA session and released the GUI. Its partial outcome is:
|
||||
|
||||
| Native case on 020 | Result |
|
||||
| --- | --- |
|
||||
| Default Allow cold Send and completion | Passed |
|
||||
| Stop, AI Deny, restore without automatic replay | Passed |
|
||||
| Themes in new views | Passed |
|
||||
| Ask visible dialog | Failed: no visible dialog, timeout, zero new model requests |
|
||||
| Ask acceptance/cancel and content-read matrix | Not run; remain pending |
|
||||
|
||||
The input's independent source-only delta review is scoped clean. Its installed
|
||||
SDK/registered IPC test reproduces the old Settings-owned failure and passes with
|
||||
the correction using represented Electron objects. That is not native Ask
|
||||
success. No new native acceptance is claimed by this coding executor.
|
||||
|
||||
Earlier terminal solo/split core passes with corrected fixtures, unreliable/
|
||||
unverified Focus and one QA Enter in A during a palette attempt remain historical
|
||||
evidence. There is no global zero-input or full terminal native-pass claim. The
|
||||
old Rfam run failed socket transport before connect resolved, without reaching
|
||||
authentication/SELECT; its cause and auth/TLS compatibility remain unverified.
|
||||
The next native priority is the new Ask path, not more old-baseline loops.
|
||||
|
||||
## Checks and retained CI history
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 696 tests: 695 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one Docker opt-in skip |
|
||||
| Mounted cold-start AI/permission/selected-summary/Host group | 21 passed |
|
||||
| Settings and security-surface visibility group | 37 passed |
|
||||
| Contract/capability/Codex generation and drift | Clean |
|
||||
| Lint | Zero errors, seven existing warnings |
|
||||
| Five sample archives | Validated; two builds each are identical and match frozen 020 |
|
||||
|
||||
The exact integration production build, renderer inventory and final CI are
|
||||
recorded in the Draft PR and artifact provenance. Local native packaging and
|
||||
unpackaged GUI acceptance are not claimed. This environment uses locked
|
||||
dependencies and standard patches; the official pinned Electron binary uses its
|
||||
documented cache/proxy support. No header workaround, native shim, HOME change
|
||||
or sandbox change is introduced.
|
||||
|
||||
Input89 [test CI 40453446775](https://github.com/binaricat/Netcatty/actions/runs/40453446775)
|
||||
succeeded on the parent's single infrastructure-only rerun: 15,342 passed, 50
|
||||
skipped, zero failed/cancelled. The original runner interruption remains evidence;
|
||||
this worker did not duplicate its retry. Input89
|
||||
[four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40453446781)
|
||||
passed with release jobs skipped. Frozen 020's successful full/platform CI and
|
||||
all earlier diagnosed/unexplained failures remain separate predecessor evidence.
|
||||
|
||||
The [sample manifest](ask-routing-packages.json) records exact IDs, versions,
|
||||
features, permissions/resources and archive/content hashes. All five archives
|
||||
are byte-identical to 020, including Assistant
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
No archive was activated by this executor. Its unchanged owner README retains
|
||||
old review wording; the current Allow/Ask/Deny procedure governs acceptance.
|
||||
|
||||
## Production-renderer preparation and native Ask recipe
|
||||
|
||||
1. Create a new isolated checkout at this Draft's exact head and a separate
|
||||
disposable synthetic QA profile. Keep the old 020 workspace/profile intact.
|
||||
2. Match `package.json` and `package-lock.json` hashes to the renderer artifact's
|
||||
provenance and the independently normally rebuilt native dependencies. They
|
||||
are unchanged from 020. Prepare matching host workspace packages normally.
|
||||
**The complete backend checkout must match the new head:** the frontend-only
|
||||
bundle does not include the new CJS window-routing code or native binaries.
|
||||
3. Materialize the exact private Library file/version in the consumer's own
|
||||
environment. Verify its archive SHA-256 and every dist file with the supplied
|
||||
inventory/verification script. Producer-local paths are not portable.
|
||||
4. Use those verified production assets as a whole `dist/` in the matching
|
||||
checkout, with the ordinary Electron entrypoint, no Vite environment and
|
||||
normal sandbox/helper settings. This is native **unpackaged production-
|
||||
renderer QA**, not packaged-binary certification. Process-loss causality
|
||||
remains unclassified; no native build/header workaround is supplied.
|
||||
5. Recreate only the synthetic provider/Note/sample setup. Verify Default Allow
|
||||
remains functional; then set the plugin operation to Ask in the active main
|
||||
or standalone Settings window. Keep the selected receiver visible, focused,
|
||||
ready and unlocked; opening Ask must not force focus back to main.
|
||||
6. Record visible readable Ask and native-view yielding. Check cancellation and
|
||||
acceptance as separate explicit operations: cancel must send no model request;
|
||||
accept must authorize only the current bounded operation. Complete the
|
||||
content-read Allow/Ask/Deny matrix and lifecycle invalidation cases. Do not
|
||||
treat the earlier zero-request timeout as an Ask pass.
|
||||
7. Record exact head, archive/sample hashes, request counters, prompt ownership,
|
||||
screenshots/video and any unrun cases. Report a model-selector failure
|
||||
separately rather than folding its other owner's repair into this candidate.
|
||||
|
||||
Hosting/distribution/deployment and remaining DB/terminal acceptance gates stay
|
||||
parent-coordinated. No main merge, Ready transition or release is part of this
|
||||
handoff.
|
||||
@@ -0,0 +1,197 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 17214,
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"changedEntriesFromFrozen677": [
|
||||
"README.md",
|
||||
"panel.css",
|
||||
"panel.js"
|
||||
],
|
||||
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom020": []
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "89beeb93749c751f255beb6d0e0f95d23bdd9a7b",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom020": []
|
||||
}
|
||||
]
|
||||
@@ -1,5 +1,10 @@
|
||||
# Test the selected Note assistant on an isolated desktop
|
||||
|
||||
For the permission-policy/AI acceptance candidate, first use
|
||||
[its integration record](policy-ai-integration.md) and exact package manifest.
|
||||
The older permission-dialog sequence below is historical baseline evidence;
|
||||
apply the current Allow/Ask/Deny semantics when testing the new candidate.
|
||||
|
||||
This recipe tests a sandbox plugin against a deterministic loopback provider.
|
||||
The provider does not run a model or contact a paid service. The profile contains
|
||||
only synthetic data. The parent owns native screenshots and video on the working
|
||||
@@ -29,9 +34,9 @@ browser runtime entry. It has no Node entry, networking, filesystem, secrets,
|
||||
storage, terminal, or command-execution permission. Its `commands` permission
|
||||
registers its own view callbacks. It cannot execute terminal commands.
|
||||
The six-file artifact at this checkpoint has SHA-256
|
||||
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
|
||||
This replaces the earlier `c0f73369...` artifact after the selected-Note expiry
|
||||
recovery repair.
|
||||
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
|
||||
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
|
||||
update. The host provider cold-start correction remains a separate app change.
|
||||
|
||||
## Start the deterministic fixture
|
||||
|
||||
@@ -119,6 +124,11 @@ Synthetic retry plan: add a retry button and show a connection status label.
|
||||
in the dropdown, and click Send yourself. Deny the fresh content permission
|
||||
once and cancel text review once before allowing a successful fixture run.
|
||||
The draft must survive all steps, and no request may be silently retried.
|
||||
14. With the sample idle, switch the host between light and dark and change its
|
||||
accent. The page, controls, muted surfaces, text, focus ring, and Send button
|
||||
must follow the existing view environment tokens without model requests or
|
||||
new permission prompts. Capture both appearances on the same host window.
|
||||
This check does not change or bypass the separately owned permission policy.
|
||||
|
||||
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
|
||||
panel and actual host service/selected-context authority clocks and advances past
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Plugin author presentation
|
||||
|
||||
The marketplace displays the catalog's author next to a small avatar. Installed
|
||||
views reuse the author row. Missing, loading, unavailable, or invalid imagery
|
||||
uses a bundled placeholder. An avatar is presentation metadata, not proof of an
|
||||
authenticated GitHub publisher.
|
||||
|
||||
The registry binds an image to its reviewed displayed-author subject. Repository
|
||||
ownership alone does not establish that subject. The current first-party author
|
||||
is the Netcatty project, not an assumed personal GitHub identity. Until an image
|
||||
pin is reviewed, its avatar remains null.
|
||||
|
||||
## Avatar delivery
|
||||
|
||||
Delivery v2 extends the reserved `avatar` field to null or
|
||||
`{path, sha256, size}`. The path is exactly `/assets/<sha256>.png` or `.webp`.
|
||||
Size is a positive integer no greater than 256KiB. The delivery digest and existing
|
||||
id/version/repository/commit tuple bind the reference. Catalog and index identity
|
||||
fields do not change. Source-only delivery v1 remains supported.
|
||||
|
||||
Only the main process fetches the selected source's same-origin asset. Existing
|
||||
URL, credential, redirect, timeout, byte-count, and SHA-256 checks apply. The host
|
||||
rechecks the source and selection after asynchronous work. The existing isolated
|
||||
icon rasterizer validates and converts PNG or WebP to a bounded PNG data URI,
|
||||
using its current queue, pending-work map, and cache. No renderer URL, arbitrary
|
||||
file path, GitHub API, or GitHub avatar URL is accepted. Visible rows load lazily.
|
||||
|
||||
Installed presentation uses already validated catalog metadata only when source,
|
||||
version, package digest, and manifest match the active installation. It does not
|
||||
fetch a catalog just because the installed list opens. Without an exact match,
|
||||
the declared publisher and placeholder remain visible, without a maintenance
|
||||
badge. No installed-data migration or persistent identity cache is added.
|
||||
|
||||
## Official maintenance
|
||||
|
||||
The borderless gold pill means that the Netcatty project maintains the plugin.
|
||||
It does not certify the plugin's safety or grant permissions. It is separate
|
||||
from the official marketplace source label.
|
||||
|
||||
The main process requires the built-in official source and one of these explicit
|
||||
first-party ID and repository pairs:
|
||||
|
||||
- `com.netcatty.starter`, `binaricat/Netcatty`
|
||||
- `com.netcatty.hello`, `binaricat/Netcatty`
|
||||
|
||||
A local import, a third-party copy, an ID prefix, author text, manifest publisher,
|
||||
README, or membership in the official marketplace cannot create this badge.
|
||||
The renderer consumes the host result through the existing trusted-app IPC
|
||||
boundary. No new plugin permission or plugin RPC is introduced.
|
||||
|
||||
## Validation scope
|
||||
|
||||
Focused registered tests cover source spoofing, exact installed identity,
|
||||
hash-bound images, invalid metadata and decoding, redirects, source removal,
|
||||
selection changes, cache reuse, lazy loading, stale UI cleanup, placeholders,
|
||||
and borderless light/dark theme classes. These tests do not establish native
|
||||
layout, actual account identity, live CDN deployment, or successful network access.
|
||||
Actual app screenshots and native acceptance remain separately scheduled.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Host and Vault actions
|
||||
|
||||
Feature: `vault.host-actions`. The SDK registers three methods under
|
||||
`context.vault`. Netcatty owns the picker, field-diff review, and connection
|
||||
confirmation. Plugin business logic stays in the offline browser sandbox.
|
||||
`context.vault`. Netcatty owns the picker, field-diff review, permission routing,
|
||||
and normal connection dispatch. Plugin business logic stays in the offline browser sandbox.
|
||||
|
||||
Base: source integration #3981 at
|
||||
`4213d8e6726c44e7a479418ece3adf16ea494782`.
|
||||
@@ -36,10 +36,12 @@ remain behind the typed target reference; its revision is the expected revision.
|
||||
Group metadata describes the explicitly selected current member handles. The
|
||||
grant never expands when another host joins a group.
|
||||
|
||||
Selection grants metadata access only. Each draft save and each connection
|
||||
requires its own host-owned confirmation. A connection confirmation displays the
|
||||
saved target and whether saved scripts can run. It does not grant terminal input,
|
||||
terminal output, or session access.
|
||||
Selection shares the chosen metadata only. Later edit and connection requests
|
||||
follow the plugin's Allow, Ask, or Deny settings. Draft saves retain the mandatory
|
||||
host editor. Connection Ask displays the saved target and whether saved scripts
|
||||
can run; Allow can dispatch without that permission-only dialog. Normal SSH
|
||||
authentication remains unchanged. These actions grant no terminal input, output,
|
||||
or session access.
|
||||
|
||||
All requests are bounded and cancel on runtime stop, source replacement, revoke,
|
||||
Vault lock, owner window replacement, expiry, or user cancellation. The host
|
||||
@@ -47,7 +49,7 @@ revalidates authority and expected revision after asynchronous work and before
|
||||
the Vault write or connection dispatch.
|
||||
|
||||
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
|
||||
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
|
||||
eight total, ten per minute, a 60-second host-interaction deadline, and a 64-KiB
|
||||
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
|
||||
existing 128-character single-line limit. Draft field lengths use
|
||||
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
|
||||
@@ -86,15 +88,16 @@ It revalidates after asynchronous authentication. AI selected-context handles ar
|
||||
text-disclosure handles and cannot target these actions.
|
||||
|
||||
The permission engine gates selection with `vault.metadata`, drafts with
|
||||
`vault.write`, and connection requests with `ui.interact`. Every save or connection
|
||||
also requires a separate host-owned confirmation and a signed one-use action
|
||||
claim. A stored permission grant cannot bypass this review.
|
||||
`vault.write`, and connection requests with `ui.interact`. Each committed save or
|
||||
connection uses a signed one-use action claim. Permission settings control the
|
||||
permission-only prompt; an Allow setting does not bypass the draft editor or
|
||||
normal SSH authentication.
|
||||
|
||||
After review, the renderer prepares the existing transaction or connection
|
||||
request. It claims action authority after the final asynchronous preparation,
|
||||
immediately before persistence or dispatch. This claim is the action's commit
|
||||
point. Completion only acknowledges the committed action. Automation changes
|
||||
during connection review require fresh confirmation.
|
||||
during an Ask connection review require fresh confirmation.
|
||||
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
|
||||
the launcher, including Mosh/ET flags and implicit localhost shells. Local
|
||||
execution displays an explicit on-device shell notice and omits the remote SSH
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
The plugin manager has Installed, Official marketplace and Third-party marketplace
|
||||
pages. Listing a plugin in the official market does not certify it or establish
|
||||
that Netcatty maintains it. Remote manifests, publisher names, source names and
|
||||
README content cannot create a host-issued official badge. This revision does not
|
||||
issue maintainer badges. README text and images remain author content.
|
||||
README content cannot create a host-issued official badge. The separate gold
|
||||
maintainer badge requires the built-in official source and an exact host-curated
|
||||
first-party ID/repository pair. It describes maintenance, not safety certification.
|
||||
README text and images remain author content.
|
||||
|
||||
## Add a source
|
||||
|
||||
@@ -33,20 +35,24 @@ manifest, compatibility, browser-runtime admission and archive validation still
|
||||
apply. Third-party source consent is not permission consent.
|
||||
|
||||
Delivery v1 is unchanged and source-only. Delivery **v2** has the same fields and
|
||||
strict schema as v1, with `schemaVersion: 2` and one extension: each plugin's
|
||||
`artifact` can be null or a package reference:
|
||||
strict schema as v1, with `schemaVersion: 2`. Each plugin's `artifact` can be null
|
||||
or a package reference:
|
||||
|
||||
```json
|
||||
{"path":"/assets/<64-lowercase-hex-sha256>.ncpkg","sha256":"<same-sha256>","size":12345}
|
||||
```
|
||||
|
||||
Packages are at most 32 MiB. The catalog entry must contain
|
||||
V2 delivery packages are at most 16 MiB. The catalog entry must contain
|
||||
`artifact: {url: "https://plugins.example.com/assets/<same-sha256>.ncpkg", sha256}`.
|
||||
The index's current, non-withdrawn version must exactly equal the catalog entry
|
||||
and use `distribution: "release"`. Source-only entries retain null delivery
|
||||
artifacts and `distribution: "source-only"`. Avatars remain null; remote README
|
||||
embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
|
||||
artifacts and `distribution: "source-only"`. The reserved `avatar` can also be
|
||||
null or a same-origin `/assets/<sha256>.png` or `.webp` reference, with a positive
|
||||
size no greater than 256KiB. The host verifies and rasterizes its bytes through
|
||||
the existing image infrastructure. Missing or invalid imagery uses a placeholder.
|
||||
Remote README embeds are not loaded. README and PNG/WebP references retain the v1 bounds.
|
||||
Existing official bootstrap entries are not changed or made installable.
|
||||
See [author presentation](author-identity.md) for subject and maintenance authority.
|
||||
|
||||
Before every install, refresh and validate the entire snapshot again. A changed
|
||||
selection, withdrawal, unavailable source, altered hash or size prevents install;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Permission-policy and AI acceptance candidate
|
||||
|
||||
This records frozen 020 and its predecessor evidence. For the later managed-
|
||||
window Ask correction, use [the isolated Ask integration record](ask-routing-integration.md).
|
||||
020 native Default Allow/Stop/Deny/restore/themes passed; Ask had no visible
|
||||
dialog and timed out with zero new model requests. Ask acceptance/cancel and
|
||||
the content-read matrix remain unrun until that new candidate is checked.
|
||||
|
||||
Use the dedicated `integration/plugin-policy-ai-acceptance` branch. Its Draft PR
|
||||
records the exact published head and aggregate CI. Frozen #3997 at
|
||||
`677aac33b1eec9dbd4a367417a49c964cdea63e7` remains the historical native baseline;
|
||||
neither that branch nor main is changed by this integration.
|
||||
|
||||
This is a candidate for parent-coordinated acceptance. It is not a claim of
|
||||
completed native acceptance, deployment readiness or permission activation.
|
||||
|
||||
## Preserved inputs
|
||||
|
||||
| Input | Exact checkpoint | Integration |
|
||||
| --- | --- | --- |
|
||||
| Frozen integration | `677aac33b1eec9dbd4a367417a49c964cdea63e7` | Starting point; includes stream/source/Host/settings and prior terminal history |
|
||||
| Permission policy #4000 | `79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9` | Initial ordinary merge `9aa899059` preserved 2eee561; follow-up ordinary merge `cfa13680e` preserves the authoritative scope/sensitivity copy and tests |
|
||||
| AI #3986 | `d33c0193ccaedc1a10aed9da495f040250121bef` | Ordinary merge `5d2ff1cb8`; cold-start wiring, themed sample and Node 22 warning-drain test correction |
|
||||
| Terminal production | #3996 `8e7c28fe1bdbcdd76816d33ec926ba8e60eefe64` | Preserve the prior renderer eligibility/publication path; policy-owned interaction changes remain from #4000 |
|
||||
|
||||
Diagnostic #3999 and its tests/docs-only `3fdaa357` continuation are not merged.
|
||||
No terminal runtime fix is required by the fixture-classification finding.
|
||||
Database protocol and fixed SQL remain solely in the minimal examples.
|
||||
|
||||
## Direct integration decisions
|
||||
|
||||
The initial merge conflict was `components/PluginAIContextHost.test.tsx`. The
|
||||
combined test keeps policy's absence of a second native content-sharing review
|
||||
and AI's actual main allowlist, two-provider synchronization, streamed result,
|
||||
Stop/lock cleanup and empty SDK-error assertion. The Node 22 warning drain is
|
||||
retained. The obsolete forced-review cancellation branch is superseded by the
|
||||
current in-app Ask/cancellation tests; no transport assertion is dropped.
|
||||
|
||||
No production file needed a manual conflict resolution. Policy production files
|
||||
remain identical to the policy input; the three AI startup wiring files and
|
||||
sample assets remain identical to d33c0193. The stream service/router and prior
|
||||
terminal renderer eligibility/publication files are unchanged from frozen 677.
|
||||
|
||||
Integration commit `7d8a354404d68071d0924532ffe74c713ace6cb9` updates two stale
|
||||
disclosure assertions in `PluginPackageManager.test.tsx` and
|
||||
`PluginMarketplace.interaction.test.tsx`. They expected the old words
|
||||
`not a grant`; the new assertions require default Allow, Ask/Deny controls and
|
||||
retained host validation/resource scope. Existing escaping, advanced-authority,
|
||||
source and README-failure assertions remain intact. Production copy is unchanged.
|
||||
|
||||
The later authoritative 79a9bd6 policy merge overlapped those two tests. Their
|
||||
final contents are byte-identical to 79a9bd6: all three earlier policy assertions
|
||||
remain, together with the owner's additional unbounded-resource, sensitivity,
|
||||
escaping/source-order and advanced-authority checks. No assertion was dropped.
|
||||
This follow-up imports the owner's five-locale wording changes; it makes no
|
||||
manual production conflict edit and leaves the shared AI test intact.
|
||||
|
||||
## Checks and retained failures
|
||||
|
||||
| Local check | Result |
|
||||
| --- | --- |
|
||||
| Registered runtime | 695 tests: 694 passed, one native skip, zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one explicit Docker opt-in skip |
|
||||
| Serial mounted AI/permission/selected-summary/Host/provider/panel group at 7856 | 31 passed |
|
||||
| Final settings, terminal interaction, cold-start host and locale group | 90 passed |
|
||||
| Contract/capability/Codex generation and drift | Passed |
|
||||
| Full lint | Zero errors, seven existing warnings |
|
||||
| Production build | Passed; Vite 35.50 seconds |
|
||||
| Five sample archives | Validated; repeat builds byte-identical |
|
||||
|
||||
The initial settings group records 34 passes and two failures on the stale copy
|
||||
assertions. That run is retained separately from the corrected 36/36 result.
|
||||
|
||||
Policy input [test CI 40443396430](https://github.com/binaricat/Netcatty/actions/runs/40443396430)
|
||||
first received an explicit runner shutdown. The parent requested one retry;
|
||||
this integration task did not duplicate it. Retry Linux job `121992430185`
|
||||
passed registered runtime (694/one skip) and examples (five/one skip), then
|
||||
failed those same two `not a grant` assertions: settings group 33 passed/two
|
||||
failed. It did not reach the full suite. The candidate includes the isolated
|
||||
test correction above; do not relabel the input CI as passing. Both input Windows
|
||||
jobs and [policy package CI](https://github.com/binaricat/Netcatty/actions/runs/40443396402)
|
||||
passed. Parent reports the independent policy correction reviews scoped clean,
|
||||
including both P2s and the three UI findings, with 71 focused/synthetic checks.
|
||||
|
||||
The policy owner subsequently corrected these tests in predecessor
|
||||
`da4e80946d2f3fb48f205aae2a732252b2f51fca`. Its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40444934631) passed
|
||||
15,342 cases with 50 skipped and no failures/cancellations, and its
|
||||
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40444934738)
|
||||
passed. The final 79a9bd6 wording head's
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40446529092) passed;
|
||||
its [package workflow](https://github.com/binaricat/Netcatty/actions/runs/40446529100)
|
||||
is tracked separately in the Draft PR. No input retry was duplicated here.
|
||||
|
||||
Integration predecessor `7856c664c3c2255a21badef2758bb29311b27a01` passed its
|
||||
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40445809589): 15,344
|
||||
passed, 50 skipped, zero failed/cancelled, plus both Windows jobs and native
|
||||
automated checks. Its [four-platform packages](https://github.com/binaricat/Netcatty/actions/runs/40445809590)
|
||||
also passed. These are retained predecessor results; the new exact head needs
|
||||
its own aggregate CI.
|
||||
|
||||
AI input [test CI](https://github.com/binaricat/Netcatty/actions/runs/40439111241)
|
||||
and [package CI](https://github.com/binaricat/Netcatty/actions/runs/40439111215)
|
||||
passed. Parent reports its three isolated changes scoped clean. Its prior 9ca
|
||||
Node 22 warning-observer failure remains documented in `ai-provider-startup.md`;
|
||||
the warning drain does not ignore errors or remove the empty-error assertion.
|
||||
Frozen 677's earlier unexplained MCP assertion remains separate history.
|
||||
|
||||
This executor used locked dependencies with install scripts disabled, then the
|
||||
standard repository dependency patches. The official pinned Electron binary
|
||||
installed through its documented proxy support and executes in Node mode.
|
||||
No native-header download, alternate mirror, native shim, HOME change or
|
||||
sandbox change was used. Normal native postinstall/rebuild, local full-suite
|
||||
success and local desktop packaging are not claimed. Exact candidate CI is the
|
||||
aggregate full-suite and platform-packaging verification surface.
|
||||
|
||||
## Exact packages and provenance
|
||||
|
||||
The machine-readable [package record](policy-ai-packages.json) contains complete
|
||||
IDs, versions, required features, permission/resource declarations, archive and
|
||||
content hashes, and frozen-baseline comparisons. All five versions are 0.1.0.
|
||||
|
||||
| Sample | SHA-256 | Comparison with frozen 677 |
|
||||
| --- | --- | --- |
|
||||
| Assistant | `7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274` | Matches the AI owner's new themed archive; only README.md, panel.css and panel.js differ from 677 |
|
||||
| Owned database | `f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874` | Byte-identical |
|
||||
| Rfam | `aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3` | Byte-identical |
|
||||
| Host actions | `cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2` | Byte-identical |
|
||||
| Terminal draft | `4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e` | Byte-identical |
|
||||
|
||||
After the authoritative 79 follow-up, all five archives were rebuilt twice,
|
||||
validated and compared byte for byte with the 7856 candidate: all five remain
|
||||
identical. No sample permission/resource declaration changed. The host's new policy changes
|
||||
effective authorization behavior; identical package bytes do not certify that
|
||||
new host behavior. The old Assistant c7d784d5 archive remains historical evidence,
|
||||
not the themed archive. No handoff archive was activated by this coding executor.
|
||||
The owner-identical Assistant README retains earlier permission-review wording;
|
||||
use this candidate's Allow/Ask/Deny procedure below for acceptance. That packaged
|
||||
copy is a documentation limitation, not a requirement for repeated dialogs.
|
||||
|
||||
## Native preparation and remaining gates
|
||||
|
||||
Use the exact candidate head from its Draft PR in a separate disposable native
|
||||
checkout/profile. Keep normal platform sandbox/helper settings. Build and hash:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build
|
||||
node examples/plugins/assistant/build.mjs
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant.ncpkg
|
||||
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-owned.ncpkg
|
||||
node examples/plugins/database-rfam-readonly/prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 /tmp/database-rfam-readonly.ncpkg
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/host-actions --out /tmp/host-actions.ncpkg
|
||||
node examples/plugins/terminal-selection-draft/prepare.mjs /tmp/terminal-selection-draft.ncpkg
|
||||
```
|
||||
|
||||
Validate each archive with the plugin CLI and compare against the machine record.
|
||||
The owned DB address is packaging-only. A different approved fixture endpoint
|
||||
requires a new archive and hash. Preparation does not dial either endpoint.
|
||||
|
||||
- Verify default Allow for declared supported capabilities without a mandatory
|
||||
first-install permission checklist or repeated permission-only dialogs. Verify
|
||||
per-plugin Ask/Deny, one scoped Ask receipt, readable host-selected labels,
|
||||
lifecycle invalidation and redacted audit. Preserve necessary target selection
|
||||
and inert draft editing, exact resources, source isolation and sandboxing.
|
||||
- Verify the assistant's cold startup through the normal host without opening
|
||||
the AI sidebar. Native inference on frozen 677 returned unavailable; the
|
||||
independent startup regression did not establish that native error's cause.
|
||||
Use only the deterministic synthetic provider and Note recipe, with current
|
||||
policy semantics. Native success is not claimed before that recheck.
|
||||
- Frozen 677 Host core passed per parent; old consent recordings describe the
|
||||
prior policy. They do not certify the new permission implementation.
|
||||
- Terminal's source-level finding is fixture-sensitive-input fail-closed:
|
||||
`fixture-A> ` is untrusted; `fixture@owned-A:~$ ` is confirmed shell. Parent
|
||||
reports corrected-fixture solo/split core passed on unmodified 677. Focus
|
||||
remained unreliable/unverified, and QA sent one Enter in A during a palette
|
||||
attempt. Do not claim global zero input or full terminal native acceptance.
|
||||
Keep the inert draft's no-PTY-dispatch property distinct from that QA action.
|
||||
Exclude diagnostic instrumentation. Prior-policy results do not certify the
|
||||
new policy; the next native priority is final-policy and cold-start AI QA.
|
||||
- Native Rfam's single baseline run failed with sanitized -32014 socket error
|
||||
before connect resolved. Auth/SELECT were not reached; transport causality and
|
||||
auth/TLS compatibility remain unverified. No probe/fallback or restriction
|
||||
change is authorized by this coding task. Owned DB fixture prerequisites and
|
||||
parent coordination remain separate.
|
||||
- Record exact head, hashes, ordered prompt/operation outcomes, screenshots and
|
||||
video for the new candidate. Hosting/distribution/deployment remain pending;
|
||||
no main merge, Ready transition or release is part of this handoff.
|
||||
@@ -0,0 +1,187 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 17214,
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"changedEntriesFromFrozen677": [
|
||||
"README.md",
|
||||
"panel.css",
|
||||
"panel.js"
|
||||
],
|
||||
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "79a9bd6df1c17fd8a5238aba31d7a8b4cda184b9",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef"
|
||||
}
|
||||
]
|
||||
@@ -29,6 +29,13 @@ for brand icons. This v1 publishing schema requires **avatar and artifact to
|
||||
be null**. The client rejects unsupported delivery metadata rather than
|
||||
inventing installable packages or deriving a GitHub avatar URL.
|
||||
|
||||
Delivery v2 retains those fields and admits hash-bound packages plus the reserved
|
||||
avatar as null or a positive-size PNG/WebP asset reference of at most 256KiB.
|
||||
The host fetches only the selected source's same-origin asset, verifies bytes,
|
||||
and reuses its bounded icon rasterizer and cache. The renderer receives a PNG
|
||||
data URI. Author imagery does not verify a GitHub account, and no GitHub avatar
|
||||
URL is derived. See [author presentation](author-identity.md).
|
||||
|
||||
Index is validated with its pinned schema. Plugin/version identities must be
|
||||
unique; each catalog entry must exactly equal the index's current, non-withdrawn,
|
||||
source-only entry. Withdrawal does not disable, uninstall or delete any existing
|
||||
@@ -73,7 +80,7 @@ exact SHA-256 bytes; it does not relax those future gates or auto-enable code.
|
||||
| README details | Exact delivery `/assets/<hash>.md` | Visible error/Retry, no upstream fallback |
|
||||
| Uninstalled brand icons | Existing reviewed bundled previews | Default icon; delivery icon metadata validated but not fetched by UI |
|
||||
| Installed brand icons | Host-owned local package decoder | Default icon; no activation/network request |
|
||||
| Author avatars | Not implemented; delivery v1 requires null | Existing local presentation; no avatar lookup |
|
||||
| Author avatars | Visible-row host fetch of a validated v2 same-origin resource; v1 remains null | Default placeholder; no GitHub lookup or renderer URL fetch |
|
||||
| Package artifacts | Not published or admitted by delivery v1 | Source-only; no download action fabricated |
|
||||
| Source/README links | Explicit user-opened external HTTPS link | May open GitHub; no background dependency |
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Settings visibility and current-main acceptance candidate
|
||||
|
||||
This isolated branch, `integration/plugin-settings-main-acceptance`, starts at
|
||||
frozen #4004 `2433cf0130d1c3274c56c39e5765349c2945de01`. Its Draft PR and private
|
||||
renderer provenance record the final exact head and CI. Frozen2433 source,
|
||||
artifact, QA workspace/profile and earlier evidence remain intact.
|
||||
|
||||
## Inputs and production ownership
|
||||
|
||||
Both inputs were ordinarily merged, without conflicts or manual production edits:
|
||||
|
||||
| Input | Import | Production files and behavior |
|
||||
| --- | --- | --- |
|
||||
| Current main `a0cf2b487c9ff0b0472d622e66bc5ee20de32c71` | Merge `c4aedfa9ed8c9f2647ac26f0e6e144edab341c2d` | `domain/workspaceAiScopeHandoff.ts` keeps inherited AI chat on a surviving owner when a workspace dissolves (#4003/e3e2dcfe); `electron/bridges/ai/shellUtils.cjs` narrowly recognizes Oh My Zsh ys two-line idle prompts (#4008/a0cf2b48) |
|
||||
| Policy `1551fa8355021b5a811d737d16f81e8070f99f5d` | Merge `714e2397a43dc6c083dce8afeeb7a0475de11384` | `PluginLogSettings.tsx` removes the idle-row security marker and opts the nested verbose confirmation into it; `confirm-dialog.tsx` adds the optional host-only marker with default false |
|
||||
|
||||
All four production files exactly match their respective owners. Main has only
|
||||
those two new commits since common base f0897c3c. Neither changed file overlaps
|
||||
the prior plugin changes. The new prompt utility is not imported by plugin
|
||||
production modules; plugin sensitive-input, selection and source guards remain
|
||||
unchanged. Existing prompt and live-PTY-byte suffix tests still reject bare
|
||||
markers and pending input. These are synthetic tests, not a new native SSH pass.
|
||||
|
||||
The input also owns its real Settings landing regression and documentation.
|
||||
The existing observer, Ask marker, IPC/window authority, sandbox, resource/source
|
||||
controls, AI d33 wiring, schemas, sample bytes and package/lock inputs are
|
||||
unchanged. `ModelSelector.tsx` equals2433: separate #4002 is not included. Global
|
||||
Agent/custom Chat #4006 and terminal diagnostic #3999 are excluded. No main merge,
|
||||
Ready transition, original-PR closure or release is part of this handoff.
|
||||
|
||||
The parent reports the1551-vs89 independent delta review scoped clean: idle
|
||||
Settings no longer requests suspension, the real logging dialog remains marked,
|
||||
its nested verbose portal protects itself while the parent is accessibility-
|
||||
hidden, closing the nested dialog leaves the parent protected, and closing all
|
||||
dialogs permits restoration. This source-only result is not native resolution
|
||||
of the recorded Settings blank body.
|
||||
|
||||
## Frozen2433 native results
|
||||
|
||||
The parent supplied these results; this coding executor did not run the GUI:
|
||||
|
||||
| Case | Result on2433 |
|
||||
| --- | --- |
|
||||
| Main Ask visible | Passed |
|
||||
| Cancel | Passed; zero provider POSTs |
|
||||
| Accept | Passed; one provider POST |
|
||||
| Stream chunks | Passed; no repeated permission prompts |
|
||||
| Content Deny | Passed |
|
||||
| Real five-minute chat expiry | Passed |
|
||||
| Settings sample body | Blank; not accepted |
|
||||
|
||||
The idle-row suppression defect is source-confirmed. The correction requires a
|
||||
new native Settings recheck; no native success is inferred from the DOM regression.
|
||||
A later GPU exit9 and incomplete cleanup were reported separately. Its cause is
|
||||
unclassified; no source hotpatch or sandbox/GPU workaround was used here.
|
||||
|
||||
Consumer verification of2433's Library renderer is complete: the parent checked
|
||||
the matching full source/backend and all1001 renderer hashes, with native inputs
|
||||
unchanged. The earlier producer download failure remains historical evidence,
|
||||
not an outstanding consumer transfer blocker. That verified artifact still
|
||||
certifies2433 only; it does not include1551 or the new main renderer behavior.
|
||||
|
||||
Earlier020 Default Allow cold Send/completion, Stop/Deny/restore and themes,
|
||||
677 Host core, and corrected-fixture terminal solo/split results remain scoped
|
||||
historical evidence. Terminal Focus is unreliable/unverified and QA sent one
|
||||
Enter inA during a palette attempt: no global zero-input/full-terminal-pass claim.
|
||||
The historical Rfam attempt failed socket transport before connect resolved;
|
||||
authentication/SELECT were not reached and cause/auth/TLS compatibility remain
|
||||
unverified. Final source-policy, DB, placement/lifecycle and actual hosting/CDN
|
||||
gates remain separately reconciled by the parent.
|
||||
|
||||
## Local checks and sample provenance
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Registered plugin runtime | 695 passed, one native skip; zero failed/cancelled |
|
||||
| Contract/SDK/CLI | 91 passed |
|
||||
| Offline examples | Five passed, one Docker opt-in skip |
|
||||
| Changed main owner/scope/prompt/suffix and terminal lifecycle group | 139 passed |
|
||||
| Actual mounted AI/permission/selected-context/Host group | 21 passed |
|
||||
| Settings boundary and security visibility group | 38 passed |
|
||||
| Contract/capability/Codex generation and committed drift | Clean |
|
||||
| Lint | Zero errors, seven existing warnings |
|
||||
| Five sample archives | Validated, built twice and byte-identical to2433 |
|
||||
|
||||
The final exact-head normal production build, renderer inventory, bounded
|
||||
credential check, CI and Codex review are recorded in the Draft PR/artifact
|
||||
handoff. Local full-suite, native packaging and GUI success are not claimed.
|
||||
Locked dependencies, standard patches and the official pinned Electron binary
|
||||
were prepared normally; no header, HOME, sandbox or native shim workaround.
|
||||
|
||||
[Exact sample IDs, permissions/resources and hashes](settings-main-packages.json)
|
||||
preserve all prior provenance, including Assistant7943d4c3, ownedDBf01c6a24,
|
||||
Rfamaaca5866, Hostcf04f82d and terminal4c9c2388. Whole archive equality was
|
||||
asserted, rather than inferred from unchanged source. The owned10.77.0.20 endpoint
|
||||
is packaging-only, not a running fixture. No package was activated or service
|
||||
queried by this executor.
|
||||
|
||||
The owner's separate QA-only Settings Ask sample has reported SHA-256
|
||||
`9502a58a4804862e4b13de9fc0d1baf6ad8c2bc681c08b5c0664b552ef0aafc4`.
|
||||
It is not one of these five repository examples and was not rebuilt here. The
|
||||
native owner must retain its exact verified manifest/source/permissions and
|
||||
fixture provenance; no package ID, permission expansion or hash equivalence is
|
||||
invented by this integration.
|
||||
|
||||
## Native preparation and bounded recheck
|
||||
|
||||
1. After the parent's GUI/Nginx slot is released, use a NEW isolated checkout of
|
||||
the final exact head and a separate synthetic QA profile. Preserve the old2433
|
||||
workspace/profile and recordings. Materialize the new private Library artifact
|
||||
in the consumer's own workspace and verify archive and every renderer hash.
|
||||
2. Use the complete matching backend checkout: frontend-only dist does not carry
|
||||
the CJS prompt helper or native dependencies. Match package/lock hashes to
|
||||
independently normally rebuilt native inputs and prepare workspace packages
|
||||
normally. Install dist as a whole; use the ordinary Electron launch without
|
||||
Vite and with normal sandbox/helper settings. This is unpackaged native
|
||||
production-renderer QA, not packaged-binary certification.
|
||||
3. Open the verified Settings sample through its existing supported settings
|
||||
entry. Check the idle body is visible/clickable with the logging row present.
|
||||
Open logging and the nested verbose confirmation separately; native content
|
||||
must yield to each, remain hidden after cancelling the nested confirmation,
|
||||
and restore only after the parent closes. Do not enable real verbose logging
|
||||
or collect real content for this check.
|
||||
4. In the ready/focused/visible/unlocked Settings owner, exercise its synthetic
|
||||
own-setting Ask operation. Record visible Ask, explicit cancel/accept, exact
|
||||
current operation outcome and restoration. Recheck main Ask briefly. Preserve
|
||||
the2433 main/content/expiry counters and any unrun matrix cases separately.
|
||||
5. Use owned synthetic A/B AI chats: form a workspace, prefer B, dissolve while A
|
||||
survives and confirm both histories stay with their owners. Remove A and
|
||||
check bounded fallback to B. Confirm handoff alone does not invoke a model or
|
||||
re-share content. Exercise selected-target invalidation across the transition
|
||||
with inert drafts and no PTY input. Do not treat earlier Focus uncertainty as
|
||||
resolved by this main domain fix.
|
||||
6. Record exact source/artifact/sample identities, screenshots/video, request
|
||||
counters, cleanup and unrun cases. No fallback endpoint/probe, real credentials,
|
||||
paid API, security relaxation or unrecognized installation is authorized here.
|
||||
|
||||
If main advances after this pinned a0cf snapshot, compare the new delta and assess
|
||||
its affected checks before landing. The current candidate deliberately does not
|
||||
chase unrelated changes while native preparation is in progress.
|
||||
@@ -0,0 +1,217 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 6,
|
||||
"bytes": 17214,
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"contentSha256": "c3e31e9613422bf9a05ccaa5b6f234304208f1a8298b1c1ee19262ba88af6cd0",
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"package": "assistant.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395",
|
||||
"changedEntriesFromFrozen677": [
|
||||
"README.md",
|
||||
"panel.css",
|
||||
"panel.js"
|
||||
],
|
||||
"previous7856Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom020": [],
|
||||
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
|
||||
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
|
||||
"previous2433Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"changedEntriesFrom2433": []
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11660,
|
||||
"sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"contentSha256": "4d628c41c7a231e31cd56dacb822acaaa37f61e1e98b285b0086a4f783be199d",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-owned.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom020": [],
|
||||
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
|
||||
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
|
||||
"previous2433Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"changedEntriesFrom2433": []
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 5,
|
||||
"bytes": 11003,
|
||||
"sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"contentSha256": "f542ca7b8e821d684cf7f71f0930458bd3ad97bc94bf654c88b0f353111e9162",
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"package": "database-rfam-readonly.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom020": [],
|
||||
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
|
||||
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
|
||||
"previous2433Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"changedEntriesFrom2433": []
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 3,
|
||||
"bytes": 4633,
|
||||
"sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"contentSha256": "d6f8bf3ae9dc417ea838f673966f2e1075fc8f9ef1d939ca85b476b1bb007e6c",
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"package": "host-actions.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom020": [],
|
||||
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
|
||||
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
|
||||
"previous2433Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"changedEntriesFrom2433": []
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"fileCount": 2,
|
||||
"bytes": 2954,
|
||||
"sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"contentSha256": "99797d8679f34b96580076f922cbec067e511adc0f4ccf396ed129206acb6c4c",
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"package": "terminal-selection-draft.ncpkg",
|
||||
"repeatIdentical": true,
|
||||
"preparedOnlyInThisExecutor": true,
|
||||
"previousFrozen677Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFromFrozen677": [],
|
||||
"previous7856Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom7856": [],
|
||||
"rebuiltAgainstPolicy": "1551fa8355021b5a811d737d16f81e8070f99f5d",
|
||||
"rebuiltAgainstAI": "d33c0193ccaedc1a10aed9da495f040250121bef",
|
||||
"previous020Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom020": [],
|
||||
"rebuiltAgainstMain": "a0cf2b487c9ff0b0472d622e66bc5ee20de32c71",
|
||||
"productionMerge": "714e2397a43dc6c083dce8afeeb7a0475de11384",
|
||||
"previous2433Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"changedEntriesFrom2433": []
|
||||
}
|
||||
]
|
||||
@@ -108,7 +108,7 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when its pane is
|
||||
});
|
||||
});
|
||||
|
||||
test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pane also survives', () => {
|
||||
test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving owner pane', () => {
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: {
|
||||
'workspace:ws-1': 'chat-a',
|
||||
@@ -132,12 +132,52 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pa
|
||||
preferredTerminalId: 'term-b',
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-a');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-a'], null);
|
||||
assert.equal(result.sessions[0]?.scope.targetId, 'term-b');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
|
||||
assert.equal(result.sessions[0]?.scope.type, 'terminal');
|
||||
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
|
||||
assert.equal(result.sessions[0]?.updatedAt, 1);
|
||||
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-b'], {
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
|
||||
mode: 'session',
|
||||
sessionId: 'chat-a',
|
||||
});
|
||||
assert.equal(result.panelViewByScope['terminal:term-b'], undefined);
|
||||
});
|
||||
|
||||
test('handoffDissolvedWorkspaceAIScope keeps owner history when the other pane is preferred', () => {
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: {
|
||||
'workspace:ws-1': 'chat-a',
|
||||
'terminal:term-b': 'chat-b',
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: 'chat-a',
|
||||
scope: { type: 'terminal', targetId: 'term-a', hostIds: ['host-a'] },
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'chat-b',
|
||||
scope: { type: 'terminal', targetId: 'term-b', hostIds: ['host-b'] },
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
workspaceId: 'ws-1',
|
||||
terminalIds: ['term-a', 'term-b'],
|
||||
preferredTerminalId: 'term-b',
|
||||
panelViewByScope: {
|
||||
'workspace:ws-1': { mode: 'session', sessionId: 'chat-a' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
|
||||
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
|
||||
assert.equal(result.sessions[1]?.id, 'chat-b');
|
||||
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
|
||||
mode: 'session',
|
||||
sessionId: 'chat-a',
|
||||
});
|
||||
|
||||
@@ -78,13 +78,15 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* When a workspace tab dissolves, copy its active chat onto the preferred
|
||||
* surviving terminal before the workspace scope key is pruned. Also remint
|
||||
* workspace-scoped chats and the handed-off active chat when it still lives
|
||||
* under a different terminal (including one that also survives dissolve),
|
||||
* clear that previous terminal selection so ownership filters do not hide the
|
||||
* chat, and seed a session panel view so terminal scopes do not fall back to
|
||||
* a blank draft.
|
||||
* When a workspace tab dissolves, copy its active chat onto a surviving
|
||||
* terminal before the workspace scope key is pruned. An inherited chat that
|
||||
* is already terminal-scoped to a pane still in `terminalIds` stays on that
|
||||
* owner, even when focus/preferred is a different survivor. Otherwise the
|
||||
* target is the preferred survivor, or the first remaining terminal when the
|
||||
* original pane is gone. Workspace-scoped chats are reminted onto that
|
||||
* target, duplicate terminal map entries for the same chat are cleared, and
|
||||
* a session panel view is seeded so terminal scopes do not fall back to a
|
||||
* blank draft.
|
||||
*/
|
||||
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
|
||||
activeSessionIdMap: AIActiveSessionIdMap;
|
||||
@@ -99,7 +101,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const preferredTerminalId = (
|
||||
const fallbackTerminalId = (
|
||||
input.preferredTerminalId
|
||||
&& input.terminalIds.includes(input.preferredTerminalId)
|
||||
? input.preferredTerminalId
|
||||
@@ -109,6 +111,17 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
|
||||
const workspaceActive = input.activeSessionIdMap[workspaceKey];
|
||||
const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0;
|
||||
const activeSession = hasWorkspaceActive
|
||||
? input.sessions.find((session) => session.id === workspaceActive)
|
||||
: undefined;
|
||||
const survivingOwnerTerminalId = (
|
||||
activeSession?.scope.type === 'terminal'
|
||||
&& activeSession.scope.targetId
|
||||
&& input.terminalIds.includes(activeSession.scope.targetId)
|
||||
? activeSession.scope.targetId
|
||||
: null
|
||||
);
|
||||
const handoffTargetTerminalId = survivingOwnerTerminalId ?? fallbackTerminalId;
|
||||
const previousPanelViewByScope = (
|
||||
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
|
||||
) ?? {};
|
||||
@@ -118,8 +131,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
};
|
||||
let mapChanged = false;
|
||||
|
||||
if (preferredTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
||||
if (handoffTargetTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
|
||||
let map = nextMap;
|
||||
let changed = false;
|
||||
if (map[terminalKey] !== workspaceActive) {
|
||||
@@ -145,24 +158,23 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
|
||||
let sessionsChanged = false;
|
||||
const nextSessions = input.sessions.map((session) => {
|
||||
if (!preferredTerminalId) return session;
|
||||
if (!handoffTargetTerminalId) return session;
|
||||
|
||||
const isWorkspaceScoped = (
|
||||
session.scope.type === 'workspace'
|
||||
&& session.scope.targetId === input.workspaceId
|
||||
);
|
||||
// Inherited member chats stay terminal-scoped under the original pane.
|
||||
// Prefer the focused survivor even when that original pane also survives
|
||||
// dissolve, otherwise B's history ownership filter excludes A's chat.
|
||||
// Keep an inherited chat on its owner when that pane survives. Remint
|
||||
// only when the handoff target is a different terminal (original pane gone).
|
||||
const isInheritedActiveChatOnOtherTerminal = (
|
||||
hasWorkspaceActive
|
||||
&& session.id === workspaceActive
|
||||
&& session.scope.type === 'terminal'
|
||||
&& Boolean(session.scope.targetId)
|
||||
&& session.scope.targetId !== preferredTerminalId
|
||||
&& session.scope.targetId !== handoffTargetTerminalId
|
||||
);
|
||||
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
|
||||
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
|
||||
if (session.scope.type === 'terminal' && session.scope.targetId === handoffTargetTerminalId) {
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -172,7 +184,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
scope: {
|
||||
...session.scope,
|
||||
type: 'terminal',
|
||||
targetId: preferredTerminalId,
|
||||
targetId: handoffTargetTerminalId,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -182,8 +194,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
|
||||
...previousPanelViewByScope,
|
||||
};
|
||||
if (preferredTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
||||
if (handoffTargetTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
|
||||
const workspacePanelView = previousPanelViewByScope[workspaceKey];
|
||||
const terminalPanelView = previousPanelViewByScope[terminalKey];
|
||||
const nextView: AIPanelViewHandoffLike = (
|
||||
|
||||
@@ -71,9 +71,17 @@ const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
|
||||
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
|
||||
// line (`# user @ host ...`), matched separately below. A bare `$`/`#` alone
|
||||
// is not a prompt.
|
||||
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
|
||||
|
||||
// Oh My Zsh `ys` status line: a decorative `#`, then `user @ host` with
|
||||
// spaces around `@`, then an optional remainder (directory, git, venv, time,
|
||||
// exit code). Example: `# user @ host in ~ [12:34:56]`. Arbitrary text before
|
||||
// a bare `$`/`#` still does not match.
|
||||
const OH_MY_ZSH_YS_HOST_LINE_PATTERN = /^#\s+[^\s@]+\s+@\s+[^\s]+(?:\s+[^\n\r]+)?$/;
|
||||
|
||||
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
|
||||
// Require a hostname and a home/absolute path, excluding prompt delimiters
|
||||
// within the path so partially entered commands do not count as idle prompts.
|
||||
@@ -124,12 +132,15 @@ function extractTrailingIdlePrompt(output) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
|
||||
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
|
||||
// CR-to-LF normalize above turns that into blank lines between the host
|
||||
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
|
||||
// uses the same normalize, and hasExpectedPromptSuffix compares this
|
||||
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
|
||||
// Two-line PS1. Classic bash is `export PS1="\u@\h: \w\n$ "`. Oh My Zsh
|
||||
// `ys` paints `# user @ host ...` and then a bare `$` or `#`. Bash emits
|
||||
// the newline as CR and the PTY's ONLCR adds another, so the bytes are
|
||||
// `\r\r\n`. The CR-to-LF normalize above turns that into blank lines
|
||||
// between the host line and `$`/`#`. Keep those blanks in the cache:
|
||||
// getFreshIdlePrompt uses the same normalize, and hasExpectedPromptSuffix
|
||||
// compares this CR-as-LF form against live PTY bytes. A lone `$`/`#` must
|
||||
// not satisfy endsWith; the previous non-empty line has to be a recognized
|
||||
// host line (classic `user@host[:path]` or Oh My Zsh `ys`).
|
||||
if (rightTrimmed === "$" || rightTrimmed === "#") {
|
||||
let hostIndex = lines.length - 1;
|
||||
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
|
||||
@@ -137,7 +148,13 @@ function extractTrailingIdlePrompt(output) {
|
||||
}
|
||||
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
|
||||
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
|
||||
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
|
||||
// Same optional "(name) " env prefix as isDefaultPosixPromptLine (#3947).
|
||||
// Recognition only; the returned prompt keeps the visible prefix.
|
||||
const previousHostLine = previousRightTrimmed.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
if (
|
||||
POSIX_PROMPT_HOST_LINE_PATTERN.test(previousHostLine)
|
||||
|| OH_MY_ZSH_YS_HOST_LINE_PATTERN.test(previousHostLine)
|
||||
) {
|
||||
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
|
||||
}
|
||||
}
|
||||
@@ -202,8 +219,9 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
|
||||
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
|
||||
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// `user@host[:path][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
|
||||
// `ys` (`# user @ host ...`) + `$`/`#`, RHEL `[user@host path][#$]`, or
|
||||
// Alpine `host:path[#$]`);
|
||||
// a remote shell switch into another shell, an oh-my-posh / starship / custom
|
||||
// PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
|
||||
@@ -262,6 +262,83 @@ test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host
|
||||
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt recognizes an Oh My Zsh ys two-line idle prompt", () => {
|
||||
// ys paints `# user @ host ...` then a bare `$` or `#`. Spaces around `@`
|
||||
// and the leading decorative `#` are required; a lone marker is not a prompt.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ "),
|
||||
"# user @ host in ~ [12:34:56]\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\n# user @ host in ~ [12:34:56]\n# "),
|
||||
"# user @ host in ~ [12:34:56]\n# ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~/proj on git:main [12:34:56]\n$ "),
|
||||
"# user @ host in ~/proj on git:main [12:34:56]\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\n# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ "),
|
||||
"# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ ",
|
||||
);
|
||||
// Same CR / CRLF blank lines as the classic two-line PS1.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\r\n$ "),
|
||||
"# user @ host in ~ [12:34:56]\n\n\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\n# "),
|
||||
"# user @ host in ~ [12:34:56]\n\n# ",
|
||||
);
|
||||
// Optional "(name) " env prefix is stripped only for recognition (#3947).
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("(venv) # user @ host in ~ [12:34:56]\n$ "),
|
||||
"(venv) # user @ host in ~ [12:34:56]\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("(venv) user@host: /home/foo\n$ "),
|
||||
"(venv) user@host: /home/foo\n$ ",
|
||||
);
|
||||
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("user @ host in ~\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user host in ~ [12:34:56]\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user@host in ~ [12:34:56]\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ ls"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56] $"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("echo $"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("price is 5$"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("(venv) not a host line\n$ "), "");
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oh My Zsh ys two-line prompt", () => {
|
||||
const session = {};
|
||||
const prompt = "# user @ host in ~ [12:34:56]\n$ ";
|
||||
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
|
||||
const extra = {};
|
||||
trackSessionIdlePrompt(extra, "# user @ host in ~/proj on git:main [12:34:56]\r\r\n$ ");
|
||||
assert.equal(
|
||||
getFreshIdlePrompt(extra),
|
||||
"# user @ host in ~/proj on git:main [12:34:56]\n\n\n$ ",
|
||||
);
|
||||
|
||||
const root = {};
|
||||
trackSessionIdlePrompt(root, "# user @ host in ~ [12:34:56]\n# ");
|
||||
assert.equal(getFreshIdlePrompt(root), "# user @ host in ~ [12:34:56]\n# ");
|
||||
|
||||
const bare = {};
|
||||
assert.equal(trackSessionIdlePrompt(bare, "$ "), "");
|
||||
assert.equal(getFreshIdlePrompt(bare), "");
|
||||
assert.equal(trackSessionIdlePrompt(bare, "not a host line\n$ "), "");
|
||||
assert.equal(getFreshIdlePrompt(bare), "");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { PluginContributionIconService } = require("./contributionIconService.cjs");
|
||||
const { MarketSourcesService } = require("./marketSourcesService.cjs");
|
||||
const { OFFICIAL_SOURCE, LOCAL_SOURCE, thirdPartySource } = require("./sourceIdentity.cjs");
|
||||
const { catalog, delivery, digest, makeFiles, read } = require("./fixtures/staticDistributionFixture.cjs");
|
||||
|
||||
const sourcePng = read(delivery.plugins[0].iconLight.path.slice(1));
|
||||
const avatar = { path: `/assets/${digest(sourcePng)}.png`, sha256: digest(sourcePng), size: sourcePng.length };
|
||||
function filesFor(entries = catalog, change = () => {}) {
|
||||
const files = makeFiles(entries, (index, sidecar) => {
|
||||
sidecar.schemaVersion = 2;
|
||||
for (const item of sidecar.plugins) {
|
||||
item.avatar = structuredClone(avatar);
|
||||
const entry = entries.find((entry) => entry.id === item.id);
|
||||
if (entry.artifact) {
|
||||
item.artifact = { path: `/assets/${entry.artifact.sha256}.ncpkg`, sha256: entry.artifact.sha256, size: 1 };
|
||||
index.plugins.find((plugin) => plugin.id === item.id).versions[0].distribution = "release";
|
||||
}
|
||||
}
|
||||
change(index, sidecar);
|
||||
});
|
||||
files.set(avatar.path, sourcePng);
|
||||
return files;
|
||||
}
|
||||
function imageService(rasterizeIcon) {
|
||||
return new PluginContributionIconService({ rasterizeIcon: rasterizeIcon ?? (async () => {
|
||||
const png = Buffer.alloc(24); Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(png);
|
||||
png.write("IHDR", 12); png.writeUInt32BE(32, 16); png.writeUInt32BE(32, 20); return png;
|
||||
}) });
|
||||
}
|
||||
function registry(options = {}) {
|
||||
const requests = [];
|
||||
let files = options.files ?? filesFor();
|
||||
const service = new RegistryCatalogService({ ...options,
|
||||
fetch: options.fetch ?? (async (url, init) => {
|
||||
requests.push(url);
|
||||
assert.equal(new URL(url).origin, options.source?.origin ?? OFFICIAL_SOURCE.origin);
|
||||
assert.equal(init.redirect, "manual"); assert.equal(init.credentials, "omit");
|
||||
return files.has(new URL(url).pathname) ? new Response(files.get(new URL(url).pathname)) : new Response(null, { status: 404 });
|
||||
}),
|
||||
resolveAvatar: options.resolveAvatar ?? ((request) => imageServiceSingleton.resolveCatalogAvatar(request)),
|
||||
});
|
||||
const imageServiceSingleton = imageService();
|
||||
return { service, requests, setFiles(value) { files = value; } };
|
||||
}
|
||||
|
||||
test("v2 avatars use verified same-origin bytes and the shared image cache without GitHub", async () => {
|
||||
const r = registry(); const snapshot = await r.service.list(); assert.equal(snapshot.error, null);
|
||||
const entry = snapshot.entries[0];
|
||||
const values = await Promise.all([r.service.author(entry.id, entry.selectionToken), r.service.author(entry.id, entry.selectionToken)]);
|
||||
assert.deepEqual(values[0], values[1]); assert.match(values[0].avatar, /^data:image\/png;base64,/);
|
||||
assert.equal(values[0].officialMaintained, true); assert.equal(values[0].author, "Netcatty");
|
||||
assert.equal(r.requests.filter((url) => new URL(url).pathname === avatar.path).length, 1);
|
||||
assert.ok(r.requests.every((url) => new URL(url).origin === OFFICIAL_SOURCE.origin));
|
||||
await r.service.author(entry.id, entry.selectionToken);
|
||||
assert.equal(r.requests.length, 5, "cached raster avoids another download");
|
||||
});
|
||||
|
||||
test("third-party copies, community entries and local imports cannot acquire maintenance authority", async () => {
|
||||
const copied = registry({ source: thirdPartySource("Netcatty official", "https://community.example.com") });
|
||||
const entry = (await copied.service.list()).entries[0];
|
||||
assert.equal((await copied.service.author(entry.id, entry.selectionToken)).officialMaintained, false);
|
||||
const community = structuredClone(catalog[0]); community.id = "com.community.official";
|
||||
community.manifest.id = community.id; community.author = "Netcatty official"; community.manifest.publisher = "netcatty";
|
||||
const r = registry({ files: filesFor([community]) }); const listed = (await r.service.list()).entries[0];
|
||||
assert.equal((await r.service.author(listed.id, listed.selectionToken)).officialMaintained, false);
|
||||
const local = new MarketSourcesService({ database: { getActivePlugin: () => ({ id: catalog[0].id, activeVersion: catalog[0].version,
|
||||
source: LOCAL_SOURCE, archiveSha256: "a".repeat(64), manifest: catalog[0].manifest, runtime: {} }) } });
|
||||
assert.equal((await local.author({ kind: "installed", pluginId: catalog[0].id })).officialMaintained, false);
|
||||
await assert.rejects(local.author({ kind: "installed", pluginId: catalog[0].id, officialMaintained: true }), /Invalid/);
|
||||
});
|
||||
|
||||
test("avatar failure, redirects and mismatched bytes yield a placeholder without another origin", async () => {
|
||||
for (const kind of ["missing", "hash", "format", "redirect"]) {
|
||||
const files = filesFor(); const requests = [];
|
||||
const r = registry({ files, fetch: async (url) => {
|
||||
requests.push(url); const p = new URL(url).pathname;
|
||||
if (p === avatar.path) {
|
||||
if (kind === "redirect") return new Response(null, { status: 302, headers: { location: "https://avatars.githubusercontent.com/u/1" } });
|
||||
if (kind === "missing") return new Response(null, { status: 404 });
|
||||
if (kind === "hash") return new Response(Buffer.alloc(sourcePng.length));
|
||||
}
|
||||
return new Response(files.get(p));
|
||||
}, ...(kind === "format" ? { resolveAvatar: async () => { throw new Error("Invalid PNG"); } } : {}) });
|
||||
const entry = (await r.service.list()).entries[0]; const value = await r.service.author(entry.id, entry.selectionToken);
|
||||
assert.equal(value.avatar, null, kind); assert.equal(value.officialMaintained, true);
|
||||
assert.ok(requests.every((url) => new URL(url).origin === OFFICIAL_SOURCE.origin));
|
||||
}
|
||||
});
|
||||
|
||||
test("avatar schema rejects foreign paths, non-raster formats and invalid sizes before fetching", async () => {
|
||||
for (const ref of [
|
||||
{ ...avatar, path: "https://avatars.githubusercontent.com/u/1" },
|
||||
{ ...avatar, path: `/assets/${avatar.sha256}.svg` },
|
||||
{ ...avatar, path: `/assets/${"a".repeat(64)}.png` },
|
||||
{ ...avatar, size: 0 }, { ...avatar, size: 256 * 1024 + 1 }, { ...avatar, size: 1.5 },
|
||||
]) {
|
||||
const r = registry({ files: filesFor(catalog, (_index, delivery) => { delivery.plugins[0].avatar = ref; }) });
|
||||
const result = await r.service.list(); assert.ok(result.error, JSON.stringify(ref));
|
||||
assert.equal(r.requests.length, 4, "invalid delivery cannot cause an avatar request");
|
||||
}
|
||||
});
|
||||
|
||||
test("v2 package references enforce the publisher's positive 16MiB bound without installing", async () => {
|
||||
const entry = structuredClone(catalog[0]);
|
||||
entry.artifact = { sha256: "a".repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${"a".repeat(64)}.ncpkg` };
|
||||
for (const size of [0, 16 * 1024 * 1024, 16 * 1024 * 1024 + 1]) {
|
||||
const r = registry({ files: filesFor([entry], (_index, sidecar) => { sidecar.plugins[0].artifact.size = size; }) });
|
||||
const snapshot = await r.service.list();
|
||||
assert.equal(Boolean(snapshot.error), size !== 16 * 1024 * 1024);
|
||||
assert.equal(r.requests.length, 4, "catalog validation never downloads or installs the package");
|
||||
}
|
||||
});
|
||||
|
||||
test("a changed selection or removed source cannot publish an in-flight avatar result", async () => {
|
||||
for (const change of ["selection", "source"]) {
|
||||
let release; let entered; let enabled = true;
|
||||
const started = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const r = registry({ sourceGuard() { if (!enabled) throw new Error("Source removed"); }, resolveAvatar: async () => { entered(); await gate; return "data:image/png;base64,eA=="; } });
|
||||
const entry = (await r.service.list()).entries[0]; const pending = r.service.author(entry.id, entry.selectionToken); await started;
|
||||
if (change === "selection") { r.setFiles(filesFor(catalog, (_i, sidecar) => { sidecar.plugins[0].avatar = null; })); await r.service.refresh(); }
|
||||
else enabled = false;
|
||||
release(); await assert.rejects(pending, /changed|removed/);
|
||||
}
|
||||
});
|
||||
|
||||
test("installed presentation requires exact source/version/package/manifest and rejects replacement during load", async () => {
|
||||
const entry = structuredClone(catalog[0]); entry.artifact = { sha256: "a".repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${"a".repeat(64)}.ncpkg` };
|
||||
let plugin = { id: entry.id, activeVersion: entry.version, archiveSha256: entry.artifact.sha256, source: OFFICIAL_SOURCE, manifest: entry.manifest, runtime: {} };
|
||||
const requests = []; const files = filesFor([entry]);
|
||||
const database = { getActivePlugin: () => plugin, db: { prepare: () => ({ get: () => undefined }) } };
|
||||
const service = new MarketSourcesService({ database, fetch: async (url) => { requests.push(url); return new Response(files.get(new URL(url).pathname)); } });
|
||||
const query = { kind: "installed", pluginId: entry.id };
|
||||
assert.equal((await service.author(query)).officialMaintained, false); assert.equal(requests.length, 0);
|
||||
await service.list(); assert.equal((await service.author(query)).officialMaintained, true);
|
||||
for (const patch of [{ activeVersion: "9.0.0" }, { archiveSha256: "b".repeat(64) }, { manifest: { ...entry.manifest, publisher: "other" } }, { source: LOCAL_SOURCE }]) {
|
||||
const saved = plugin; plugin = { ...plugin, ...patch };
|
||||
assert.equal((await service.author(query)).officialMaintained, false); plugin = saved;
|
||||
}
|
||||
let release; let entered;
|
||||
const started = new Promise((resolve) => { entered = resolve; });
|
||||
service.catalogs.get("official").resolveAvatar = async () => { entered(); await new Promise((resolve) => { release = resolve; }); return null; };
|
||||
const pending = service.author(query); await started; plugin = { ...plugin, archiveSha256: "b".repeat(64) }; release();
|
||||
await assert.rejects(pending, /Installed plugin changed/);
|
||||
});
|
||||
|
||||
test("avatar decoding validates bytes and uses the existing bounded rasterizer", async () => {
|
||||
let calls = 0;
|
||||
const service = imageService(async () => { calls++; throw new Error("Decode failed"); });
|
||||
const request = { sourceId: "official", reference: avatar, read: async () => sourcePng };
|
||||
await assert.rejects(service.resolveCatalogAvatar(request), /Decode failed/); assert.equal(calls, 1);
|
||||
await assert.rejects(service.resolveCatalogAvatar({ ...request, read: async () => Buffer.alloc(sourcePng.length) }), /bytes/); assert.equal(calls, 1);
|
||||
await assert.rejects(service.resolveCatalogAvatar({ ...request, reference: { ...avatar, path: `/assets/${avatar.sha256}.webp` } }), /format|signature/); assert.equal(calls, 1);
|
||||
const cached = imageService();
|
||||
await cached.resolveCatalogAvatar(request);
|
||||
await assert.rejects(cached.resolveCatalogAvatar({ ...request, reference: { ...avatar, size: avatar.size + 1 } }), /bytes/);
|
||||
await assert.rejects(cached.resolveCatalogAvatar({ ...request, reference: { ...avatar, path: `/assets/${avatar.sha256}.webp` } }), /format|signature/);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
const path = require("node:path");
|
||||
const { createHash } = require("node:crypto");
|
||||
|
||||
const { readContainedFile } = require("./pluginProtocol.cjs");
|
||||
const { createIsolatedContributionIconRasterizer } = require("./contributionIconRasterizer.cjs");
|
||||
@@ -214,6 +215,35 @@ class PluginContributionIconService {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Called only with a host-selected, hash-bound registry reference. It shares
|
||||
// the existing decoder, queue, cache and pending work with package icons.
|
||||
async resolveCatalogAvatar({ sourceId, reference, read }) {
|
||||
const key = JSON.stringify(["catalog-avatar", sourceId, reference.path, reference.sha256, reference.size]);
|
||||
if (this.cache.has(key)) return this.cache.get(key);
|
||||
if (this.pending.has(key)) return this.pending.get(key);
|
||||
if (this.pending.size >= MAX_QUEUED_ICON_RASTERIZATIONS + MAX_CONCURRENT_ICON_RASTERIZATIONS) {
|
||||
throw new Error("Plugin image queue is full");
|
||||
}
|
||||
const pending = (async () => {
|
||||
const body = await read();
|
||||
if (!Buffer.isBuffer(body) || body.length !== reference.size || body.length > 256 * 1024
|
||||
|| createHash("sha256").update(body).digest("hex") !== reference.sha256) throw new Error("Invalid plugin avatar bytes");
|
||||
const extension = path.extname(reference.path);
|
||||
if (![".png", ".webp"].includes(extension)) throw new Error("Invalid plugin avatar format");
|
||||
const inspected = inspectContributionIconSource(body, extension);
|
||||
if (inspected.mimeType !== (extension === ".png" ? "image/png" : "image/webp")) throw new Error("Plugin avatar format mismatch");
|
||||
const png = await this.rasterizeIcon({ body, ...inspected, maxEdge: MAX_ICON_EDGE });
|
||||
if (!png.length || png.length > MAX_ICON_PNG_BYTES) throw new Error("Plugin avatar output is too large");
|
||||
inspectContributionIconSource(png, ".png", MAX_ICON_EDGE);
|
||||
const value = `data:image/png;base64,${png.toString("base64")}`;
|
||||
if (this.cache.size >= 256) this.cache.clear();
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
})().finally(() => this.pending.delete(key));
|
||||
this.pending.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async #resolveUncached({ pluginId, plugin, icon, cacheKey }) {
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
const result = Object.freeze({
|
||||
|
||||
@@ -14,3 +14,12 @@ The fixture includes no executable plugin package, avatar or credentials.
|
||||
|
||||
Schema revisions must be coordinated with the registry publisher. In particular,
|
||||
artifact/avatar are deliberately null in v1; do not expand this consumer alone.
|
||||
|
||||
`delivery-v2.schema.json` is vendored without changes from
|
||||
`binaricat/netcatty-plugins@2ffed31ad6a430e3711a63a902305063bd2504f8`,
|
||||
`schema/delivery-v2.schema.json` (Git blob
|
||||
`7a57504fbea272ca0e873725e050d1f15352cb5b`). The v2 consumer retains the pinned v1
|
||||
schema and permits positive-size package references up to 16MiB and PNG/WebP avatar references.
|
||||
Avatar references require positive size up to 256KiB and exact content-addressed
|
||||
paths. No v3 path is added. The original bootstrap bytes remain unchanged, and
|
||||
client support does not publish or activate live metadata.
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Netcatty static delivery v2 (reviewed avatars and packages)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"registryCommit",
|
||||
"plugins",
|
||||
"catalogSha256",
|
||||
"indexSha256",
|
||||
"sourceLockSha256",
|
||||
"builderSha256"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"const": 2
|
||||
},
|
||||
"registryCommit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"maxItems": 1000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"repository",
|
||||
"commit",
|
||||
"readme",
|
||||
"iconLight",
|
||||
"iconDark",
|
||||
"avatar",
|
||||
"artifact"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"commit": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{40}$"
|
||||
},
|
||||
"readme": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
},
|
||||
"iconLight": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"iconDark": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(md|png|webp|ncpkg)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"avatar": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.(png|webp)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 262144
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"artifact": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"path",
|
||||
"sha256",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/assets/[a-f0-9]{64}\\.ncpkg$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 16777216
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalogSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"indexSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"sourceLockSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
},
|
||||
"builderSha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,7 @@ ajv.addSchema(require("./generated/plugin-contract.schema.json"));
|
||||
const validateIndex = ajv.compile(require("./distribution/index.schema.json"));
|
||||
const deliverySchema = require("./distribution/delivery.schema.json");
|
||||
const validateDelivery = ajv.compile(deliverySchema);
|
||||
const deliveryV2 = structuredClone(deliverySchema);
|
||||
deliveryV2.title = "Netcatty static delivery v2 (hash-bound packages)";
|
||||
deliveryV2.properties.schemaVersion.const = 2;
|
||||
const packageRef = structuredClone(deliveryV2.properties.plugins.items.properties.readme);
|
||||
packageRef.properties.path.pattern = "^/assets/[a-f0-9]{64}\\.ncpkg$";
|
||||
packageRef.properties.size.maximum = 32 * 1024 * 1024;
|
||||
deliveryV2.properties.plugins.items.properties.artifact = { oneOf: [{ type: "null" }, packageRef] };
|
||||
const validateDeliveryV2 = ajv.compile(deliveryV2);
|
||||
const validateDeliveryV2 = ajv.compile(require("./distribution/delivery-v2.schema.json"));
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
const tuple = (entry) => JSON.stringify([entry.id, entry.version, entry.repository, entry.commit]);
|
||||
|
||||
@@ -101,9 +94,10 @@ function validateSet(pointer, entries, index, delivery, origin = DISTRIBUTION_OR
|
||||
if (!catalogTuples.has(key) || resources.has(key)) throw new Error("Delivery identity does not match catalog tuple");
|
||||
assertAsset(plugin.readme, ["md"]);
|
||||
for (const icon of [plugin.iconLight, plugin.iconDark]) if (icon !== null) assertAsset(icon, ["png", "webp"]);
|
||||
if (plugin.avatar !== null) assertAsset(plugin.avatar, ["png", "webp"]);
|
||||
const entry = entries.find((item) => tuple(item) === key);
|
||||
if (plugin.artifact !== null) {
|
||||
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 32 * 1024 * 1024);
|
||||
assertReference(plugin.artifact, `/assets/${plugin.artifact.sha256}.ncpkg`, 16 * 1024 * 1024);
|
||||
if (!entry.artifact || entry.artifact.sha256 !== plugin.artifact.sha256 || entry.artifact.url !== `${origin}${plugin.artifact.path}`) throw new Error("Package delivery does not match catalog hash and source");
|
||||
} else if (entry.artifact) throw new Error("Catalog package has no delivery resource");
|
||||
resources.set(key, plugin);
|
||||
|
||||
@@ -478,7 +478,8 @@ function createPluginHostService(options) {
|
||||
themeService,
|
||||
interactionService,
|
||||
hostVaultService,
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net),
|
||||
resolveAvatar: (request) => contributionIconService.resolveCatalogAvatar(request) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
contributionService,
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
const { PluginSourceRegistry } = require("./sourceRegistry.cjs");
|
||||
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
|
||||
const { createSourceFetch } = require("./sourceFetch.cjs");
|
||||
const { isDeepStrictEqual } = require("node:util");
|
||||
|
||||
const installedIdentity = (plugin) => JSON.stringify([plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source, plugin?.manifest, plugin?.runtime?.quarantinedAt]);
|
||||
|
||||
class MarketSourcesService {
|
||||
constructor(options) {
|
||||
@@ -9,6 +12,15 @@ class MarketSourcesService {
|
||||
this.manager = options.manager;
|
||||
this.registry = new PluginSourceRegistry(options.database);
|
||||
this.catalogs = new Map();
|
||||
this.catalogIdentities = new Map();
|
||||
this.changeListeners = new Set();
|
||||
}
|
||||
onDidChange(listener) {
|
||||
this.changeListeners.add(listener);
|
||||
return { dispose: () => this.changeListeners.delete(listener) };
|
||||
}
|
||||
#changed(sourceId) {
|
||||
for (const listener of this.changeListeners) listener({ sourceId });
|
||||
}
|
||||
#catalog(sourceId = "official") {
|
||||
const source = this.registry.get(sourceId);
|
||||
@@ -22,21 +34,63 @@ class MarketSourcesService {
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
list(sourceId) { return this.#catalog(sourceId).list(); }
|
||||
async list(sourceId = "official") {
|
||||
const snapshot = await this.#catalog(sourceId).list();
|
||||
// Selection tokens bind the validated entry and its delivery resources.
|
||||
const identity = JSON.stringify(snapshot.entries.map((entry) => [entry.id, entry.selectionToken]));
|
||||
if (this.catalogIdentities.get(sourceId) !== identity) {
|
||||
this.catalogIdentities.set(sourceId, identity);
|
||||
this.#changed(sourceId);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
|
||||
async author(request) {
|
||||
if (!request || typeof request !== "object" || Array.isArray(request)
|
||||
|| typeof request.pluginId !== "string" || !/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(request.pluginId)) throw new TypeError("Invalid plugin author request");
|
||||
if (request.kind === "catalog") {
|
||||
if (Object.keys(request).some((key) => !["kind", "pluginId", "selectionToken", "sourceId"].includes(key))) throw new TypeError("Invalid plugin author request");
|
||||
return this.#catalog(request.sourceId).author(request.pluginId, request.selectionToken);
|
||||
}
|
||||
if (request.kind !== "installed" || Object.keys(request).some((key) => !["kind", "pluginId"].includes(key))) throw new TypeError("Invalid installed author request");
|
||||
const plugin = this.options.database.getActivePlugin(request.pluginId);
|
||||
if (!plugin?.activeVersion) return null;
|
||||
const captured = installedIdentity(plugin);
|
||||
let value = { author: plugin.manifest?.publisher ?? "", avatar: null, officialMaintained: false,
|
||||
sourceId: plugin.source.id, version: plugin.activeVersion, archiveSha256: plugin.archiveSha256 ?? null };
|
||||
if (plugin.runtime?.quarantinedAt == null) {
|
||||
try {
|
||||
// Presentation reuses an already validated catalog. It never refreshes
|
||||
// a source merely because the installed list becomes visible.
|
||||
this.registry.get(plugin.source.id);
|
||||
const catalog = this.catalogs.get(plugin.source.id);
|
||||
const entry = catalog?.publicEntries.find((item) => item.id === plugin.id && item.version === plugin.activeVersion
|
||||
&& item.artifact?.sha256 === plugin.archiveSha256 && isDeepStrictEqual(item.manifest, plugin.manifest));
|
||||
if (entry) value = await catalog.author(entry.id, entry.selectionToken);
|
||||
} catch { /* No exact current source match confers no maintenance badge. */ }
|
||||
}
|
||||
if (captured !== installedIdentity(this.options.database.getActivePlugin(request.pluginId))) throw new Error("Installed plugin changed while loading its author");
|
||||
return value;
|
||||
}
|
||||
install(id, token, sourceId = "official", acceptedSourceId) {
|
||||
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
|
||||
return this.#catalog(sourceId).install(id, token);
|
||||
}
|
||||
listSources() { return this.registry.list(); }
|
||||
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
|
||||
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
|
||||
async updateSource(payload) {
|
||||
const source = await this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled));
|
||||
this.#changed(source.id);
|
||||
return source;
|
||||
}
|
||||
async removeSource(payload) {
|
||||
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
|
||||
await this.manager.changeMarketSource(() => {
|
||||
this.registry.remove(payload.id);
|
||||
this.catalogs.delete(payload.id);
|
||||
this.catalogIdentities.delete(payload.id);
|
||||
}, payload.disablePlugins ? payload.id : undefined);
|
||||
this.#changed(payload.id);
|
||||
}
|
||||
}
|
||||
module.exports = { MarketSourcesService };
|
||||
@@ -41,7 +41,9 @@ const CHANNELS = Object.freeze({
|
||||
updateSource: "netcatty:plugins:update-source",
|
||||
removeSource: "netcatty:plugins:remove-source",
|
||||
catalog: "netcatty:plugins:catalog",
|
||||
catalogChanged: "netcatty:plugins:catalog-changed",
|
||||
catalogReadme: "netcatty:plugins:catalog-readme",
|
||||
author: "netcatty:plugins:author",
|
||||
catalogInstall: "netcatty:plugins:catalog-install",
|
||||
install: "netcatty:plugins:install",
|
||||
setEnabled: "netcatty:plugins:set-enabled",
|
||||
@@ -679,6 +681,10 @@ function registerPluginBridge(ipcMain, options) {
|
||||
handle(CHANNELS.updateSource, async (_manager, payload) => options.catalogService.updateSource(payload));
|
||||
handle(CHANNELS.removeSource, async (_manager, payload) => options.catalogService.removeSource(payload));
|
||||
handle(CHANNELS.catalog, async (_manager, payload) => options.catalogService?.list(payload?.sourceId) ?? { entries: [], source: "builtin", error: "Plugin catalog unavailable" });
|
||||
handle(CHANNELS.author, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.author(payload);
|
||||
});
|
||||
handle(CHANNELS.catalogReadme, async (_manager, payload) => {
|
||||
if (!options.catalogService) throw new Error("Plugin catalog unavailable");
|
||||
return options.catalogService.readme(payload?.id, payload?.selectionToken, payload?.sourceId);
|
||||
@@ -1703,6 +1709,7 @@ function registerPluginBridge(ipcMain, options) {
|
||||
publishScopeCatalog(event);
|
||||
return null;
|
||||
});
|
||||
options.catalogService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.catalogChanged, event));
|
||||
contributionService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.contributionsChanged, event));
|
||||
contributionService?.onDidPostViewMessage?.((event) => options.broadcast?.(CHANNELS.viewMessagePosted, event));
|
||||
viewHost?.onDidClose?.((event) => options.broadcast?.(CHANNELS.viewClosed, event));
|
||||
|
||||
@@ -101,6 +101,19 @@ test("plugin management bridge checks sender ownership before invoking manager",
|
||||
);
|
||||
});
|
||||
|
||||
test("author presentation is a trusted-app-only route and forwards no invented authority", async () => {
|
||||
const ipcMain = createIpcMain(); const calls = [];
|
||||
registerPluginBridge(ipcMain, { manager: { initialize: async () => {} }, isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: "http://localhost:5173" }),
|
||||
catalogService: { author: async (request) => { calls.push(request); return { author: "Netcatty", avatar: null, officialMaintained: false }; } } });
|
||||
const payload = { kind: "catalog", pluginId: "com.netcatty.starter", selectionToken: "a".repeat(64), sourceId: "official" };
|
||||
for (const url of ["netcatty-plugin://plugin-runtime/index.html", "https://attacker.invalid/"]) {
|
||||
await assert.rejects(ipcMain.handlers.get(CHANNELS.author)({ senderFrame: { url } }, payload), /Untrusted/);
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
const result = await ipcMain.handlers.get(CHANNELS.author)({ senderFrame: { url: "app://netcatty/index.html" } }, payload);
|
||||
assert.equal(result.officialMaintained, false); assert.deepEqual(calls, [payload]);
|
||||
});
|
||||
|
||||
test("plugin Vault credential catalog updates only through the trusted host bridge", async () => {
|
||||
const ipcMain = createIpcMain();
|
||||
const updates = [];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { PluginCatalogService, validateCatalog, MAX_CATALOG_BYTES } = require("./catalogService.cjs");
|
||||
const { REGISTRY_URL, fetchDistribution, fetchVerified, tuple } = require("./distributionSnapshot.cjs");
|
||||
const { isOfficialMaintained } = require("./sourceIdentity.cjs");
|
||||
const tokenFor = (entry) => createHash("sha256").update(JSON.stringify(entry)).digest("hex");
|
||||
|
||||
function prepareSnapshot(value, resources = new Map(), source) {
|
||||
@@ -19,6 +20,7 @@ class RegistryCatalogService extends PluginCatalogService {
|
||||
this.source = "builtin";
|
||||
this.error = null;
|
||||
this.refreshing = null;
|
||||
this.resolveAvatar = options.resolveAvatar;
|
||||
}
|
||||
async refresh() {
|
||||
if (this.refreshing) return this.refreshing;
|
||||
@@ -58,6 +60,22 @@ class RegistryCatalogService extends PluginCatalogService {
|
||||
return entry;
|
||||
}
|
||||
artifactSize(entry) { return this.resources.get(tuple(entry))?.artifact?.size; }
|
||||
async author(id, token) {
|
||||
this.sourceGuard();
|
||||
const entry = this.selected(id, token);
|
||||
const reference = this.resources.get(tuple(entry))?.avatar;
|
||||
let avatar = null;
|
||||
if (reference && this.resolveAvatar) {
|
||||
try {
|
||||
avatar = await this.resolveAvatar({ sourceId: this.marketSource.id, reference,
|
||||
read: () => fetchVerified(reference, this.fetch, this.origin) });
|
||||
} catch { /* Missing or invalid imagery uses the same author placeholder. */ }
|
||||
}
|
||||
this.sourceGuard();
|
||||
this.selected(id, token);
|
||||
return { author: entry.author, avatar, officialMaintained: isOfficialMaintained(entry, this.marketSource),
|
||||
sourceId: this.marketSource.id, version: entry.version, archiveSha256: entry.artifact?.sha256 ?? null };
|
||||
}
|
||||
async readme(id, token) {
|
||||
const entry = this.selected(id, token);
|
||||
const resource = this.resources.get(tuple(entry))?.readme;
|
||||
|
||||
@@ -6,6 +6,11 @@ const { isIP } = require("node:net");
|
||||
const OFFICIAL_ORIGIN = "https://plugins.netcatty.app";
|
||||
const OFFICIAL_SOURCE = Object.freeze({ id: "official", kind: "official", name: "Netcatty marketplace", origin: OFFICIAL_ORIGIN });
|
||||
const LOCAL_SOURCE = Object.freeze({ id: "local", kind: "local", name: "Local import", origin: null });
|
||||
// Curated first-party maintenance authority, independent of manifest publisher text.
|
||||
const FIRST_PARTY_PLUGINS = Object.freeze([
|
||||
Object.freeze({ id: "com.netcatty.starter", repository: "binaricat/Netcatty" }),
|
||||
Object.freeze({ id: "com.netcatty.hello", repository: "binaricat/Netcatty" }),
|
||||
]);
|
||||
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
||||
|
||||
// Source identity is host-owned. Neither a manifest nor a catalog supplies kind/id.
|
||||
@@ -41,4 +46,11 @@ function privateOwner(source, pluginId) {
|
||||
return `source:${hash(assertSource(source).id)}:${pluginId}`;
|
||||
}
|
||||
|
||||
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner };
|
||||
function isOfficialMaintained(entry, source) {
|
||||
const trusted = assertSource(source);
|
||||
return trusted.id === OFFICIAL_SOURCE.id && trusted.kind === OFFICIAL_SOURCE.kind
|
||||
&& trusted.origin === OFFICIAL_SOURCE.origin
|
||||
&& FIRST_PARTY_PLUGINS.some((pair) => pair.id === entry.id && pair.repository === entry.repository);
|
||||
}
|
||||
|
||||
module.exports = { OFFICIAL_SOURCE, LOCAL_SOURCE, canonicalSourceOrigin, thirdPartySource, assertSource, privateOwner, isOfficialMaintained };
|
||||
@@ -106,6 +106,12 @@ function createPreloadApi(ctx) {
|
||||
updatePluginSource: (source) => ipcRenderer.invoke("netcatty:plugins:update-source", source),
|
||||
removePluginSource: (id, disablePlugins) => ipcRenderer.invoke("netcatty:plugins:remove-source", { id, disablePlugins }),
|
||||
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
|
||||
getPluginAuthorPresentation: (request) => ipcRenderer.invoke("netcatty:plugins:author", request),
|
||||
onPluginCatalogChanged: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("netcatty:plugins:catalog-changed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:catalog-changed", listener);
|
||||
},
|
||||
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
|
||||
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
|
||||
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
|
||||
|
||||
@@ -4,6 +4,11 @@ A minimal browser plugin that uses Netcatty's selected configured model and one
|
||||
explicitly selected reference. It provides Send, streamed text, Stop, and usage.
|
||||
It has no tools, terminal commands, direct network access, Node runtime, or storage.
|
||||
|
||||
The page follows light/dark mode and accent updates through the existing
|
||||
`netcattyView.onDidChangeEnvironment()` API and seven public HSL theme tokens.
|
||||
Missing tokens fall back to the standard sample palettes. Its theme subscription
|
||||
is disposed on page hide and restored on page show.
|
||||
|
||||
An expired chat is reopened before the next explicit Send. If the configured
|
||||
model changes or a request fails, use **Refresh model** to reopen the session,
|
||||
then click **Send** yourself. Refresh keeps your draft and never retries model
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
|
||||
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
|
||||
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; }
|
||||
main { max-width:760px; padding:36px; margin:auto; }
|
||||
header { margin-bottom:26px; }
|
||||
.badge { color:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
|
||||
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
|
||||
p { color:#a9b4c7; margin:4px 0; }
|
||||
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
|
||||
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
|
||||
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
|
||||
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
|
||||
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
|
||||
#model { font-weight:600; }
|
||||
select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
|
||||
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
|
||||
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
|
||||
textarea { resize:vertical; min-height:84px; }
|
||||
form { margin:22px 0; }
|
||||
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
|
||||
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
|
||||
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
|
||||
button:disabled { opacity:.42; cursor:default; }
|
||||
#stop,#refresh,#choose-reference { background:transparent; border-color:#56657b; }
|
||||
#choose-reference { margin-top:10px; }
|
||||
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
|
||||
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
|
||||
.response { min-height:160px; }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
|
||||
.hint,footer { font-size:12px; color:#9aabc3; margin-top:8px; }
|
||||
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
|
||||
footer { padding-top:8px; }
|
||||
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
|
||||
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
|
||||
@@ -2,6 +2,31 @@
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
// Follow the same seven public semantic tokens as the other browser samples.
|
||||
// Only this page's own DOM is updated; no host settings or DOM access is used.
|
||||
(() => {
|
||||
const names = ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground'];
|
||||
let subscription;
|
||||
const subscribe = () => {
|
||||
if (subscription) return;
|
||||
subscription = window.netcattyView.onDidChangeEnvironment?.((environment) => {
|
||||
const root = document.documentElement;
|
||||
const theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme === 'system' ? 'light dark' : theme;
|
||||
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
|
||||
root.dataset.highContrast = String(Boolean(environment.highContrast));
|
||||
for (const name of names) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === 'string' && value.length > 0 && value.length <= 96) root.style.setProperty(name, value);
|
||||
else root.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
};
|
||||
subscribe();
|
||||
window.addEventListener('pagehide', () => { subscription?.dispose(); subscription = undefined; });
|
||||
window.addEventListener('pageshow', subscribe);
|
||||
})();
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
|
||||
@@ -11,9 +11,11 @@ Group results contain the chosen current members and never include future member
|
||||
label change for the first chosen host. Netcatty displays editable fields and the
|
||||
normalized field diff before either action saves.
|
||||
|
||||
`connect` asks Netcatty to confirm a saved-host connection. The confirmation shows
|
||||
whether saved scripts can run. The acknowledgement contains no session identifier
|
||||
or terminal data. Selecting metadata never authorizes a connection by itself.
|
||||
`connect` requests a saved-host connection under the plugin's permission settings.
|
||||
Ask displays the target and whether saved scripts can run; Allow can dispatch
|
||||
without that permission-only dialog, and Deny blocks the request. Normal SSH
|
||||
authentication still applies. The acknowledgement contains no session identifier
|
||||
or terminal data. Selecting metadata does not change those permission settings.
|
||||
|
||||
All sample test resources use synthetic hostnames. Native acceptance must use
|
||||
synthetic hosts and must not establish a real host connection.
|
||||
Vendored
+14
@@ -355,6 +355,18 @@ declare global {
|
||||
artifact?: { url: string; sha256: string };
|
||||
}
|
||||
|
||||
type NetcattyPluginAuthorRequest =
|
||||
| { kind: 'catalog'; pluginId: string; selectionToken: string; sourceId: string }
|
||||
| { kind: 'installed'; pluginId: string };
|
||||
interface NetcattyPluginAuthorPresentation {
|
||||
author: string;
|
||||
avatar: string | null;
|
||||
officialMaintained: boolean;
|
||||
sourceId: string;
|
||||
version: string;
|
||||
archiveSha256: string | null;
|
||||
}
|
||||
|
||||
type PluginPermissionMode = 'allow' | 'ask' | 'deny';
|
||||
type NetcattyPluginPermissionPrompt = import('@netcatty/plugin-contract').PermissionRequest & {
|
||||
actionToken: string; resources: string[];
|
||||
@@ -477,6 +489,8 @@ declare global {
|
||||
updatePluginSource?(source: { id: string; name: string; enabled: boolean }): Promise<NetcattyPluginSource>;
|
||||
removePluginSource?(id: string, disablePlugins: boolean): Promise<void>;
|
||||
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
|
||||
getPluginAuthorPresentation?(request: NetcattyPluginAuthorRequest): Promise<NetcattyPluginAuthorPresentation | null>;
|
||||
onPluginCatalogChanged?(callback: (event: { sourceId: string }) => void): () => void;
|
||||
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
|
||||
setPluginAIHostReady?(ready: boolean): Promise<boolean>;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_PROVIDERS } from '../../config/storageKeys';
|
||||
import type { ProviderConfig } from '../types';
|
||||
import { netcattyBridge } from '../../services/netcattyBridge';
|
||||
|
||||
// Host renderer only. Read the existing configured provider/model without
|
||||
// loading AgentRuntime or syncing/inheriting the Agent's permission policy.
|
||||
export function getConfiguredPluginModel() {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
return selectedModel(providers);
|
||||
}
|
||||
|
||||
function selectedModel(providers: ProviderConfig[]) {
|
||||
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
|
||||
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
|
||||
const provider = providers.find((item) => item.id === activeProviderId && item.enabled);
|
||||
@@ -14,3 +19,15 @@ export function getConfiguredPluginModel() {
|
||||
if (!config.defaultModel) return null;
|
||||
return { config, label: `${config.name} · ${config.defaultModel}`.slice(0, 160), revision: JSON.stringify(config) };
|
||||
}
|
||||
|
||||
// The existing main provider transport needs its host-owned registry for both
|
||||
// credentials and endpoint admission. Do not depend on mounting the AI sidebar,
|
||||
// or replace the registry with a plugin-supplied URL / just one selected entry.
|
||||
export async function prepareConfiguredPluginModel(revision: string) {
|
||||
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
|
||||
if (selectedModel(providers)?.revision !== revision) throw Error('Selected model changed');
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok) {
|
||||
throw Error('Host provider configuration is unavailable');
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const syntheticModel = { config: { id: 'synthetic', name: 'Synthetic', providerI
|
||||
|
||||
test('renderer-owned model handles never serialize provider config and cannot cross runtimes', async () => {
|
||||
let active = true;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, chooseSelected: async () => [], isActive: () => active });
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, chooseSelected: async () => [], isActive: () => active });
|
||||
const model = await runtime.handle('model.select', {}, 'runtime-a') as { hostHandle: string; label: string };
|
||||
assert.doesNotMatch(JSON.stringify(model), /synthetic-not-a-secret|apiKey|providerId|config/);
|
||||
await assert.rejects(runtime.handle('model.current', model, 'runtime-b'));
|
||||
@@ -36,7 +36,7 @@ test('selected metadata uses explicit safe projections and current resource iden
|
||||
|
||||
test('selected summaries never read content; late resource changes and runtime release deny content', async () => {
|
||||
let current = true; let reads = 0; let resolve: (text: string) => void;
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, isActive: () => true,
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => syntheticModel, prepareModel: async () => {}, isActive: () => true,
|
||||
chooseSelected: async () => [{ key: 'note', scope: 'note', title: 'Synthetic', version: '1', isCurrent: () => current,
|
||||
readText: () => { reads++; return new Promise<string>((r) => { resolve = r; }); } }] });
|
||||
const result = await runtime.handle('context.select', {}, 'runtime-a') as { items: Array<{ hostHandle: string; version: string }> };
|
||||
@@ -46,3 +46,22 @@ test('selected summaries never read content; late resource changes and runtime r
|
||||
await new Promise(setImmediate); current = false; resolve!('Late synthetic text'); await assert.rejects(reading);
|
||||
runtime.releaseRuntime('runtime-a'); await assert.rejects(runtime.handle('context.current', reference, 'runtime-a')); runtime.shutdown();
|
||||
});
|
||||
|
||||
test('provider preparation failures and late results cannot publish a model handle after release, cancellation or model change', async () => {
|
||||
for (const action of ['failure', 'release', 'cancel', 'change', 'shutdown']) {
|
||||
let resume!: () => void; let current = syntheticModel;
|
||||
const waiting = new Promise<void>((resolve) => { resume = resolve; });
|
||||
const controller = new AbortController();
|
||||
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => current, isActive: () => true, chooseSelected: async () => [],
|
||||
prepareModel: async () => { await waiting; if (action === 'failure') throw Error('Synthetic sync denied'); } });
|
||||
const selecting = runtime.handle('model.select', {}, 'runtime-a', controller.signal);
|
||||
const rejection = assert.rejects(selecting);
|
||||
if (action === 'release') runtime.releaseRuntime('runtime-a');
|
||||
if (action === 'cancel') controller.abort();
|
||||
if (action === 'change') current = { ...syntheticModel, revision: '2' };
|
||||
if (action === 'shutdown') runtime.shutdown();
|
||||
resume(); await rejection;
|
||||
await assert.rejects(runtime.handle('model.current', { hostHandle: 'not-published' }, 'runtime-a'));
|
||||
runtime.shutdown();
|
||||
}
|
||||
});
|
||||
@@ -17,10 +17,12 @@ type Owned<T> = T & { runtimeId: string };
|
||||
|
||||
export function createTrustedPluginAIHostRuntime(options: {
|
||||
getModel: () => { config: ProviderConfig; label: string; revision: string } | null;
|
||||
prepareModel: (revision: string) => Promise<void>;
|
||||
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
|
||||
isActive: () => boolean;
|
||||
}) {
|
||||
const models = new Map<string, Owned<{ model: Model }>>();
|
||||
const selections = new Set<{ runtimeId: string; active: boolean }>();
|
||||
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
|
||||
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
|
||||
let closed = false;
|
||||
@@ -37,6 +39,7 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
void turn.iterator.return(undefined).catch(() => {});
|
||||
};
|
||||
const releaseRuntime = (runtimeId: string) => {
|
||||
for (const selection of selections) if (selection.runtimeId === runtimeId) { selection.active = false; selections.delete(selection); }
|
||||
for (const [handle, turn] of turns) if (turn.runtimeId === runtimeId) cancel(handle);
|
||||
for (const [handle, model] of models) if (model.runtimeId === runtimeId) models.delete(handle);
|
||||
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
|
||||
@@ -50,12 +53,19 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
if (kind === 'model.select') {
|
||||
const selected = options.getModel();
|
||||
if (!selected || !selected.config.enabled || !selected.config.defaultModel) throw Error('Choose a configured model first');
|
||||
if (models.size >= 32) throw Error('Model handle limit reached');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
if (models.size + selections.size >= 32) throw Error('Model handle limit reached');
|
||||
const selection = { runtimeId, active: true };
|
||||
selections.add(selection);
|
||||
try {
|
||||
await options.prepareModel(selected.revision);
|
||||
signal.throwIfAborted(); assertActive();
|
||||
if (!selection.active || options.getModel()?.revision !== selected.revision) throw Error('Selected model changed');
|
||||
const handle = crypto.randomUUID();
|
||||
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
|
||||
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
|
||||
models.set(handle, { runtimeId, model });
|
||||
return { hostHandle: handle, label: selected.label };
|
||||
} finally { selections.delete(selection); }
|
||||
}
|
||||
if (kind === 'model.release') { owned(models, payload.hostHandle, runtimeId); models.delete(String(payload.hostHandle)); return { ok: true }; }
|
||||
if (kind === 'model.current') return { current: await owned(models, payload.hostHandle, runtimeId).model.isCurrent() };
|
||||
@@ -138,6 +148,6 @@ export function createTrustedPluginAIHostRuntime(options: {
|
||||
throw Error('Unsupported host operation');
|
||||
},
|
||||
releaseRuntime,
|
||||
shutdown() { closed = true; for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
shutdown() { closed = true; for (const selection of selections) selection.active = false; selections.clear(); for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -117,7 +117,8 @@ export interface PluginHostUI {
|
||||
}
|
||||
|
||||
/** Saved-host selection does not grant editing, connecting or session access.
|
||||
* Each action uses the host's separate review and confirmation UI. */
|
||||
* Later actions follow the plugin's permission settings. Host drafts retain
|
||||
* the host editor; connections retain normal authentication. */
|
||||
export interface PluginVault {
|
||||
selectHosts(params: import("@netcatty/plugin-contract").VaultSelectHostsParams): Promise<import("@netcatty/plugin-contract").VaultHostSelectionResult>;
|
||||
/** Managed SSH-config drafts require the full Vault host editor. */
|
||||
|
||||
@@ -9,12 +9,24 @@ async function fixture(t, options = {}) {
|
||||
const root = path.resolve(__dirname, "../examples/plugins/assistant");
|
||||
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
|
||||
t.after(() => dom.window.close());
|
||||
const stylesheet = dom.window.document.createElement("style");
|
||||
stylesheet.textContent = fs.readFileSync(path.join(root, "panel.css"), "utf8");
|
||||
dom.window.document.head.append(stylesheet);
|
||||
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
|
||||
const calls = [];
|
||||
const environmentListeners = new Set(); let environment;
|
||||
const element = (id) => dom.window.document.getElementById(id);
|
||||
dom.window.Date.now = options.clock ?? (() => now);
|
||||
Object.defineProperty(dom.window, "netcattyView", { value: {
|
||||
onDidReceiveMessage: () => () => {},
|
||||
onDidChangeEnvironment: (listener) => {
|
||||
environmentListeners.add(listener);
|
||||
if (environment) {
|
||||
const replay = environment;
|
||||
queueMicrotask(() => { if (environmentListeners.has(listener) && replay === environment) listener(replay); });
|
||||
}
|
||||
return { dispose: () => environmentListeners.delete(listener) };
|
||||
},
|
||||
executeCommand: async (id, params) => {
|
||||
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
|
||||
if (options.commands) return options.commands(operation, structuredClone(params));
|
||||
@@ -45,11 +57,43 @@ async function fixture(t, options = {}) {
|
||||
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
|
||||
await waitFor(() => element("status").textContent === "Ready");
|
||||
element("prompt").value = "Synthetic draft must survive session recovery";
|
||||
return { element, calls, waitFor, opens: () => opens, modelCalls: () => modelCalls,
|
||||
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
|
||||
emitEnvironment: (value) => { environment = value; for (const listener of environmentListeners) listener(value); },
|
||||
environmentListeners: () => environmentListeners.size,
|
||||
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
|
||||
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
|
||||
}
|
||||
|
||||
test("assistant follows host light/dark and accent updates with safe fallbacks and subscription cleanup", async (t) => {
|
||||
const f = await fixture(t); const root = f.window.document.documentElement;
|
||||
const computed = () => f.window.getComputedStyle(root);
|
||||
const calls = f.calls.length;
|
||||
assert.equal(computed().colorScheme, "light dark");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
assert.equal(f.environmentListeners(), 1);
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--background": "20 20% 98%", "--primary": "145 60% 35%", "--muted": "30 10% 90%", "--private-token": "ignored" } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.dataset.theme, "light");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "145 60% 35%");
|
||||
assert.equal(root.style.getPropertyValue("--private-token"), "");
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--background": "220 20% 10%", "--primary": "280 80% 75%" } });
|
||||
assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "280 80% 75%");
|
||||
assert.equal(root.style.getPropertyValue("--muted"), "");
|
||||
assert.equal(computed().getPropertyValue("--muted").trim(), "219 27% 21%");
|
||||
f.emitEnvironment({ theme: "light", themeTokens: { "--primary": "x".repeat(97) } });
|
||||
assert.equal(root.style.colorScheme, "light"); assert.equal(root.style.getPropertyValue("--primary"), "");
|
||||
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
|
||||
f.emitEnvironment({ theme: "system" }); assert.equal(root.style.colorScheme, "light dark");
|
||||
assert.equal(f.calls.length, calls); assert.equal(f.modelCalls(), 0);
|
||||
f.window.dispatchEvent(new f.window.Event("pagehide")); assert.equal(f.environmentListeners(), 0);
|
||||
f.emitEnvironment({ theme: "dark", themeTokens: { "--primary": "210 90% 70%" } });
|
||||
assert.equal(root.style.colorScheme, "light dark");
|
||||
f.window.dispatchEvent(new f.window.Event("pageshow"));
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(f.environmentListeners(), 1); assert.equal(root.style.colorScheme, "dark");
|
||||
assert.equal(root.style.getPropertyValue("--primary"), "210 90% 70%");
|
||||
});
|
||||
|
||||
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
|
||||
const f = await fixture(t); f.timeTo(300_001); f.send();
|
||||
await f.waitFor(() => !f.element("prompt").disabled);
|
||||
|
||||
Reference in new issue
Block a user