Compare commits

..
Author SHA1 Message Date
陈奇 37df67256e feat(examples): prepare fixed public Rfam acceptance candidate 2026-10-26 17:39:17 +00:00
陈奇 110448afb8 Merge late transport lifecycle and example checks into integration 2026-10-26 17:29:55 +00:00
陈大猫 f1410f8bb1 feat(plugins): validate installed stream example and integrate final source isolation 2026-10-27 01:19:32 +08:00
陈奇 6a4fb178cd docs(plugins): provide reproducible samples and native integration handoff 2026-10-26 17:04:42 +00:00
陈奇 e6f44460ac fix(plugins): normalize local peers and settle cancelled stream credit 2026-10-26 17:03:15 +00:00
陈奇 fffe5a9fca Merge connected plugin AI integration without reviewing its internals 2026-10-26 16:52:55 +00:00
陈奇 7189864c7c Merge source identity history into byte stream integration 2026-10-26 16:52:55 +00:00
陈奇 173ef5abbb docs(plugin-ai): retain red totals and record shared blocker repair 2026-10-26 16:25:16 +00:00
陈奇 0ba3ea6069 fix(plugin): describe scoped AI and stream permissions 2026-10-26 16:23:53 +00:00
陈奇 06dd46f94f docs(plugin-ai): record connected host QA and native handoff 2026-10-26 16:20:50 +00:00
陈奇 42a9826603 fix(plugin-ai): yield native modal views to host context selection 2026-10-26 16:15:14 +00:00
陈奇 da0c79ff0d fix(example-assistant): import URL in the package build tool 2026-10-26 16:03:01 +00:00
陈奇 a6e0cb8995 feat(plugin-ai): connect canonical trusted host IPC and app UI 2026-10-26 15:51:05 +00:00
陈奇 bb2fc729b8 Merge commit 'c4433d4961ecf4a81bea92b0a14f47b4ec12f679' into feat/plugin-ai-context 2026-10-26 15:35:13 +00:00
陈奇 c29fc95531 docs(plugin-ai): define read-only context authority boundary 2026-10-26 15:30:52 +00:00
陈奇 b0386a5769 test(plugin-ai): isolate the timeout fixture from normal reply tests 2026-10-26 15:29:04 +00:00
陈奇 dc45d03c7b test(plugin-ai): verify installed assistant and pending selection teardown 2026-10-26 15:21:35 +00:00
陈奇 9290debe91 fix(plugin-ai): cancel active resources when source or host guards fail 2026-10-26 15:09:15 +00:00
陈奇 6cf931ac4d feat(plugin-ai): implement trusted renderer selection and model lifecycle 2026-10-26 15:07:38 +00:00
99 changed files with 4353 additions and 3013 deletions

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ jobs:
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
npm run test:plugin-examples
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
-2
View File
@@ -13,7 +13,6 @@ import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -93,7 +92,6 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
<PluginHostActionHost />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
+2
View File
@@ -26,6 +26,7 @@ import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -856,6 +857,7 @@ function AppViewInner({ domains }: AppViewProps) {
sessions={sessions}
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
-15
View File
@@ -1380,20 +1380,5 @@ Highlight the focused split pane:
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
'plugins.hostActions.fieldDiff': 'Changes to save',
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
};
-15
View File
@@ -1380,20 +1380,5 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
};
-15
View File
@@ -1275,19 +1275,4 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
};
-15
View File
@@ -1295,20 +1295,5 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
'plugins.hostActions.fieldDiff': '即将保存的修改',
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
};
-15
View File
@@ -1298,19 +1298,4 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
'plugins.hostActions.fieldDiff': '即將儲存的修改',
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
};
@@ -1,128 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host, ManagedSource } from '../../domain/models';
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
function fixture() {
const resources = new PluginHostActionResources();
let active = true, authorized = 0, connections = 0, writes = 0;
let beforeRead: (() => void) | undefined;
let beforeCommit: (() => void) | undefined;
let duringAuthorize: (() => void) | undefined;
let storedSources: ManagedSource[] | undefined;
const vault: PluginHostActionVault = {
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
const nextSources = sources(storedSources ?? vault.managedSources);
beforeCommit?.();
await authorize?.();
writes++; vault.hosts = next;
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
},
};
resources.observe(vault.hosts);
const target = resources.metadata(synthetic.id);
const options = {
getVault: () => vault, resources,
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
};
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
storedSources(value: ManagedSource[]) { storedSources = value; },
};
}
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
});
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].password, synthetic.password);
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
assert.equal(f.vault.hosts[0].label, 'Reviewed');
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
});
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
const f = fixture(); f[point](() => f.cancel());
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
});
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
const f = fixture();
f.beforeRead(() => {
if (change === 'remove') f.vault.hosts = [];
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
if (change === 'scripts') f.vault.snippets = [];
if (change === 'locked') f.cancel();
});
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
});
test('create produces a saved SSH draft without executable or credential injection', async () => {
const f = fixture();
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
assert.equal(await executePluginHostAction(action, f.options), 'saved');
const created = f.vault.hosts[1];
assert.notEqual(created.id, synthetic.id);
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
});
const managedSources: ManagedSource[] = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
const f = fixture();
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
f.vault.readPersistedHosts = async () => [structuredClone(host)];
f.options.resources.observe(f.vault.hosts);
const target = f.options.resources.metadata(host.id);
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
});
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
const f = fixture(); f.vault.managedSources = managedSources;
const action: PluginHostAction = kind === 'create'
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
: { kind, target: f.target, draft: { group: 'B/Child' } };
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
const f = fixture(); f.vault.managedSources = managedSources;
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
assert.equal(f.vault.managedSources, managedSources);
});
test('a managed-source catalog replacement during preparation prevents the write', async () => {
const f = fixture();
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
const f = fixture(); f.storedSources(managedSources);
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
@@ -1,102 +0,0 @@
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
import type { PluginHostActionResources } from './pluginHostActionResources';
import type { AppVaultRuntime } from './appRuntimeBridge';
// Application action model. Public RPC types live only in plugin-contract.
export type PluginHostAction =
| { kind: 'create'; draft: PluginHostDraftFields }
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
| { kind: 'connect'; target: { resourceId: string; revision: string } };
export interface PluginHostActionVault {
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
isInitialized: boolean;
readPersistedHosts: () => Promise<Host[]>;
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
}
export interface PluginHostActionExecution {
getVault: () => PluginHostActionVault;
resources: PluginHostActionResources;
assertCurrent: () => void;
authorizeCommit: () => Promise<void>;
connect: (host: Host) => unknown;
}
const sameHost = (left: Host, right: Host) => (
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
);
/** Execute only a host-reviewed action. authorizeCommit claims separate,
* one-use main-process action authority after the final asynchronous work. */
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
const current = () => {
options.assertCurrent();
const vault = options.getVault();
if (!vault.isInitialized) throw new Error('Vault is unavailable');
options.resources.observe(vault.hosts);
return vault;
};
const initialVault = current();
const reviewedGroups = initialVault.groupConfigs;
const reviewedSnippets = initialVault.snippets;
const reviewedSources = initialVault.managedSources;
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
const assertSelected = () => {
const vault = current();
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
throw new Error('Selected host changed');
}
// A connection preview includes inherited and global automation. Any
// settings change while the preview is open requires fresh confirmation.
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
throw new Error('Saved connection settings changed');
}
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
return vault;
};
const persisted = await initialVault.readPersistedHosts();
assertSelected();
if (selected) {
const persistedHost = persisted.find((host) => host.id === selected.id);
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
}
if (action.kind === 'connect') {
await options.authorizeCommit();
assertSelected();
// The existing host connection handler owns authentication, saved scripts,
// and sessions. Its return value never reaches the sandboxed plugin.
options.connect(selected!);
return 'requested';
}
const nextHost = action.kind === 'create'
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
: applyPluginHostEdit(selected!, action.draft);
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
throw new Error('Managed SSH config changes require the Vault host editor');
}
const nextHosts = action.kind === 'create'
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
const keepSources = (sources: ManagedSource[]) => {
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
return sources;
};
const result = await initialVault.commitVaultImportTransaction(
nextHosts, updateGroups, keepSources,
undefined, persisted, undefined,
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
);
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
return 'saved';
}
@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host } from '../../domain/models';
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const metadata = resources.metadata(first.id);
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
resources.observe([first]);
assert.equal(resources.resolve(first.id, metadata.revision), first);
resources.observe([{ ...first }]);
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
const replaced = resources.metadata(first.id);
resources.observe([]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
resources.observe([first]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
});
test('group choices are explicit current members and never expand to later members', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const selection = [resources.metadata(first.id)];
resources.observe([first, { ...first, id: 'synthetic-2' }]);
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
assert.equal(resources.resolve(first.id, selection[0].revision), first);
});
@@ -1,34 +0,0 @@
import type { Host } from '../../domain/models';
/** Renderer-local revisions track replacement as well as visible edits. They
* are never authorization handles; only the main-process authority issues one. */
export class PluginHostActionResources {
private entries = new Map<string, { host: Host; revision: string }>();
observe(hosts: readonly Host[]) {
const next = new Map<string, { host: Host; revision: string }>();
for (const host of hosts) {
const existing = this.entries.get(host.id);
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
}
this.entries = next;
}
resolve(id: string, expectedRevision: string): Host {
const entry = this.entries.get(id);
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
return entry.host;
}
metadata(id: string) {
const entry = this.entries.get(id);
if (!entry) throw new Error('Selected host is unavailable');
const host = entry.host;
return {
resourceId: host.id, revision: entry.revision,
label: host.label, hostname: host.hostname, username: host.username,
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
group: host.group ?? '', tags: [...host.tags],
};
}
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -0,0 +1,21 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { getNotesSnapshot } from './notesStore';
import { useActiveTabId } from './activeTabStore';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { createTrustedPluginAIHostRuntime } from '../../infrastructure/ai/plugin/trustedHostRuntime';
import { getConfiguredPluginModel } from '../../infrastructure/ai/plugin/configuredModel';
import { buildSelectedPluginSources } from '../../infrastructure/ai/plugin/selectedSources';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const activeTabId = useActiveTabId();
const latest = useRef({ activeTabId, sessions, workspaces });
latest.current = { activeTabId, sessions, workspaces };
const [choice, setChoice] = useState<{ confirm: (id: string) => void; cancel: () => void } | null>(null);
const [noteId, setNoteId] = useState('');
const [noteChoices, setNoteChoices] = useState<Array<{ id: string; title: string }>>([]);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAIHostRequest || !api.setPluginAIHostReady || !api.respondPluginAIHost) return;
const bridge = {
onPluginAIHostRequest: api.onPluginAIHostRequest.bind(api),
setPluginAIHostReady: api.setPluginAIHostReady.bind(api),
respondPluginAIHost: api.respondPluginAIHost.bind(api),
};
let selectedNoteId: string | null = null;
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAIHostRuntime({
getModel: getConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
const finish = (id: string | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (id === null || signal.aborted) { reject(Error('Selection cancelled')); return; }
selectedNoteId = id || null;
resolve(buildSelectedPluginSources(() => ({ ...latest.current,
note: getNotesSnapshot().notes.find((note) => note.id === selectedNoteId) ?? null })));
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel };
signal.addEventListener('abort', cancel, { once: true });
setNoteId('');
// This inventory stays inside trusted host UI. The plugin receives
// summaries only for the Note chosen here and current selected scopes.
setNoteChoices(getNotesSnapshot().notes.map((note) => ({ id: note.id, title: note.title })));
setChoice({ confirm: (id) => finish(id), cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.onPluginAIHostRequest((request) => {
if (request.kind === 'request.cancel') {
requests.get(String(request.payload.requestId))?.controller.abort();
return;
}
if (request.kind === 'runtime.release') {
for (const pending of requests.values()) if (pending.runtimeId === request.runtimeId) pending.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId);
return;
}
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController();
requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request.kind, request.payload, request.runtimeId, controller.signal).then(
(result) => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, result }),
() => bridge.respondPluginAIHost({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const pending of requests.values()) pending.controller.abort(); };
window.addEventListener('pagehide', pageHide);
void bridge.setPluginAIHostReady(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
for (const pending of requests.values()) pending.controller.abort();
requests.clear(); runtime.shutdown();
void bridge.setPluginAIHostReady(false).catch(() => {});
};
}, []);
return { choice, noteId, setNoteId, noteChoices };
}
-168
View File
@@ -1,168 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppHandlers } from '../app/appHandlersBridge';
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
import { PluginHostActionResources } from './pluginHostActionResources';
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
type Review = {
prompt: VaultHostPrompt;
candidates: Host[];
candidateRevisions: Map<string, string>;
customGroups: string[];
host?: Host;
groups: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
scriptsRun: boolean;
claimed: boolean;
completionToken?: string;
};
export function usePluginHostActions() {
const [review, setReview] = useState<Review | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const current = useRef<Review | null>(null);
const resources = useRef(new PluginHostActionResources());
const responding = useRef(false);
const getVault = useCallback(() => {
const vault = getAppVaultRuntime();
const lock = getAppAppLockRuntime();
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
const actionState = vault.readCurrentHostActionState();
resources.current.observe(actionState.hosts);
return { ...vault, ...actionState };
}, []);
const assertCurrent = useCallback((entry: Review) => {
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
const vault = getVault();
const target = entry.prompt.vaultTarget;
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
throw new Error('Saved connection settings changed');
}
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
}, [getVault]);
const fail = useCallback(async (entry: Review) => {
if (current.current !== entry) return;
setError(true);
try {
await netcattyBridge.get()?.respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
});
} catch { /* A stopped runtime or closed window already invalidates it. */ }
}, []);
const execute = useCallback(async (entry: Review) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondPluginInteraction) return fail(entry);
setBusy(true);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const target = entry.prompt.vaultTarget;
let action: PluginHostAction;
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
} else throw new Error('Host action is invalid');
await executePluginHostAction(action, {
getVault, resources: resources.current,
assertCurrent: () => assertCurrent(entry),
authorizeCommit: async () => {
assertCurrent(entry);
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
entry.completionToken = receipt.completionToken;
entry.claimed = true;
assertCurrent(entry);
},
connect: (host) => {
const connect = getAppHandlers()?.handleConnectToHost;
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
return connect(host);
},
});
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
} catch { await fail(entry); }
}, [assertCurrent, fail, getVault]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.prompt.requestId !== event.requestId) return;
current.current = null; setReview(null); setBusy(false); responding.current = false;
return;
}
if (!event.request.kind.startsWith('vault.')) return;
const prompt = event as VaultHostPrompt;
try {
if (prompt.phase === 'commit') {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
setReview({ ...entry });
void execute(entry);
return;
}
const vault = getVault();
const candidates = [...vault.hosts];
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
const entry: Review = {
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
managedSources: vault.managedSources,
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
});
return () => {
const entry = current.current;
current.current = null;
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
responding.current = true; setBusy(true); setError(false);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
const revision = entry.candidateRevisions.get(id);
if (!revision) throw new Error('Host was not part of the current selection');
resources.current.resolve(id, revision);
return resources.current.metadata(id);
}) : undefined;
await netcattyBridge.require().respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
...(selected === undefined ? {} : { resources: selected }),
...(draft === undefined ? {} : { draft }),
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
});
} catch { await fail(entry); }
finally {
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
}
}, [assertCurrent, fail, selectedIds]);
return { review, selectedIds, setSelectedIds, busy, error, respond };
}
@@ -1,14 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<UiPrompt | null>(null);
const current = useRef<HostInteractionPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -18,8 +17,7 @@ export function usePluginHostInteractions() {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
current.current = event; setPrompt(event);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
@@ -26,6 +26,7 @@ import {
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -307,6 +308,17 @@ export function usePluginViewLifecycle({
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
-13
View File
@@ -776,13 +776,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}, [waitForPendingVaultWrites]);
// Host-owned action guards need the synchronous mutation snapshot, including
// edits that React has not published yet. This accessor never crosses RPC.
const readCurrentHostActionState = useCallback(() => ({
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
managedSources: managedSourcesRef.current,
}), []);
const updateKeys = useCallback((data: SSHKey[]) => {
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
@@ -1255,7 +1248,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
assertAuthorized?: () => void | Promise<void>,
): Promise<
| {
status: "persisted";
@@ -1330,10 +1322,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
encryptHosts(cleanedHosts),
encryptGroupConfigs(nextGroupConfigs),
]);
// Optional plugin draft guard: runtime/source/revoke/lock authority must
// still hold after encryption, immediately before the existing atomic
// persistence boundary. Ordinary imports retain their existing behavior.
if (assertAuthorized) await assertAuthorized();
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
@@ -2531,7 +2519,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
groupConfigs,
updateHosts,
readPersistedHosts,
readCurrentHostActionState,
updateKeys,
importOrReuseKey,
updateIdentities,
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('default host adapter, real preload, mounted host chooser and installed assistant reach only the local fixture', { timeout: 20_000 }, async () => {
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
// evidence. All state is synthetic, with no real provider, key, Note or host.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = fixtureRoot;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('localStorage', dom.window.localStorage);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginAIContextHost } = await import('./plugins/PluginAIContextHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { publishNotesSnapshot, getNotesSnapshot } = await import('../application/state/notesStore');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL } = await import('../infrastructure/config/storageKeys');
const { createAssistantProviderFixture } = await import('../scripts/plugin-assistant-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs');
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
let service: ReturnType<typeof createPluginHostService> | undefined;
let panel: JSDOM | undefined;
const root = createRoot(document.getElementById('root')!);
let locked = false; let contentAllowed = false; let reviewAllowed = false;
const lockListeners = new Set<(state: { locked: boolean }) => void>();
const reviews: Array<Record<string, unknown>> = [];
const trustedPayloads: unknown[] = [];
try {
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string');
const endpoint = `http://127.0.0.1:${address.port}/v1`;
const ipcHandlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false, send: (channel: string, payload: unknown) => {
if (channel === 'netcatty:plugins:ai-host-request') trustedPayloads.push(structuredClone(payload));
ipcRenderer.emit(channel, {}, structuredClone(payload));
} });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
const status = await aiBridge._streamRequestForTests(request.url, { headers: request.headers, body: request.body }, { sender }, request.requestId, false);
return { ok: status.statusCode === 200, ...status };
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
return handler({ sender }, structuredClone(payload));
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
Object.defineProperty(dom.window, 'netcatty', { value: api });
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' }]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
service = createPluginHostService({
app: { getAppPath: () => rootPath, getPath: () => fixtureRoot, getVersion: () => '0.0.0' }, env: {},
getInteractionWindow: () => mainWindow, isAppLocked: () => locked,
subscribeAppLock: (listener: (state: { locked: boolean }) => void) => { lockListeners.add(listener); return () => lockListeners.delete(listener); },
electron: { ipcMain: { handle: (name: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(name, handler), removeHandler: (name: string) => ipcHandlers.delete(name) },
dialog: { showMessageBox: async (_window: unknown, options: Record<string, unknown>) => { reviews.push(options); return { response: reviewAllowed ? 0 : 1 }; } } },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => request.permission === 'context.selected.read' && !contentAllowed
? { requestId: request.requestId, decision: 'deny' }
: { requestId: request.requestId, decision: 'allow', scope: request.permission === 'context.selected.read' ? 'once' : 'application' },
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => {
const deadline = Date.now() + 5000;
while (!condition()) {
if (Date.now() >= deadline) throw Error('Connected host UI state timed out');
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
} });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
await act(async () => { noteChoice.value = syntheticNote.id; noteChoice.dispatchEvent(new Event('change', { bubbles: true })); });
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Share selected summaries')!.click(); });
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent === 'Ready');
assert.match(element('model').textContent!, /QA fixture/);
const reference = element('context') as HTMLSelectElement;
assert.equal(reference.value, ''); assert.equal(reference.options.length, 2);
assert.notEqual(reference.options[1].value, syntheticNote.id); reference.value = reference.options[1].value;
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 1);
assert.equal(reviews[0].defaultId, reviews[0].cancelId); assert.match(String(reviews[0].detail), /Synthetic retry plan: add a retry button/);
reviewAllowed = true;
send(); await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
element('stop').dispatchEvent(new panel.window.Event('click'));
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
for (const listener of lockListeners) listener({ locked: true });
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|apiKey|privateOwner|permissionMode|mcp/);
} finally {
panel?.window.close();
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
dom.window.close();
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
@@ -1,290 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { mkdtemp, cp, rm } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { JSDOM } from 'jsdom';
import { sanitizeHost } from '../domain/host';
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
const require = createRequire(import.meta.url);
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLButtonElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
const runtime = await import('../application/state/appRuntimeBridge');
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
const listeners = new Set<(event: unknown) => void>();
const prompts: HostInteractionPrompt[] = [];
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
let locked = false;
let onLock: ((state: { locked: boolean }) => void) | undefined;
const service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
getInteractionWindow: () => owner, isAppLocked: () => locked,
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
});
let requestClock = Date.now();
service.interactionService.clock = () => requestClock;
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
} });
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
let hosts: Host[] = [first, second];
let persisted = hosts.map((host) => structuredClone(host));
let beforeCommit: (() => Promise<void>) | undefined;
let saves = 0;
const connections: Host[] = [];
const vault = {
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
const nextSources = sources(vault.managedSources);
await beforeCommit?.();
await authorize();
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
},
};
const publish = () => {
runtime.registerAppVaultRuntime(vault as never);
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
};
publish();
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const waitFor = async (predicate: () => boolean) => {
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
};
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
const begin = async (name: string) => {
requestClock += 60_001;
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
return { promise };
};
const chooseGroup = async () => {
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
};
try {
await service.manager.initialize();
const packageRoot = path.join(directory, 'source');
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
let selectedTarget: VaultHostRef;
await t.test('group selection shares only explicit current members and grants no connection', async () => {
const run = await begin('select');
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
persisted = hosts.map((host) => structuredClone(host));
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
const result = await run.promise;
assert.ok('hosts' in result);
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
assert.equal(result.hosts[0].target.scope, 'metadata');
selectedTarget = result.hosts[0].target;
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
assert.equal(connections.length, 0); assert.equal(saves, 0);
});
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
const transport = { signal: new AbortController().signal };
for (const target of [
{ handle: 'text-only', version: 'v1', scope: 'host' },
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
});
await t.test('cancel creates no draft or connection', async () => {
const run = await begin('create'); await click(button('Cancel'));
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
});
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
const run = await begin('create');
assert.match(document.body.textContent ?? '', /Changes to save/);
await click(button('Save Host'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
assert.equal(saves, 1);
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
});
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('edit'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
hosts = change === 'remove' ? [second] : [{ ...first }, second];
await click(button('Save Host'));
await assert.rejects(run.promise, /changed|cancelled|complete/);
assert.equal(saves, 1); assert.equal(connections.length, 0);
});
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('connect'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(connections.length, 1); assert.equal(connections[0], first);
});
await t.test('Vault lock cancels a pending connection', async () => {
const run = await begin('connect'); await chooseGroup();
await waitFor(() => button('Connect') !== undefined);
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
locked = false; publish();
});
await t.test('revoke during asynchronous preparation prevents the write', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
let release!: () => void; let preparing = false;
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
});
await t.test('source replacement during review cannot use the old action authority', async () => {
const run = await begin('create');
const original = service.database.getRetainedPluginSource;
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
finally { service.database.getRetainedPluginSource = original; }
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
vault.managedSources = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined);
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
assert.ok(group);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Save Host')); assert.equal(saves, 1);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
vault.managedSources = [];
});
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
const run = await begin('create');
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(saves, 1); vault.managedSources = [];
});
for (const transport of [
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
const dispatched: Host[] = [];
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
}), host) });
const run = await begin('connect');
try {
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
assert.equal(dispatched.length, 0);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(dispatched.length, 1);
assert.equal(logs[0].protocol, transport.expected);
} finally {
if (button('Cancel')) await click(button('Cancel'));
await run.promise.catch(() => {});
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
}
});
await t.test('runtime removal rejects old signed replies', async () => {
const run = await begin('create');
const old = prompts.at(-1)!;
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
await assert.rejects(run.promise);
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
await service.manager.shutdown();
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
dom.window.close(); await rm(directory, { recursive: true, force: true });
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -1,38 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
import type { Host } from '../domain/models';
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
const submitted: PluginHostDraftFields[] = [];
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
assert.equal(dom.document.querySelectorAll('input').length, 3);
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
const save = dom.document.querySelector('button');
assert.ok(save);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('invalid create draft cannot be confirmed', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
assert.ok(dom.document.querySelector('[role="alert"]'));
const save = dom.document.querySelector('button');
assert.ok(save?.disabled);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
} finally { await renderer.unmount(); dom.cleanup(); }
});
+1 -3
View File
@@ -50,7 +50,6 @@ export interface SelectHostPanelContentProps {
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
onSelectionChange?: (selectedHostIds: string[]) => void;
onConfirm: () => void;
confirmDisabled?: boolean;
onNewHost?: () => void;
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
@@ -80,7 +79,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSelect,
onSelectionChange,
onConfirm,
confirmDisabled = false,
onNewHost,
availableKeys = [],
identities = [],
@@ -645,7 +643,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={confirmDisabled || selectedHostIds.length === 0}
disabled={selectedHostIds.length === 0}
onClick={onConfirm}
>
{multiSelect
@@ -0,0 +1,22 @@
import React from 'react';
import type { TerminalSession, Workspace } from '../../domain/models';
import { usePluginAIContextHost } from '../../application/state/usePluginAIContextHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAIContextHost({ sessions, workspaces }: { sessions: TerminalSession[]; workspaces: Workspace[] }) {
const { choice, noteId, setNoteId, noteChoices } = usePluginAIContextHost({ sessions, workspaces });
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
<DialogContent data-host-security-surface="">
<DialogHeader><DialogTitle>Choose a reference for the plugin</DialogTitle>
<DialogDescription>Share a Note summary and the current selected workspace, tab, session, and host summaries. Reading content requires a separate permission and text review.</DialogDescription></DialogHeader>
<label className="text-sm" htmlFor="plugin-ai-note">Note</label>
<select id="plugin-ai-note" value={noteId} onChange={(event) => setNoteId(event.target.value)} className="rounded border border-border bg-background p-2 text-foreground">
<option value="">No Note</option>
{noteChoices.map((note) => <option key={note.id} value={note.id}>{note.title}</option>)}
</select>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(noteId)}>Share selected summaries</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -1,45 +0,0 @@
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { SelectHostPanelContent } from '../SelectHostPanelContent';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
export function PluginHostActionHost() {
const { t } = useI18n();
const state = usePluginHostActions();
const entry = state.review;
if (!entry) return null;
const request = entry.prompt.request;
const selection = request.kind === 'vault.selectHosts';
const connection = request.kind === 'vault.requestConnect';
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
</DialogHeader>
{selection ? <>
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
</> : connection && effective ? <>
<p className="break-all text-sm font-medium">{effective.label}</p>
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -1,58 +0,0 @@
import { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, ManagedSource } from '../../domain/models';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
/** Host-owned editable draft and exact normalized field diff. The containing
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
before?: Host;
draft: PluginHostDraftFields;
busy: boolean;
onConfirm: (draft: PluginHostDraftFields) => void;
managedSources?: ManagedSource[];
}) {
const { t } = useI18n();
const [fields, setFields] = useState<Record<string, string>>(() => ({
label: draft.label ?? before?.label ?? '',
hostname: draft.hostname ?? '', username: draft.username ?? '',
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
tags: (draft.tags ?? before?.tags ?? []).join(', '),
}));
const preview = useMemo(() => {
const value = {
label: fields.label, group: fields.group,
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
};
try {
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
return { value: {
label: after.label, group: after.group ?? '', tags: after.tags,
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
} catch { return null; }
}, [before, fields, managedSources]);
return <div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
<span>{t(`hostForm.field.${field}`)}</span>
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
</label>)}
</div>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
<dl className="space-y-2 text-xs">
{preview.diff.map((change) => <div key={change.field} className="break-all">
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
<dd>{JSON.stringify(change.before)} &rarr; {JSON.stringify(change.after)}</dd>
</div>)}
</dl>
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
</div>;
}
+130 -43
View File
@@ -2,23 +2,25 @@
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: isolated host internals and fixture-tested transport reuse. Public contract,
production adapters, installable example, independent review, and native acceptance
are pending. This source is not ready to merge or release.
Status: source-bound services, default trusted main/renderer adapters, app preload,
the AppView host chooser, canonical private IPC types, and an installable minimal
example are connected. Complete independent review and native acceptance remain
pending. This source is not ready to merge or release.
## Proposed public boundaries
## Public boundaries
The proposed optional features are `ai.model-invocation` and
`context.selected-read`. The parent has not confirmed these names. No canonical
schema, generated contract, SDK, runtime peer, hostService, or installer changes
are included in this checkpoint.
The parent approved `ai.model-invocation` and `context.selected-read`. The
transport task owns the public schema, SDK, and runtime peer. Its
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
routes and is merged into this branch. The parent assigned this task the additive
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
The model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
`read` pulls one chunk with one pending read per turn. The parent can map this
iterator onto the existing stream router after the canonical contract is agreed.
`read` pulls one chunk with one pending read per turn through the registered SDK
and runtime routes.
Context summary enumeration returns only references explicitly shared with the
current runtime and source owner. Content reads require an opaque handle and an
@@ -32,7 +34,10 @@ or arbitrary entity-ID lookup.
| --- | --- |
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
| This task | Additive trusted-host IPC types and generation, app preload methods/types, AppView host mount, AI-only hostService adapter construction/registration |
| This task | Host security-dialog visibility hook in the modal/tab plugin view lifecycle |
| This task, reassigned by parent | Four permission descriptions and the bounded `network.stream` contract test expectation |
| Transport task | Public canonical schema, generated SDK, runtime forwarding, generic stream registration |
| Market task | Source identity, private data-owner namespace, installer database, marketplace UI |
| Transport task | Generic TCP and TLS streams |
| Logging task | Optional local plugin logs |
@@ -44,6 +49,12 @@ This branch does not claim that workflow is complete.
## Implemented authority
`PluginAISourceBinding` authenticates the current browser runtime before reading
`getActivePlugin().source`, `getRetainedPluginSource`, `privateOwner`, and
`publicPluginId`. Source binding is rechecked after asynchronous work. The source
database and sync implementation are unchanged. Source origin does not imply
that a package is officially maintained.
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
@@ -67,7 +78,10 @@ trusted sharing UI must preview the selected text before sharing it.
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
`streamText`, through the existing host provider transport. Credential injection
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
SSE payloads have an aggregate 128 KiB cap before SDK buffering. SDK retries are
SSE payloads have an aggregate 128 KiB cap before SDK buffering. The main
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
total deadline. Host-owned request tags allow sender/runtime cancellation
without cancelling the internal Agent's ordinary streams. SDK retries are
disabled. Only text and allowlisted token totals reach the broker. Provider
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
@@ -85,25 +99,35 @@ a tokenizer or a monetary billing cap. Provider usage is reported separately.
Reservations are not refunded after cancellation. Rate limits survive runtime
restart within the broker's lifetime. Host shutdown clears this in-memory state.
## Required host adapters
## Connected host path
Production integration is blocked on these parent-owned contracts:
`createPluginHostService` constructs `PluginAITrustedHostAdapter` by default from
the existing interaction window, app-lock subscription, and native dialog. It
registers the trusted ready/response IPC handlers. App preload exposes the request
subscription and validated responses only to the trusted application renderer;
the sandbox view preload does not expose these methods.
1. `resolveDataOwner(context)` must return the current private source owner from
host provenance. A plugin-supplied source value or plugin ID is insufficient.
2. `selectModel(context)` must return a trusted invocation adapter for the model
explicitly selected in the main UI. No provider configuration crosses RPC.
3. The renderer adapter must validate the current window, selected resource,
lifecycle generation, version, and owner before and after asynchronous reads.
Reconnect, tab or session close, navigation, lock, disable, and permission
revocation must invalidate handles and abort turns.
4. Summary enumeration, content reads, and model invocation need separate
permissions. Only the canonical schema can define their wire format.
Model invocation permission does not grant context or action permissions.
5. Host review must show selected content before it becomes model input.
A command-palette or menu ID is never resource authority.
6. The shared hostService adapter must register teardown and permission-revoke
hooks, and discard results if its renderer owner changes.
`AppView` mounts `PluginAIContextHost`. Its hook chooses the currently configured
host model and opens an app-owned Note chooser. `trustedHostRuntime` keeps the
provider adapter and selected readers in renderer-local maps; only canonical,
validated request/results cross the main/renderer IPC boundary. The default
adapter authenticates the current main-window sender, uses one-use nonces,
rechecks the current owner after awaits, and invalidates on lock, navigation,
hide, destruction, or renderer replacement. Plugin permission revocation,
disable, and runtime release abort that runtime's turns and transport requests.
Summary enumeration, content reads, and model invocation require separate
permissions. Before selected text becomes model input, a native host dialog
shows that exact text and version with Cancel as the default. Model invocation
permission grants neither context nor action access. Menu IDs are never data
authority. While the app-owned chooser is present, the active native modal/tab
plugin view yields so it cannot cover the chooser. Existing embedded views use
their host-overlay occlusion observer. Native stacking still requires desktop QA.
The selected-context authority is read-only text. Its constructor and lifecycle
API are documented in [Selected context authority](selected-context-authority.md).
Host metadata handles cannot be promoted into edit/connect authority; those
actions require separate typed host targets, grants, and confirmations.
## QA evidence
@@ -134,19 +158,82 @@ node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
```
The final focused domain run passes 16 tests. The transport suite passes three
tests, including the actual assistant panel's Send, streaming, usage, and Stop
handlers in JSDOM. The test bridge uses structured clones to model the existing
view command boundary. It does not replace installed-plugin or native coverage.
The wider plugin runtime run passes 556 tests with one skipped. The existing
provider regression suites pass 34 tests.
The installed assistant command test passes on this branch's ordinary merge with
transport head `c4433d496`. It installs and
activates the six-file sample through the actual package manager and runtime peer,
then checks canonical SDK/RPC, independent model/content denial, text-review
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
The in-process browser harness is a Node test transport, not native sandbox proof.
The full repository typecheck fails in existing code, including provider tool-call
inference and plugin CLI test manifest literals. No new adapter type errors were
reported. The focused lint passes.
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
app preload factory, mounted host chooser, installed sample command/runtime peer,
existing provider factory, and existing main HTTP transport to the loopback
fixture. It checks content denial, review cancellation, streaming usage, Stop,
lock revocation, and absence of keys/configuration/policy on the trusted wire.
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
and teardown without restoring an old view. DOM tests do not establish native
WebContentsView stacking or Chromium sandbox behavior.
Native Electron launch aborts because the bundled `chrome-sandbox` is not owned
by root with mode 4755. No sandbox bypass was attempted. Native screenshots,
video, installed-plugin UI tests, packaged builds, and independent review are
pending on the parent's working desktop after shared integration. The panel
source alone is not evidence of native acceptance.
The trusted main/renderer suites cover nonce replay, sender substitution, owner
replacement, navigation, hide, runtime release, lock, cancellation, focus recovery,
safe field projection, and late selected-content results. The main transport test
also verifies targeted plugin cancellation leaves an ordinary synthetic Agent
stream running and aborts an oversized plugin stream.
Failed aggregate runs remain evidence, not passing acceptance records:
| Log in `/tmp` | Result |
| --- | --- |
| `netcatty-ai-qa-tests-full.log` | 14,912 tests: 14,783 passed, 85 failed, 1 cancelled, 43 skipped; a stuck FIDO child was terminated |
| `netcatty-ai-final-tests.log` | After standard dependency patches: 15,246 tests, 15,192 passed, 11 failed, 43 skipped |
| `netcatty-ai-final-runtime.log` | 610 tests: 607 passed, 1 failed, 2 skipped |
| `netcatty-ai-connected-final-tests.log` | Connected code `42a982660`: 15,248 tests, 15,204 passed, 1 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-connected-final-runtime.log` | Connected code: 610 tests, 607 passed, 1 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-runtime.log` | After `0ba3ea606`: 610 tests, 608 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-fixed-contract-suite.log` | After `0ba3ea606`: all 88 contract/SDK/CLI tests passed |
The ten Cursor failures in the second full run came from its missing SQLite
native dependency. Rebuilding the existing dependency with the standard
`node-gyp --devdir` option repaired them; the focused rerun passed 42 with 3
skipped. The sole connected full-run failure was a test expectation missing
`network.stream` after `network`. The runtime failure was permission-copy coverage
missing descriptions for `ai.invoke`, `context.selected.list`,
`context.selected.read`, and `network.stream`. The parent reassigned these two
hunks here. Commit `0ba3ea606988e95df256d449395adb7190b46fa0` adds accurate English
and Chinese descriptions and updates the resource-scope test. The test checks
both bounded optional stream declarations and rejection of the unbounded stream
shorthand. Transport can consume this isolated two-file commit.
The permission-copy test, complete 88-test plugin contract/SDK/CLI suite, and
610-test runtime aggregate now pass (with 2 existing runtime skips). The earlier
whole-repository red totals remain historical records; no passing whole-repository
rerun is claimed from these focused repairs. Exact-head CI is recorded on the PR.
The final connected UI/visibility run passes all 3 tests. The 47 focused
service/preload/runtime checks and 7 connected provider/renderer checks also pass.
`npm run check:plugin-contract` passes. The six-file package validates and retains
the SHA-256 recorded in the native recipe.
The connected AppView production build passes. Full lint after the permission
repair reports no errors and
seven existing warnings. The repository typecheck remains red with 773 diagnostics;
the new adapter, component, hook, and visibility modules have no diagnostics. Native packaging's
standard Electron rebuild stops at its hardcoded `/home/agent/.electron-gyp`
cache outside this executor's writable roots. No dependency patch or home-directory
override was used to bypass it.
The package build validates six files. The final artifact and native procedure
are recorded in [the native QA recipe](assistant-native-qa.md). The source staging
excludes build tooling and has no Node entry, network, filesystem, secret, storage,
or terminal permission.
Independent review confirmed the malformed-reference revocation defect on
`afe831bfc`. The two regression tests failed before the repair and pass afterward.
The repair is retained in #3985 head `4882e0501`. Further independent review was
blocked by the review tool/model restriction. That review is incomplete and has
not been retried through another route. CI is not a substitute for that review.
Native Electron launch on this executor aborts because `chrome-sandbox` is not
root-owned mode 4755. No sandbox bypass or native retry was attempted. Native
screenshots, video, and installed UI acceptance belong to the parent's working
cloud GUI machine on the integrated exact head. No native acceptance is claimed here.
+120
View File
@@ -0,0 +1,120 @@
# Test the selected Note assistant on an isolated desktop
This recipe tests a sandbox plugin against a deterministic loopback provider.
The provider does not run a model or contact a paid service. The profile contains
only synthetic data. The parent owns native screenshots and video on the working
cloud GUI machine. The local executor's sandbox-helper failure remains a separate
failed launch record.
## Prepare the integrated head
Use a head containing the market source API, #3985's malformed-context repair,
#3986, and the transport task's canonical AI/context registration. Record the exact
commit and artifact hashes. Keep all PRs Draft. Do not merge or release them.
Run the build and tests with the repository's supported Node version:
```sh
git rev-parse HEAD
npm run build
npm run build:plugin-runtime-deps
node examples/plugins/assistant/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/assistant/.package --out /tmp/assistant-example.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/assistant-example.ncpkg
sha256sum /tmp/assistant-example.ncpkg
```
The package contains six files: its manifest, README, three panel files, and one
browser runtime entry. It has no Node entry, networking, filesystem, secrets,
storage, terminal, or command-execution permission. Its `commands` permission
registers its own view callbacks. It cannot execute terminal commands.
The six-file artifact at this checkpoint has SHA-256
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
## Start the deterministic fixture
Start `node scripts/plugin-assistant-provider-fixture.mjs` from the repository.
The server prints its loopback URL. It listens only on `127.0.0.1`; use a fresh
unused port. Its response is fixed synthetic text, split into delayed SSE chunks.
The response includes fixed usage metadata. It accepts no request tools or system
messages. It keeps only request counts and prints no request content or headers.
Launch the exact integrated app on the working desktop with a fresh disposable
user-data directory. Preserve Chromium sandboxing. Do not use a real app profile,
cloud account, credential, host, Note, or chat history.
On that working desktop, use a separate shell for each long-running process:
```sh
node scripts/plugin-assistant-provider-fixture.mjs --port 9217
```
```sh
netcattyAssistantQaProfile=$(mktemp -d /tmp/netcatty-assistant-qa.XXXXXX)
env -u ELECTRON_RUN_AS_NODE node node_modules/electron/cli.js . --user-data-dir="$netcattyAssistantQaProfile"
```
The fixture's counts are available from `http://127.0.0.1:9217/fixture/status`.
Use a different unused loopback port if 9217 is occupied and update the provider
URL accordingly. These launch commands are for the working GUI machine; they do
not override this executor's failed sandbox-helper check.
In AI Settings, create a custom OpenAI-compatible provider with the fixture's
`/v1` URL, model `deterministic-fixture`, and the literal synthetic credential marker
`fixture-only`. This marker exercises the existing credential injection path
without a real API credential. Select that
provider and model through the host UI. Do not enable an external Agent or tools.
Create one Note named `Synthetic retry plan` containing:
```text
Synthetic retry plan: add a retry button and show a connection status label.
```
## Record the installed-plugin path
1. Import the package. Record its ID `com.netcatty.assistant`, version `0.1.0`,
source, SHA-256, browser runtime, required permissions, and optional permissions.
Installation must not grant optional access. Source labels do not imply that
an author or package is officially maintained.
2. Enable the plugin and open its `Selected Note Assistant` modal from the plugin
view catalog. Allow only its required view and command registration permissions.
3. Grant `ai.invoke` and `context.selected.list` when the host prompts. Record the
host-selected model label. The host's Note chooser must show app-owned chrome.
Verify the native plugin modal yields while this chooser is open and restores
after dismissal; the plugin must not cover the host's selection controls.
Select only `Synthetic retry plan` and click **Share selected summaries**.
4. Verify that the plugin reference dropdown starts at **No reference**. Select
`Synthetic retry plan` explicitly, then click **Send**.
5. Deny `context.selected.read`. No fixture request may be sent. The optional
model permission must not authorize Note access.
6. Retry. Allow the single selected-context read. The separate host text dialog
must show the exact synthetic text, scope, and version, with **Cancel** selected
by default. Cancel once and confirm that no fixture request is sent.
7. Retry and click **Share this text**. Record streamed text, completion, and token
usage. Capture the full host window, not only the plugin iframe.
8. Click **Send** again, share the same synthetic text, and click **Stop** while
the delayed fixture is streaming. The text must stop changing; the fixture's
aborted-connection counter must increase. Record this sequence as video.
9. Change the Note during a pending review and attempt to share the old dialog.
The expected-version check must reject the result. Do not send stale content.
10. Revoke the plugin's AI/context permission while a turn is active. Disable,
re-enable, and close its view. Old handles and late results must not resume.
Lock/unlock the app and navigate or reload the main window in the isolated
profile. Record a fresh successful run after reauthorization.
11. Keep the fixture request counter and aborted-connection counter in the
evidence. Do not include prompt bodies, headers, credentials, or Note content
other than the synthetic text above in logs.
The automated suites cover source substitution, runtime identity, two-handle
revocation after malformed input, late replies, byte/token/time bounds, and
no-key serialization. Native execution must still verify the real prompts,
installed runtime, configured provider transport, and UI lifecycle.
## Record remaining limits
Independent review found the malformed-context revocation bug. The regression
reproduced and the repair passed focused tests. Further independent review was
blocked by the review tool/model restriction. That review remains incomplete and
has not been retried through another route. CI and native evidence do not replace
that missing review.
@@ -0,0 +1,94 @@
# Byte stream acceptance evidence
This is the historical report published by the transport owner at #3988
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its local failures, package hash,
and missing AI wiring describe that branch and environment. The current owner
is #3993 on `integration/plugin-byte-stream-handoff`. Follow
[the integration handoff](byte-stream-native-qa.md) for current checks, the
connected AI checkpoint, and native prerequisites. The old GUI-local Docker
recipe is not applicable to the occupied GUI machine without Docker.
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
The transport delta does not edit the source database or sync implementation.
Draft PR #3988 records the published review head. No merge or release is authorized.
## Completed local checks
The delegated environment uses Node 24.19.0 on Linux.
- `npm run test:plugin-contract`: 90 passed, zero skipped.
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
Database-specific testing is now entirely in the example directory.
- Final focused transport, source-isolation, installed entrypoint and disposable
database run before the test-file move: 32 passed, zero skipped. After the
move, the same installed database test passed again at its example path.
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
real MariaDB result and server-side INSERT denial passed through both the
core and the ordinary installed example entrypoint. No host ports, fixture
override, real hosts, or real credentials were used by this database test.
Its container, network, package and profile were removed.
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
clearing before dispatch and safe text rendering passed.
- Production `npm run build`, generated contract drift, changed-source lint,
and whitespace checks passed.
Real broker TLS tests validate trust and the original requested hostname using
a disposable fixture CA. Database traffic is plain TCP inside an internal
Docker network. Neither check proves native BrowserWindow sandbox behavior,
database TLS, device testing, or real model inference. The registered AI/context
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
wiring owned by the AI task.
The EOF and last-await cancellation regressions each failed before their fix
and passed after it with real loopback sockets. Source changes before grant
publication, source changes during a pending read, and a stream returned after
runtime release have separate passing regressions.
## Reproducible native sample
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
Two separate staging directories produced the same five-file package:
- Archive bytes: 11,621.
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
The artifact contains no credentials or server setup. Changing its approved
fixture endpoint requires a newly prepared package and a recorded new hash.
## Checks that did not pass locally
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
Electron build caches under `/workspace` passed that step, then failed while
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
directory packaging remains failed locally and needs the normal CI or native
QA build environment.
The broad `npm test` run reported failures in existing transfer-resume,
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
tests, then stalled without a final aggregate. Its owned process tree was
terminated. It is not reported as a passing full test run.
An independent source-baseline worktree at `4213d8e` ran five affected files in
the same environment with shared dependencies: 145 tests, 118 passed,
24 failed, three skipped. It reproduced failures in transfer-resume,
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
This diagnoses that tested subset only. It does not classify every broad-suite
failure, replace exact-head CI, or make any failed result successful.
## Pending acceptance
Exact published-head CI and independent review remain pending. The parent owns
sequential native GUI acceptance for streams and the database example, then AI,
then logging integration. The native recipe covers ordinary install/source and
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
password, audit inspection, screenshots, and a short recording. No native GUI
run or screenshot/video result is claimed by this delegated executor.
+33 -15
View File
@@ -1,9 +1,16 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
No source database or sync implementation is changed by the transport delta.
The original transport checkpoint is #3988
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The separate integration branch
`integration/plugin-byte-stream-handoff` preserves that history and ordinarily
merges source identity #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and connected AI #3986 `173ef5abbb1f8cea4e2c409c5791186926b778e1`.
The original transport branch remains untouched. Follow the
[current native handoff](byte-stream-native-qa.md) for the integrated package.
The late transport checkpoint `f1410f8` is subsequently preserved through an
ordinary merge. [The comparison record](late-transport-integration.md) lists its
remaining changes and the conflict resolutions that preserve the two P2 fixes
and newer connected AI files.
## Registered transport
@@ -51,7 +58,7 @@ Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
implemented. Generic audit records contain endpoint, runtime, handle, and byte
counts. The host does not inspect SQL or record payloads or credentials.
## Additive AI interface for the parallel owner
## Integrated AI interface
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
@@ -59,13 +66,12 @@ and `context.selectedContext.list/read`. Host registration advertises
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
plugin disable, revocation through the service, and shutdown.
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
and optional lifecycle callbacks. These callbacks are never exposed to plugin
views. The default adapter rejects model invocation, returns no selected
resources, and refuses text review. The AI owner still owns real renderer
wiring and model/context UX. A registered synthetic adapter test is not proof
of that remaining production wiring.
The connected host renderer and shared permission descriptions from #3986 are
preserved. Its owner documents the adapter and existing configured model path in
[the AI checkpoint](ai-context-wip.md), with a
[synthetic native assistant recipe](assistant-native-qa.md). No further
independent review of AI internals was performed. The prior review denial remains
unresolved and has not been retried through another route.
## Database example
@@ -106,6 +112,18 @@ connection-management subsystem. See the example README for native setup.
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
Normal native BrowserWindow, full app/package checks, exact-head independent
review, and screenshot/video acceptance remain separate required evidence.
Do not disable the Chromium sandbox to obtain that evidence.
The integration fixes own-machine comparison across IPv4 and mapped IPv6.
Its regression mocks interface lookup and uses a fake dialer. It does not retry
the reviewer's failed EPERM interface lookup. Other private peers remain allowed.
Cancellation now discards buffered browser bytes, settles producer-cancelled
credit once, and retains terminal producer state only until outstanding credit
arrives. Existing sequence, schema, and negotiated-window checks still reject
unknown, duplicate, out-of-order, and excess credit. Incoming cleanup runs even
if a cancellation refund cannot be sent. This uses the existing stream lifecycle
and runtime shutdown, without another scheduler.
Normal native BrowserWindow and screenshot/video acceptance remain pending.
The native GUI machine lacks Docker and is occupied with SSH acceptance. See
the handoff for fixture prerequisites. Do not disable the Chromium sandbox or
relax endpoint restrictions to obtain that evidence.
@@ -0,0 +1,176 @@
# Test integrated byte streams on the native desktop
Use `integration/plugin-byte-stream-handoff` from its integration PR. Record the
exact commit with `git rev-parse HEAD`. The branch preserves transport #3988
(`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`), AI integration #3986
(`173ef5abbb1f8cea4e2c409c5791186926b778e1`), and source identity #3981
(`4213d8e6726c44e7a479418ece3adf16ea494782`) through ordinary merges.
## Prepare the desktop and fixture
Wait until the approved native GUI machine is free from SSH acceptance work.
Use a fresh disposable app profile, a working display, and the platform's normal
Electron sandbox. Keep the existing endpoint policy and system security settings.
The database pass requires a disposable MariaDB fixture at a reachable private
IPv4 address. The address must not belong to the GUI machine. The current native
GUI machine lacks Docker, so fixture provisioning is a prerequisite. Use an
already approved separate Docker host or disposable database machine with an
existing permitted network path. Do not install new software or create local
forwarding exceptions as part of this recipe. If no fixture is available, record
the database pass as pending.
There is also a separately labeled
[public Rfam candidate](../../examples/plugins/database-rfam-readonly/README.md).
It fixes one public hostname, public account, database, and `SELECT 1 AS value`.
It has only been prepared and checked offline. The parent must obtain exact
package and endpoint authorization before any install, permission activation,
or connection. Its authentication and TLS requirements remain unverified. This
candidate does not replace the owned fixture regression or prove that the GUI
can reach the coding container's private database.
The registered automated fixture uses
`mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873`.
It runs on an internal Docker network, publishes no host ports, generates a
disposable password, and removes its container and network. On a prepared Linux
executor with that recognized image, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
That test exercises the protocol, broker, and installed package's SDK/RPC path
in a Node harness. It does not prove native GUI behavior. For the GUI fixture,
have its operator create only
synthetic data and a dedicated SELECT-only account. Run this SQL using the
fixture's administration channel, replacing the password placeholder with a
fresh disposable password that is never included in screenshots or logs:
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<fresh-disposable-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
Use protocol 4.1 with `mysql_native_password`. The example uses plain TCP only
within the approved isolated fixture network. Verify INSERT denial independently
through the fixture's client. The plugin intentionally offers only its fixed read.
## Build and identify the packages
Use Node 22 or later and the lockfile. Run these commands in sequence:
```sh
npm ci
npm run test:plugin-runtime
npm run test:plugin-contract
npm run test:plugin-examples
npm run generate:plugin-contract
npm run check:plugin-contract
npm run generate:capability-tools
npm run check:codex-app-server-schema
npm run lint
npm run build
git diff --exit-code
```
For the reproducible packaging-only example, use the fixed synthetic endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example-repeat.ncpkg
cmp /tmp/database-example.ncpkg /tmp/database-example-repeat.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
sha256sum /tmp/database-example.ncpkg
```
The two five-file packages built on Node 24.19.0 with the locked dependencies
match byte for byte. The current owned-fixture package SHA-256 is
`588cb6766616c2f3057c7c8df1c20dbac947e0d5e9d01b2a928c7581b00a69c5`.
The earlier `6a4fb178` package used hash `88c14283…dbc1af4`. The example-only
protocol now has separate fixed owned-fixture and Rfam entrypoints; the owned
fixture database, query, and opt-in live regression are retained.
The six-file assistant package retains SHA-256
`8ef162d13d0a16878fb817462759c9221352d0565dcefebeb102ea3575a918e8`.
The preparation tool removes temporary build paths from its minified browser
bundle. Rebuild with the actual fixture endpoint before native testing. Validate
and record that package's new hash. Preparation does not dial the endpoint.
Build the assistant and follow its existing
[native QA recipe](assistant-native-qa.md). Use its deterministic local provider,
synthetic credential marker, and synthetic Note. No paid model call is required.
The AI internals were integrated without another independent review. The earlier
review denial remains in effect and is not replaced by these tests.
## Record the installed browser path
1. Launch the integrated app on the approved desktop with a new disposable
`--user-data-dir`. Capture the full app window and record the exact commit.
2. In Settings > Plugins, import the database package. Capture its ID, version,
package hash, source identity, browser entry, and exact endpoint permission.
The import must leave the plugin disabled and optional access ungranted.
3. Enable it and open **Disposable database example**. Deny the first endpoint
request. Capture the failure and verify the runtime remains usable.
4. Retry with only the disposable fixture credentials and allow the exact
endpoint. Capture the one-row result and the source-aware permission prompt.
The password field must clear when the request starts.
5. Record a video of a delayed fixture operation followed by **Cancel**. Use a
delay offered by the fixture operator. Do not add a host protocol scheduler
or change the query. Confirm the request stops and a fresh read still works.
6. Revoke `network.stream` while a connection is active. Confirm the panel reports
failure and a new request prompts again. Disable, re-enable, close the view,
and restart the app. No old connection or result may resume.
7. Record the assistant's denial, exact-text review, streamed result, usage,
Stop, and post-revocation recovery through its linked recipe. Use the same
integrated head and preserve its separate permission descriptions.
8. Remove only the synthetic plugins and profile. Have the fixture operator
remove the disposable account, data, container, and network. Stop the synthetic
model provider.
## Record the remaining evidence
Save the exact head, app platform/version, package hashes, ordered commands and
exit codes, screenshots, and video paths. Distinguish a skipped Docker test,
synthetic Node runtime integration, and actual native sandbox acceptance.
The address regression uses a mocked `os.networkInterfaces()` result and a fake
dialer. It never retries the reviewer's EPERM interface lookup. The cancellation
regression uses the registered package, host service, SDK, RPC, and real loopback
echo fixture under the existing test-only override. This override is unavailable
through the production service constructor. Neither test certifies the native
BrowserWindow sandbox or a live database pass on the GUI machine.
The integration executor completed these checks in order on 2026-10-26:
| Check | Result |
| --- | --- |
| `npm run test:plugin-runtime` | 627 tests: 626 passed, 1 native skip, 0 failed |
| `npm run test:plugin-contract` | 90 passed, 0 skipped, 0 failed |
| `npm run test:plugin-examples` | 4 passed, 1 opt-in Docker skip, 0 failed |
| Plugin contract generate and drift check | Passed, no generated changes |
| Capability tool generation and drift check | Passed, no generated changes |
| Codex App Server schema check | Passed |
| `npm run lint` | Passed, 0 errors and 7 warnings outside the transport changes |
| `npm run build` | Passed, Vite production build completed in 40.23 seconds |
| Database repeat packaging and both package validations | Passed |
The late transport merge moves the opt-in database test into the example directory.
Run its panel and protocol tests explicitly with
`node --test examples/plugins/database-minimal/*.test.cjs`. The database test
still requires explicit opt-in and the native Electron suite remains separate.
This executor has Docker, but the pinned MariaDB image
is not cached. No image was installed and no new live database run is claimed.
Native GUI acceptance remains pending on the occupied desktop. Full repository
tests and platform packages run in the integration PR's exact-head CI. Record
their final workflow conclusions separately from these local results.
After the late transport merge and Rfam candidate preparation, the registered
runtime suite has 626 passed and one native skip, contract/SDK/CLI has 90 passed,
and `npm run test:plugin-examples` has four passed and one Docker skip. The
example suite is now registered in CI. Public protocol checks use only synthetic
packet bytes and package preparation; no Rfam connection or permission was
activated. Final CI belongs to the current #3993 head, not the older `6a4fb178`
head whose two workflows already succeeded.
@@ -1,125 +0,0 @@
# Host and Vault actions
Feature: `vault.host-actions`. The SDK registers three methods under
`context.vault`. Netcatty owns the picker, field-diff review, and connection
confirmation. Plugin business logic stays in the offline browser sandbox.
Base: source integration #3981 at
`4213d8e6726c44e7a479418ece3adf16ea494782`.
The published stream/AI branch point `c4433d496` is preserved through an ordinary
merge. Its owner was still completing integration checks when it was published.
## Reusable boundaries
| Concern | Existing contract | Reuse |
| --- | --- | --- |
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
## Public methods
| Method | Parameters | Result |
| --- | --- | --- |
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
Selected metadata contains label, hostname, username, optional port, protocol,
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
remain behind the typed target reference; its revision is the expected revision.
Group metadata describes the explicitly selected current member handles. The
grant never expands when another host joins a group.
Selection grants metadata access only. Each draft save and each connection
requires its own host-owned confirmation. A connection confirmation displays the
saved target and whether saved scripts can run. It does not grant terminal input,
terminal output, or session access.
All requests are bounded and cancel on runtime stop, source replacement, revoke,
Vault lock, owner window replacement, expiry, or user cancellation. The host
revalidates authority and expected revision after asynchronous work and before
the Vault write or connection dispatch.
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
existing 128-character single-line limit. Draft field lengths use
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
preserve connection endpoints and credentials. Changing a group can change
inherited authentication, proxies, and scripts; the draft review states this.
Drafts involving managed SSH-config hosts or SSH destinations inside managed
groups are explicitly unavailable in this small API. The review disables saving
and directs the user to the Vault host editor. The execution adapter enforces the
same restriction before claiming action authority, using the existing
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
managed-source catalog for changes before writing. This API does not initiate
managed-file sync or expose plugin file access.
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
execution. Host selection and action UI live in Netcatty. Plugin business logic
stays in the sandboxed plugin.
## Typed target and action authority
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
It does not authorize editing, connecting, terminal input, or terminal output.
References bind source, private owner, plugin version, runtime, window owner and
generation, exact host ID, revision, and expiry. They live for five minutes at
most, with at most 500 retained references. Another selection replaces that
runtime's previous references. Lock, revoke, window replacement, and runtime stop
invalidate the affected references. Removal or replacement of a host makes its
renderer revision stale, even if its visible fields are identical.
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
It revalidates after asynchronous authentication. AI selected-context handles are
text-disclosure handles and cannot target these actions.
The permission engine gates selection with `vault.metadata`, drafts with
`vault.write`, and connection requests with `ui.interact`. Every save or connection
also requires a separate host-owned confirmation and a signed one-use action
claim. A stored permission grant cannot bypass this review.
After review, the renderer prepares the existing transaction or connection
request. It claims action authority after the final asynchronous preparation,
immediately before persistence or dispatch. This claim is the action's commit
point. Completion only acknowledges the committed action. Automation changes
during connection review require fresh confirmation.
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
the launcher, including Mosh/ET flags and implicit localhost shells. Local
execution displays an explicit on-device shell notice and omits the remote SSH
username and port.
## Sample and validation
`examples/plugins/host-actions` adds four commands through the existing command
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
filesystem, terminal-content, or credential permissions.
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
production React dialog, and renderer execution adapter. It covers explicit
selection, cancel, stale revisions, locked Vault, host removal and replacement,
revoke, source replacement, runtime removal, and connection confirmation. Registry
checks reject text handles, tampered references, caller owners, and mismatched
expected revisions. Only synthetic resources are used. The Node transport fixture
does not certify the operating-system sandbox.
Focused action tests compare Mosh, ET, and localhost confirmations with the real
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
unmanaged saves, and catalog changes use synthetic source records; no SSH config
files are read or written.
Native screenshots and video run on the parent's existing cloud GUI. This branch
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
and credentials. No real host connection or credential access is authorized.
@@ -0,0 +1,70 @@
# Reconcile the late transport checkpoint
The integration owner is #3993, branch
`integration/plugin-byte-stream-handoff`. Its independently rechecked head
`6a4fb178cde73b57f78b1711a4e1f9a0f394b4fb` passed both
[test CI](https://github.com/binaricat/Netcatty/actions/runs/40365457418) and
[four-platform package CI](https://github.com/binaricat/Netcatty/actions/runs/40365457024).
The original transport owner later published #3988 at
`f1410f8bb172ef2c2069cc7d55a463f7cb127c0b`. Its common baseline with #3993 is
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679`. The late commit has that baseline
and source checkpoint `4213d8e6726c44e7a479418ece3adf16ea494782` as parents.
The source checkpoint is already an ancestor of #3993.
## Keep the remaining transport changes
The late delta is not entirely superseded. An ordinary merge retains its
published history and these useful changes:
- Attach cleanup cancels a producer returned after runtime release exactly
once. The new regression fails against the old #3993 service and passes
with the late service change. The test uses a loopback fixture and a fake
stream producer. It does not enumerate real interfaces.
- Service tests cover source replacement before permission publication and
during a pending read. A router test checks one close notification for idle
peer cancellation. The installed entrypoint also checks consumer cancellation.
- CLI and SDK tests cover the public canonical byte-stream requests. Shared
manifest checks require the corresponding AI/context feature declarations.
No AI runtime, broker, adapter, or renderer implementation is reviewed here.
- Database-specific testing moves out of `electron/plugins` into the minimal
example. The opt-in test also installs its real package and checks the
SDK/RPC path and audit omission. Its live pass at `f1410f8` remains historical
evidence, not a new live pass on the current integration head.
- The example panel test checks password clearing and text-only result output.
## Resolve overlap in favor of the integrated behavior
| Overlap | Resolution |
| --- | --- |
| Marketplace source files and lockfile | Already identical to the late checkpoint |
| Own-machine address comparison | Retain #3993's IPv4 and mapped IPv6 normalization and regression |
| Cancelled buffered and in-flight credit | Retain #3993's stream lifecycle fix and regressions |
| Service test file | Combine the normalization test with all three late lifecycle/source tests |
| Installed entrypoint test | Keep retained-byte revocation coverage and add the late consumer-cancel case |
| Shared permission descriptions | Retain the newer connected AI checkpoint's descriptions |
| Assistant preparation | Retain the newer six-file staging build |
| Contract test | Retain the repaired optional-stream expectation and positive exact-resource assertion |
| Database preparation | Both sides minify the bundle. Keep the existing deterministic preparation |
| Native instructions | Keep the GUI's actual prerequisites. Its missing Docker and unsupported path to the coding container are not resolved by the late branch's local Docker recipe |
The original reports are retained in
[the historical acceptance record](byte-stream-acceptance.md). Neither their old
AI wiring status nor their environment-specific broad-test failures replace the
connected implementation and exact-head CI evidence on #3993. No main merge,
force-push, endpoint-policy relaxation, AI internal review retry, or release is
part of this reconciliation.
## Check the reconciled source
The merged transport source passed 627 registered runtime tests: 626 passed and
one native Electron test skipped. Contract/SDK/CLI tests passed 90 of 90. The
example panel passed; its opt-in database test remained skipped. The late attach
regression failed against the old #3993 service with two cancel calls instead
of one, then passed after the merge.
A subsequent example-only Rfam candidate reuses the limited browser protocol
through two fixed entrypoints. Its offline tests check the original owned
fixture database/query and the fixed public database/query, plus rejection of
an unsupported auth plugin. No general query API is exported and no host
database code is introduced. The owned MariaDB live regression remains opt-in.
@@ -0,0 +1,44 @@
# Selected-context authority reference
`electron/plugins/context/selectedContextAuthority.cjs` implements text disclosure.
It does not issue typed Host targets or authorize edit, connect, or command actions.
The constructor is `new SelectedContextAuthority({ resolveDataOwner, clock?,
limits? })`. `resolveDataOwner(context)` returns the authenticated host-owned source
binding. Runtime identity comes from the host RPC context, never plugin arguments.
| Method | Meaning |
| --- | --- |
| `share({scope,title,version,readText,isCurrent}, context)` | Trusted host issues an opaque, expiring text handle. Returns a summary. |
| `summaries(context)` | Enumerates only this source/runtime's shared summaries. Does not call `readText`. |
| `read({handle,version}, context)` | Checks identity, expected version, current owner, lifetime, and resource currency before and after the bounded read. |
| `assertCurrent({handle,version}, context)` | Checks currency without reading content. |
| `onDidInvalidate(listener)` | Observes handle revocation. One failed listener cannot interrupt cleanup. |
| `release(handle)` | Revokes one text handle. |
| `releaseRuntime(runtimeId)` | Revokes that runtime's text handles. |
| `releaseOwner(owner)` | Revokes that exact owner binding's text handles. |
| `shutdown()` | Revokes all handles. |
The summary contains `handle`, `scope`, `title`, `version`, and `expiresAt`.
`scope: "host"` means curated host-related text. It is not a Host entity reference.
A summary, menu ID, title, or host-related text cannot become an action target.
`PluginAISourceBinding` in `electron/plugins/ai/sourceBinding.cjs` consumes the
market owner's source API. `resolve(context)` first authenticates the current
browser runtime and then returns a stable frozen object containing `runtimeId`,
`pluginId`, `pluginVersion`, `sourceKey`, and `privateOwner`. It revalidates source
binding after asynchronous authentication. The object never crosses plugin RPC.
`sourceKey` binds the source ID, kind, and origin. It is not a trust endorsement.
This helper supplies neither resource selection nor action authorization.
Resource currency remains a trusted `isCurrent()` callback. The callback binds
the selected resource, expected revision, current host window, and its lifecycle.
The authority stores the issued version separately and never accepts a later
plugin-supplied revision as authoritative.
The Host/Vault action task requires a separate typed selection authority. Such a
target binds the host-owned source identity, runtime, plugin version, window owner
and generation, exact Host ID, expected Host revision, expiry, and allowed action.
Each action needs its own grant and host confirmation. Text-read handles must not
implicitly gain edit or connect authority. Host draft review and connection
confirmation remain separate workflows owned by that task.
-40
View File
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostFieldDiff, pluginHostRunsSavedScripts } from './pluginHostActions';
import type { Host, Snippet } from './models';
const host: Host = { id: 'synthetic-host', label: 'Synthetic', hostname: 'host.invalid', username: 'test', os: 'linux', tags: [], password: 'synthetic-secret', startupCommand: 'synthetic-script' };
test('host action drafts reuse importer normalization and discard credential/executable fields', () => {
const created = normalizePluginHostDraft({ ...host, password: 'untrusted', protocol: 'local' } as never, 'host-owned-id');
assert.equal(created.id, 'host-owned-id');
assert.equal(created.protocol, 'ssh');
assert.equal(created.password, undefined);
assert.equal(created.startupCommand, undefined);
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid', port: 0 }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid\ninvalid' }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid other.invalid' }, 'id'));
});
test('edit draft preserves saved connection and credentials and diff includes only approved metadata', () => {
const edited = applyPluginHostEdit(host, { label: ' Renamed ', tags: ['new'], hostname: 'attacker.invalid', password: 'untrusted' } as never);
assert.equal(edited.password, host.password);
assert.equal(edited.hostname, host.hostname);
assert.equal(edited.startupCommand, host.startupCommand);
assert.throws(() => applyPluginHostEdit(host, { label: ' ' }), /required/);
assert.deepEqual(pluginHostFieldDiff(host, edited), [
{ field: 'label', before: 'Synthetic', after: 'Renamed' },
{ field: 'tags', before: [], after: ['new'] },
]);
assert.equal(JSON.stringify(pluginHostFieldDiff(undefined, normalizePluginHostDraft(host, 'id'))).includes('synthetic-secret'), false);
});
test('connection warning covers host, inherited, global and output automation without returning script content', () => {
const clean = { ...host, startupCommand: undefined };
assert.equal(pluginHostRunsSavedScripts(clean, [], []), false);
assert.equal(pluginHostRunsSavedScripts(host, [], []), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, group: 'Synthetic' }, [{ path: 'Synthetic', startupCommand: 'synthetic-group-script' }], []), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'global', label: 'Global', command: 'synthetic', kind: 'script', trigger: 'onConnect', targetsAllHosts: true } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'output', label: 'Output', command: 'synthetic', kind: 'script', trigger: 'onOutput', triggerPattern: 'ready' } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, outputTriggers: [{ id: 't', pattern: 'ready', scriptId: 'synthetic' }] }, [], []), true);
});
-77
View File
@@ -1,77 +0,0 @@
import type { ImporterHostDraft } from '@netcatty/plugin-contract';
import type { GroupConfig, Host, ManagedSource, Snippet } from './models';
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
import { hasHostConnectAutomation } from './hostConnectScripts';
import { normalizePluginImporterRecords } from './pluginImporter';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import { isScriptSnippet } from './snippetScript';
import { snippetAppliesToOutputTrigger } from './snippetTargets';
// Keep the public host draft a subset of the existing typed importer. In
// particular, references to credentials, plugin configuration and executable
// settings are never accepted by this surface.
export type PluginHostDraftFields = Pick<ImporterHostDraft,
'label' | 'hostname' | 'username' | 'port' | 'group' | 'tags'>;
export type PluginHostEditFields = Pick<PluginHostDraftFields, 'label' | 'group' | 'tags'>;
export const PLUGIN_HOST_CREATE_FIELDS = ['label', 'hostname', 'username', 'port', 'group', 'tags'] as const;
export const PLUGIN_HOST_EDIT_FIELDS = ['label', 'group', 'tags'] as const;
export function normalizePluginHostDraft(draft: PluginHostDraftFields, id: string): Host {
const fields = Object.fromEntries(PLUGIN_HOST_CREATE_FIELDS.flatMap((field) => (
draft[field] === undefined ? [] : [[field, draft[field]]]
)));
// Validate before sanitization as well: an invalid address must not become
// a different, valid endpoint when the importer removes control characters.
const raw = { ...fields, hostname: draft.hostname ?? '', protocol: 'ssh' as const };
if (!buildVaultHostFromDraft(raw).ok) throw new Error('Host draft is invalid');
const normalized = normalizePluginImporterRecords([{
type: 'draft', draft: { kind: 'host', value: raw },
}]);
if (normalized.errors.length || normalized.hosts.length !== 1) {
throw new Error('Host draft is invalid');
}
const validated = buildVaultHostFromDraft(normalized.hosts[0]);
if (!validated.ok) throw new Error('Host draft is invalid');
return { ...validated.host, id };
}
export function applyPluginHostEdit(host: Host, draft: PluginHostEditFields): Host {
if (draft.label !== undefined && !draft.label.trim()) throw new Error('Host label is required');
const normalized = normalizePluginHostDraft({
label: draft.label ?? host.label,
hostname: 'draft.invalid', username: '',
group: draft.group ?? host.group, tags: draft.tags ?? host.tags,
}, host.id);
return { ...host, label: normalized.label, group: normalized.group, tags: normalized.tags };
}
export function pluginHostFieldDiff(before: Host | undefined, after: Host) {
const fields = before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS;
return fields.flatMap((field) => {
const previous = before?.[field];
const value = after[field];
return JSON.stringify(previous) === JSON.stringify(value) ? [] : [{
field, before: previous ?? null, after: value ?? null,
}];
});
}
/** Managed SSH-config writes stay in the full Vault editor. Reuse its group
* ownership rules without opening or syncing any managed file here. */
export function pluginHostDraftTouchesManagedSource(host: Host, managedSources: ManagedSource[]): boolean {
if (host.managedSourceId) return true;
if (managedSources.length === 0) return false;
const reconciled = applyVaultHostUpdate([host], [], host.id, { group: host.group ?? '' }, { managedSources });
if (!reconciled.ok) throw new Error('Host draft is invalid');
return Boolean(reconciled.updatedHost.managedSourceId);
}
/** Only the host UI consumes this disclosure; script text never crosses RPC. */
export function pluginHostRunsSavedScripts(host: Host, groups: GroupConfig[], snippets: Snippet[]): boolean {
const effective = applyGroupDefaults(host, host.group ? resolveGroupDefaults(host.group, groups) : {});
return Boolean(effective.startupCommand?.trim())
|| hasHostConnectAutomation(effective, snippets)
|| snippets.some((snippet) => isScriptSnippet(snippet) && snippetAppliesToOutputTrigger(snippet, effective))
|| Boolean(effective.outputTriggers?.length);
}
+31 -1
View File
@@ -241,6 +241,7 @@ let registeredVaultAgentBridge = null;
// Active streaming requests (for cancellation)
const activeStreams = new Map();
const pluginModelStreamOwners = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
const ENC_PREFIX = "enc:v1:";
@@ -613,7 +614,11 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
const parsedUrl = new URL(url);
// Register cancellation before any await so Stop during PAC/proxy lookup works.
const controller = new AbortController();
const { idleTimeoutMs, totalTimeoutMs } = resolveAIStreamTimeouts(options);
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
: resolveAIStreamTimeouts(options);
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
let lifecycleFinished = false;
let idleTimer;
@@ -623,6 +628,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(totalTimer);
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -636,6 +642,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
}, totalTimeoutMs);
noteStreamActivity();
activeStreams.set(requestId, controller);
if (pluginRuntimeId) pluginModelStreamOwners.set(requestId, { sender: event.sender, runtimeId: pluginRuntimeId });
if (controller.signal.aborted) {
finishLifecycle();
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
@@ -782,10 +789,19 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
settleResolve({ statusCode, statusText });
let buffer = "";
let pluginResponseBytes = 0;
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB safety limit
res.on("data", (chunk) => {
if (streamFinished) return;
noteStreamActivity();
if (pluginRuntimeId) {
pluginResponseBytes += Buffer.byteLength(chunk);
if (pluginResponseBytes > 131_072) {
failStream(new Error("Plugin model stream byte budget exceeded"));
return;
}
}
const previousBufferLength = buffer.length;
buffer += chunk.toString();
// Guard against unbounded buffer growth
@@ -1005,12 +1021,25 @@ function registerHandlers(ipcMain) {
}
}
// This entry point is main-process only. Plugin views cannot choose a sender or
// access it through IPC. Internal Agent streams have no plugin request tag.
function cancelPluginAIStreamsForSender(sender, runtimeId) {
let cancelled = 0;
for (const [requestId, owner] of [...pluginModelStreamOwners]) {
if (owner.sender !== sender || (runtimeId !== undefined && owner.runtimeId !== runtimeId)) continue;
activeStreams.get(requestId)?.abort(createAbortError());
cancelled++;
}
return cancelled;
}
// Abort active streams and child processes on shutdown
function cleanup() {
for (const [id, controller] of activeStreams) {
try { controller.abort(); } catch {}
}
activeStreams.clear();
pluginModelStreamOwners.clear();
// Abort active SDK agent streams (set by registerSdkStreamHandlers on ctx).
if (registeredContext && registeredContext.sdkActiveStreams) {
@@ -1056,6 +1085,7 @@ module.exports = {
buildExternalAgentSystemContext,
buildExternalAgentContextualPrompt,
_streamRequestForTests: streamRequest,
cancelPluginAIStreamsForSender,
_resolveAIStreamTimeoutsForTests: resolveAIStreamTimeouts,
_getActiveStreamCountForTests: () => activeStreams.size,
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
@@ -0,0 +1,73 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const schema = require("../generated/plugin-contract.schema.json");
if (!schema.$defs.PluginAIChatResult) {
test("assistant installed runtime requires the shared canonical AI/context contract", { skip: "Run on the parent-coordinated integration head" }, () => {});
} else {
test("installed assistant uses canonical SDK/RPC, independent content grants, reviewed version and revocation", { timeout: 15_000 }, async (t) => {
const { createPluginHostService } = require("../hostService.cjs");
const { inProcessBrowserRuntime } = require("../fixtures/inProcessBrowserRuntime.cjs");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-assistant-entry-"));
let service; let contentAllowed = false; let modelAllowed = true; let reviewed = true; let invoked = 0; let versionCurrent = true;
const decisions = [];
t.after(async () => { await service?.manager.shutdown(); await fs.rm(root, { recursive: true, force: true }); });
service = createPluginHostService({
app: { getAppPath: () => path.resolve(__dirname, "../../.."), getPath: () => root, getVersion: () => "0.0.0" }, electron: {}, env: {},
pluginAIHostAdapter: { assertActive: (context) => context.assertActive(),
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: () => true, invoke: async () => {
invoked++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; yield { kind: "usage", inputTokens: 12, outputTokens: 6, totalTokens: 18 }; })();
} }),
listSelected: async () => [{ key: "synthetic-note", scope: "note", title: "Synthetic retry plan", version: "v1",
readText: () => "Synthetic retry plan: add a retry button", isCurrent: () => versionCurrent }],
reviewText: async () => reviewed,
},
requestPermissionDecision: async (request) => {
decisions.push(request);
if ((request.permission === "context.selected.read" && !contentAllowed) || (request.permission === "ai.invoke" && !modelAllowed)) return { requestId: request.requestId, decision: "deny" };
return { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
},
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
const sample = path.resolve(__dirname, "../../../examples/plugins/assistant");
// Use an explicit six-file fixture snapshot, not source/build tooling.
const staging = path.join(root, "package"); await fs.mkdir(path.join(staging, "dist"), { recursive: true });
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js", "README.md"]) await fs.copyFile(path.join(sample, file), path.join(staging, file));
await fs.copyFile(path.join(sample, "src/index.js"), path.join(staging, "dist/index.js"));
const { buildPluginPackage, validatePluginPackage } = await import("@netcatty/plugin-cli");
const archive = path.join(root, "assistant.ncpkg"); const built = await buildPluginPackage(staging, archive);
assert.equal(built.fileCount, 6); assert.equal((await validatePluginPackage(archive)).manifest.id, "com.netcatty.assistant");
await service.manager.install(archive, { enable: true });
const id = "com.netcatty.assistant";
const run = (name, args) => service.contributionService.executeCommand(`${id}.${name}`, args);
modelAllowed = false; await assert.rejects(run("open")); assert.equal(invoked, 0);
modelAllowed = true;
const chat = await run("open"); const references = await run("contexts"); assert.equal(references.length, 1);
assert.equal(references[0].scope, "note"); assert.equal(references[0].text, undefined);
const input = { chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: references[0].handle, version: references[0].version }] };
await assert.rejects(run("send", input)); assert.equal(invoked, 0);
contentAllowed = true; reviewed = false; await assert.rejects(run("send", input)); assert.equal(invoked, 0);
reviewed = true;
const turn = await run("send", input);
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "text", sequence: 0, text: "Synthetic summary" });
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "usage", sequence: 1, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
assert.deepEqual(await run("read", { turnHandle: turn.turnHandle }), { kind: "end", sequence: 2 });
await assert.rejects(run("send", { ...input, privateOwner: "spoofed" }));
await assert.rejects(run("send", { ...input, contexts: [null] }));
await assert.rejects(run("read", { turnHandle: turn.turnHandle, source: "spoofed" }));
const pending = await run("send", input);
service.permissionEngine.revokeApplication(id, "ai.invoke", "*");
assert.equal(service.aiContextService.broker.turns.size, 0);
await assert.rejects(run("read", { turnHandle: pending.turnHandle }));
assert.ok(decisions.some((decision) => decision.permission === "context.selected.read" && decision.resources[0].startsWith("selected:")));
const fresh = await run("open"); const [reference] = await run("contexts");
versionCurrent = false;
await assert.rejects(run("send", { chatHandle: fresh.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }));
await service.manager.setEnabled(id, false); assert.equal(service.aiContextService.broker.chats.size, 0);
});
}
+10 -4
View File
@@ -49,10 +49,16 @@ class PluginAIContextService {
}
async #guard(context) {
const owner = await this.resolveOwner(context);
await this.host.assertActive(context);
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
return owner;
try {
const owner = await this.resolveOwner(context);
await this.host.assertActive(context);
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
return owner;
} catch (error) {
const binding = this.source.bindings.get(context.runtimeId);
if (!context.signal?.aborted && binding?.pluginId === context.pluginId && binding.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
throw error;
}
}
#authorization(permission, params) {
@@ -0,0 +1,66 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { PluginAIContextService } = require("./aiContextService.cjs");
function fixture() {
let current = true; let allowed = true; let reviewed = true; let sourceId = "source-a"; let revoke; let invoked = 0; let reads = 0;
const authorizations = [];
const context = { pluginId: "com.example.ai", runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
const source = () => ({ id: sourceId, kind: "third-party", origin: "https://source.example" });
const database = { getActivePlugin: () => ({ source: source(), enabled: true, activeVersion: "1", runtime: { status: "running" } }),
getRetainedPluginSource: source, privateOwner: () => `opaque-${sourceId}`, publicPluginId: () => context.pluginId };
const hostAdapter = { assertActive: async () => {},
selectModel: async () => ({ label: "Synthetic", isCurrent: () => current, invoke: () => { invoked++; return (async function* () { yield { kind: "text", text: "Result" }; })(); } }),
listSelected: async () => [{ key: "selected-note", scope: "note", title: "Synthetic", version: "1", isCurrent: () => current, readText: () => { reads++; return "Synthetic body"; } }],
reviewText: async () => reviewed,
};
const permissionEngine = { onDidRevoke: (listener) => { revoke = listener; return { dispose() {} }; },
authorize: async (_context, authorization) => { authorizations.push(authorization); if (!allowed) throw Error("Denied"); } };
const service = new PluginAIContextService({ database, permissionEngine, hostAdapter, getRuntimeIdentity: () => ({ ...context, runtimeKind: "browser" }) });
return { service, context, hostAdapter, authorizations, invoked: () => invoked, reads: () => reads,
allow: (v) => { allowed = v; }, review: (v) => { reviewed = v; }, current: (v) => { current = v; }, source: (v) => { sourceId = v; }, revoke: () => revoke({ pluginId: context.pluginId }) };
}
test("model invocation independently requests content permission and reviewed text", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const { items: [reference] } = await f.service.list({}, f.context); assert.equal(f.reads(), 0);
const chat = await f.service.open({}, f.context);
f.allow(false);
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.reads(), 0); assert.equal(f.invoked(), 0);
f.allow(true); f.review(false);
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.invoked(), 0);
f.review(true);
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context);
assert.equal((await f.service.read(turn, f.context)).text, "Result");
assert.ok(f.authorizations.every((authorization) => authorization.permission === "context.selected.read" && authorization.allowedScopes[0] === "once"));
});
test("source change during host review suppresses text and inference", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const { items: [reference] } = await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
f.hostAdapter.reviewText = async () => { f.source("source-b"); return true; };
await assert.rejects(f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: reference.handle, version: reference.version }] }, f.context));
assert.equal(f.invoked(), 0);
});
test("permission revocation clears all handles for the plugin, including chats without context", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
await f.service.list({}, f.context); const chat = await f.service.open({}, f.context);
const turn = await f.service.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
f.revoke(); assert.equal(f.service.broker.turns.size, 0); assert.equal(f.service.selected.handles.size, 0);
await assert.rejects(f.service.read(turn, f.context));
});
test("cancelling one host request does not revoke another chat in the same runtime", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
const chat = await f.service.open({}, f.context);
let resolve; f.hostAdapter.listSelected = () => new Promise((done) => { resolve = done; });
const controller = new AbortController();
const listing = f.service.list({}, { ...f.context, signal: controller.signal });
const cancelled = assert.rejects(listing);
await new Promise(setImmediate); controller.abort(); resolve([]); await cancelled;
assert.equal(f.service.broker.chats.has(chat.chatHandle), true);
});
+29 -14
View File
@@ -36,6 +36,7 @@ class ModelInvocationBroker {
this.clock = clock;
this.limits = boundedLimits(limits);
this.chats = new Map();
this.openings = new Set();
this.turns = new Map();
this.rates = new Map();
this.closed = false;
@@ -81,22 +82,33 @@ class ModelInvocationBroker {
async open(context) {
const binding = await this.#binding(context);
// Host chooses from configured models. Plugin parameters cannot override it.
const model = await this.selectModel(context);
await context.assertActive();
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
throw fail("unavailable", "Choose a configured model in the host first");
}
await context.assertActive();
this.#prune();
if (this.closed || this.chats.size >= this.limits.chats
|| [...this.chats.values()].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
if (this.closed || this.chats.size + this.openings.size >= this.limits.chats
|| [...this.chats.values(), ...this.openings].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Plugin chat limit reached");
}
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
this.chats.set(chat.handle, chat);
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
// Reserve before awaiting the host picker. Revoke/close can invalidate this
// admission even if a host callback ignores cancellation and returns late.
const opening = { ...binding, active: true };
this.openings.add(opening);
let model;
try {
model = await this.selectModel(context);
const current = await this.#binding(context);
if (!opening.active || this.closed || current.owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
throw fail("unavailable", "Choose a configured model in the host first");
}
await context.assertActive();
if (!opening.active || this.closed || (await this.#binding(context)).owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
this.chats.set(chat.handle, chat);
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
} catch (error) {
try { Promise.resolve(model?.dispose?.()).catch(() => {}); } catch {}
throw this.#safe(error);
} finally { this.openings.delete(opening); }
}
async start({ chatHandle, prompt, contexts = [] }, context) {
@@ -243,6 +255,7 @@ class ModelInvocationBroker {
#close(chat) {
if (chat.turn) this.#end(chat.turn);
this.chats.delete(chat.handle);
try { Promise.resolve(chat.model.dispose?.()).catch(() => {}); } catch {}
}
async close(chatHandle, context) {
const chat = this.chats.get(chatHandle);
@@ -250,14 +263,16 @@ class ModelInvocationBroker {
await this.#ownedChat(chat, context); this.#close(chat); return true;
}
releaseRuntime(runtimeId) {
for (const opening of [...this.openings]) if (opening.runtimeId === runtimeId) { opening.active = false; this.openings.delete(opening); }
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
this.contexts?.releaseRuntime(runtimeId);
}
releaseOwner(owner) {
for (const opening of [...this.openings]) if (opening.owner === owner) { opening.active = false; this.openings.delete(opening); }
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
this.contexts?.releaseOwner(owner);
}
shutdown() { this.closed = true; for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
shutdown() { this.closed = true; for (const opening of this.openings) opening.active = false; this.openings.clear(); for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
}
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
@@ -209,3 +209,14 @@ test("malformed context cannot publish a turn or interrupt two-handle runtime re
assert.equal(f.calls.length, 0);
f.broker.shutdown();
});
test("pending model selections reserve capacity and cannot publish after revocation", async () => {
const f = fixture({ limits: { perRuntime: 1 } }); const pending = deferred(); let disposed = 0;
f.broker.selectModel = () => pending.promise;
const opening = f.broker.open(f.context); const rejected = assert.rejects(opening);
await new Promise(setImmediate);
await assert.rejects(f.broker.open(f.context), (error) => error.code === RPC_ERRORS.resourceExhausted);
f.broker.releaseRuntime("r1");
pending.resolve({ ...f.model, dispose: () => { disposed++; } });
await rejected; assert.equal(f.broker.chats.size, 0); assert.equal(f.broker.openings.size, 0); assert.equal(disposed, 1); f.broker.shutdown();
});
+216
View File
@@ -0,0 +1,216 @@
"use strict";
const { randomBytes, randomUUID, timingSafeEqual } = require("node:crypto");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const CHANNELS = Object.freeze({ request: "netcatty:plugins:ai-host-request", ready: "netcatty:plugins:ai-host-ready", response: "netcatty:plugins:ai-host-response" });
// Trusted main-window link, never a plugin IPC surface. Canonical envelopes are
// supplied by the shared contract task; no provider config or key crosses it.
class PluginAITrustedHostAdapter {
constructor(options) {
this.getWindow = options.getWindow;
this.isLocked = options.isLocked ?? (() => true);
this.dialog = options.dialog;
this.cancelProviderStreams = options.cancelProviderStreams ?? ((sender, runtimeId) => require("../../bridges/aiBridge.cjs").cancelPluginAIStreamsForSender(sender, runtimeId));
this.pending = new Map(); this.listeners = new Set(); this.reviewControllers = new Map();
this.owner = null; this.ownerCleanup = null; this.generation = 0; this.closed = false;
this.timeoutMs = Math.min(options.timeoutMs ?? 60_000, 60_000);
this.validateRequest = options.validateRequest ?? null;
this.validateResponse = options.validateResponse ?? null;
this.unsubscribeLock = options.subscribeLock?.((state) => { if (state.locked) this.invalidate(); });
}
registerIpc(ipcMain) {
if (this.ipcRegistration) throw new Error("Trusted AI host IPC is already registered");
this.validateRequest ??= createDefinitionValidator("PluginAIHostRequest");
this.validateResponse ??= createDefinitionValidator("PluginAIHostResponse");
ipcMain.handle(CHANNELS.ready, (event, ready) => this.setReady(event.sender, ready));
ipcMain.handle(CHANNELS.response, (event, response) => this.respond(event.sender, response));
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(CHANNELS.ready); ipcMain.removeHandler(CHANNELS.response); } };
return this.ipcRegistration;
}
#owner() {
const window = this.getWindow?.();
if (this.closed || this.isLocked() || !this.owner || window?.webContents !== this.owner || window.isDestroyed?.()
|| this.owner.isDestroyed?.() || window.isVisible?.() !== true) throw fail("unavailable", "Trusted AI host is unavailable");
return this.owner;
}
async assertActive(context) {
await context.assertActive(); context.signal?.throwIfAborted(); this.#owner();
}
setReady(sender, ready) {
if (typeof ready !== "boolean" || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) {
throw fail("permissionDenied", "AI host sender is not the main window");
}
if (!ready) { if (this.owner === sender) this.invalidate(true); return true; }
if (this.closed) throw fail("unavailable", "AI host is closed");
if (this.owner === sender) return true;
this.invalidate(true); this.owner = sender;
const window = this.getWindow();
const release = () => this.invalidate(true);
const hide = () => this.invalidate();
const navigate = (details, _url, inPlace, mainFrame) => {
if ((details?.isMainFrame ?? mainFrame) !== false && (details?.isSameDocument ?? inPlace) !== true) release();
};
sender.on?.("destroyed", release); sender.on?.("render-process-gone", release); sender.on?.("did-start-navigation", navigate); window.on?.("hide", hide);
this.ownerCleanup = () => {
sender.removeListener?.("destroyed", release); sender.removeListener?.("render-process-gone", release);
sender.removeListener?.("did-start-navigation", navigate); window.removeListener?.("hide", hide);
};
return true;
}
#envelope(kind, payload, runtimeId) {
const request = { requestId: randomUUID(), actionToken: randomBytes(24).toString("base64url"), kind, payload, runtimeId };
assertPluginJsonValue(request, { maxBytes: 24_576 });
if (!this.validateRequest?.(request)) throw fail("internal", "Trusted AI host request is invalid");
return request;
}
#send(kind, payload, runtimeId) {
const request = this.#envelope(kind, payload, runtimeId);
return { request, sender: this.#owner(), generation: this.generation };
}
#cleanup(kind, payload, runtimeId, sender = this.owner) {
if (!sender || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) return;
try { sender.send(CHANNELS.request, this.#envelope(kind, payload, runtimeId)); } catch {}
}
async #request(kind, payload, context) {
await this.assertActive(context);
if (this.pending.size >= 64) throw fail("resourceExhausted", "Trusted AI host request limit reached");
const { request, sender, generation } = this.#send(kind, payload, context.runtimeId);
return new Promise((resolve, reject) => {
const entry = { request, sender, generation, context, resolve, reject };
entry.finish = (result, error) => {
if (!this.pending.delete(request.requestId)) return;
clearTimeout(entry.timer); context.signal?.removeEventListener("abort", entry.abort);
if (error) {
this.#cleanup("request.cancel", { requestId: request.requestId }, context.runtimeId, sender);
reject(error);
} else resolve(result);
};
entry.abort = () => entry.finish(null, fail("cancelled", "Trusted AI host request cancelled"));
entry.timer = setTimeout(() => entry.finish(null, fail("deadlineExceeded", "Trusted AI host request timed out")), this.timeoutMs);
this.pending.set(request.requestId, entry);
context.signal?.addEventListener("abort", entry.abort, { once: true });
if (context.signal?.aborted) { entry.abort(); return; }
try { sender.send(CHANNELS.request, request); }
catch { entry.finish(null, fail("unavailable", "Trusted AI host disconnected")); }
});
}
async respond(sender, response) {
if (sender !== this.#owner()) throw fail("permissionDenied", "AI host response sender is invalid");
assertPluginJsonValue(response, { maxBytes: 24_576 });
if (!this.validateResponse?.(response)) throw fail("invalidArgument", "AI host response is invalid");
const entry = this.pending.get(response.requestId);
if (!entry || entry.sender !== sender || entry.generation !== this.generation) return false;
const expected = Buffer.from(entry.request.actionToken); const actual = Buffer.from(response.actionToken);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
// Consume the nonce before awaiting authentication to prevent replay races.
entry.request.actionToken = "";
try {
await this.assertActive(entry.context);
if (entry.generation !== this.generation || this.pending.get(response.requestId) !== entry) return false;
if (response.error === true) entry.finish(null, fail("unavailable", "Trusted AI host operation failed"));
else entry.finish(response.result);
return true;
} catch { entry.finish(null, fail("cancelled", "Trusted AI host response became stale")); return false; }
}
async selectModel(context) {
const selected = await this.#request("model.select", {}, context);
if (typeof selected?.hostHandle !== "string" || typeof selected.label !== "string" || selected.label.length > 160) throw fail("internal", "Selected model is invalid");
const generation = this.generation;
return {
label: selected.label,
dispose: async () => { try { await this.#request("model.release", { hostHandle: selected.hostHandle }, { ...context, signal: undefined }); } catch {} },
isCurrent: async () => generation === this.generation && (await this.#request("model.current", { hostHandle: selected.hostHandle }, context)).current === true,
invoke: async (input) => {
const activeContext = { ...context, signal: input.signal };
const turn = await this.#request("model.start", { hostHandle: selected.hostHandle, prompt: input.prompt,
contexts: input.contexts, maxOutputTokens: input.maxOutputTokens, maxChunkBytes: input.maxChunkBytes }, activeContext);
if (typeof turn?.hostHandle !== "string") throw fail("internal", "Model turn is invalid");
return {
next: async () => {
const result = await this.#request("model.next", { hostHandle: turn.hostHandle }, activeContext);
if (result.done === true) return { done: true };
if (result.done !== false || !result.event) throw fail("internal", "Model stream event is invalid");
return { done: false, value: result.event };
},
return: async () => {
// Cleanup must remain possible after the turn signal is aborted.
try { await this.#request("model.stop", { hostHandle: turn.hostHandle }, { ...context, signal: undefined }); } catch {}
return { done: true };
},
};
},
};
}
async listSelected(context) {
const result = await this.#request("context.select", {}, context);
if (!Array.isArray(result?.items) || result.items.length > 5) throw fail("internal", "Selected context list is invalid");
const generation = this.generation;
return result.items.map((item) => ({ key: item.hostHandle, scope: item.scope, title: item.title, version: item.version,
isCurrent: async () => generation === this.generation && (await this.#request("context.current", { hostHandle: item.hostHandle, version: item.version }, context)).current === true,
readText: async ({ signal } = {}) => {
const read = await this.#request("context.read", { hostHandle: item.hostHandle, version: item.version }, { ...context, signal: signal ?? context.signal });
return read.text;
},
}));
}
async reviewText(selected, context) {
await this.assertActive(context);
const window = this.getWindow(); const generation = this.generation;
if (typeof this.dialog?.showMessageBox !== "function") return false;
// A native permission dialog can resolve just before its parent regains
// focus. Wait briefly for this exact owner, without focusing it ourselves.
const focusDeadline = Date.now() + 1_000;
while (window.isFocused?.() !== true) {
await this.assertActive(context);
if (generation !== this.generation || Date.now() >= focusDeadline) return false;
await new Promise((resolve) => setTimeout(resolve, 20));
}
const controller = new AbortController();
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
this.reviewControllers.set(controller, context.runtimeId);
try {
const result = await this.dialog.showMessageBox(window, {
type: "question", title: "Share selected context", message: `Share "${selected.title}" with ${selected.pluginId}?`,
detail: `Scope: ${selected.scope}\nVersion: ${selected.version}\n\n${selected.text}`,
buttons: ["Share this text", "Cancel"], defaultId: 1, cancelId: 1, noLink: true, signal,
});
await this.assertActive(context);
return !signal.aborted && generation === this.generation && result.response === 0;
} finally { this.reviewControllers.delete(controller); }
}
onDidInvalidate(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
releaseRuntime(runtimeId) {
try { if (this.owner) this.cancelProviderStreams(this.owner, runtimeId); } catch {}
for (const entry of [...this.pending.values()]) if (entry.context.runtimeId === runtimeId) entry.abort();
for (const [controller, owner] of this.reviewControllers) if (owner === runtimeId) controller.abort();
this.#cleanup("runtime.release", {}, runtimeId);
}
invalidate(releaseOwner = false) {
this.generation++;
try { if (this.owner) this.cancelProviderStreams(this.owner); } catch {}
for (const entry of [...this.pending.values()]) entry.finish(null, fail("cancelled", "Trusted AI host lifecycle changed"));
for (const controller of this.reviewControllers.keys()) controller.abort();
for (const listener of [...this.listeners]) { try { listener(); } catch {} }
if (releaseOwner) { this.ownerCleanup?.(); this.ownerCleanup = null; this.owner = null; }
}
shutdown() { this.ipcRegistration?.dispose(); this.ipcRegistration = null; this.closed = true; this.invalidate(true); this.unsubscribeLock?.(); this.listeners.clear(); }
}
module.exports = { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS };
@@ -0,0 +1,96 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS } = require("./trustedHostAdapter.cjs");
function fixture(options = {}) {
const sender = Object.assign(new EventEmitter(), { id: 1, isDestroyed: () => false });
const messages = [];
sender.send = (channel, payload) => messages.push({ channel, payload: structuredClone(payload) });
const window = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
let currentWindow = window; let locked = false;
const adapter = new PluginAITrustedHostAdapter({ getWindow: () => currentWindow, isLocked: () => locked,
// Domain tests isolate nonce/owner lifecycle. Canonical schema validation is
// exercised separately when the shared contract is integrated.
cancelProviderStreams: () => {},
validateRequest: () => true, validateResponse: () => true, timeoutMs: 1_000,
dialog: { showMessageBox: async () => ({ response: 0 }) }, ...options });
const context = { runtimeId: "r", pluginId: "plugin", pluginVersion: "1", assertActive: async () => {} };
adapter.setReady(sender, true);
return { adapter, sender, window, context, messages, lockedTo: (v) => { locked = v; }, ownerTo: (v) => { currentWindow = v; } };
}
const tick = () => new Promise(setImmediate);
test("trusted renderer replies require the exact owner and one-use nonce", async (t) => {
const f = fixture(); t.after(() => f.adapter.shutdown());
const selecting = f.adapter.selectModel(f.context); await tick();
const request = f.messages.at(-1).payload;
const response = { requestId: request.requestId, actionToken: request.actionToken, result: { hostHandle: "model", label: "Synthetic model" } };
await assert.rejects(f.adapter.respond({ id: 2 }, response));
assert.equal(await f.adapter.respond(f.sender, { ...response, actionToken: "wrong" }), false);
assert.equal(await f.adapter.respond(f.sender, response), true);
assert.equal(await f.adapter.respond(f.sender, response), false);
assert.equal((await selecting).label, "Synthetic model");
});
test("owner navigation/hide, runtime release, cancellation and timeout discard pending replies", async () => {
for (const action of ["navigate", "hide", "runtime", "abort", "timeout"]) {
const f = fixture(action === "timeout" ? { timeoutMs: 20 } : {}); const controller = new AbortController();
const selecting = f.adapter.selectModel({ ...f.context, signal: controller.signal });
const rejected = assert.rejects(selecting); await tick();
const request = f.messages.at(-1).payload;
if (action === "navigate") f.sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
if (action === "hide") f.window.emit("hide");
if (action === "runtime") f.adapter.releaseRuntime("r");
if (action === "abort") controller.abort();
await rejected; assert.equal(f.adapter.pending.size, 0);
if (action !== "navigate") assert.equal(await f.adapter.respond(f.sender, { ...request, result: {} }), false);
f.adapter.shutdown();
}
});
test("main window replacement and lock cannot reuse a previous renderer owner", async () => {
const f = fixture(); f.ownerTo({ ...f.window, webContents: { id: 3 } });
await assert.rejects(f.adapter.selectModel(f.context));
assert.throws(() => f.adapter.setReady(f.sender, true)); f.adapter.shutdown();
const next = fixture(); next.lockedTo(true); await assert.rejects(next.adapter.selectModel(next.context)); next.adapter.shutdown();
});
test("selected text review is Cancel by default and cannot approve after cancellation", async () => {
let resolve; let options;
const f = fixture({ dialog: { showMessageBox: (_window, value) => { options = value; return new Promise((r) => { resolve = r; }); } } });
const controller = new AbortController();
const review = f.adapter.reviewText({ scope: "note", title: "Synthetic", version: "v1", pluginId: "plugin", text: "Synthetic text" }, { ...f.context, signal: controller.signal });
await tick(); assert.equal(options.defaultId, options.cancelId); assert.equal(options.cancelId, 1);
controller.abort(); resolve({ response: 0 }); await assert.rejects(review); f.adapter.shutdown();
});
test("text review tolerates native permission focus restoration without focusing a window", async () => {
const f = fixture(); let focused = false; let focusCalls = 0;
f.window.isFocused = () => focused; f.window.focus = () => { focusCalls++; };
const timer = setTimeout(() => { focused = true; }, 25);
try {
assert.equal(await f.adapter.reviewText({ title: "Synthetic", scope: "note", version: "1", text: "Synthetic", pluginId: "plugin" }, f.context), true);
assert.equal(focusCalls, 0);
} finally { clearTimeout(timer); f.adapter.shutdown(); }
});
test("canonical trusted envelopes reject unknown requests, keys and provider diagnostics", async () => {
const { createDefinitionValidator } = require("../contractValidator.cjs");
const requestValid = createDefinitionValidator("PluginAIHostRequest");
const responseValid = createDefinitionValidator("PluginAIHostResponse");
const identity = { requestId: "00000000-0000-4000-8000-000000000001", actionToken: "A".repeat(32), runtimeId: "runtime" };
assert.equal(requestValid({ ...identity, kind: "model.select", payload: {} }), true);
assert.equal(requestValid({ ...identity, kind: "model.select", payload: { providerId: "arbitrary" } }), false);
assert.equal(requestValid({ ...identity, kind: "terminal.execute", payload: {} }), false);
assert.equal(responseValid({ requestId: identity.requestId, actionToken: identity.actionToken,
result: { hostHandle: identity.requestId, label: "Synthetic", apiKey: "forbidden" } }), false);
assert.equal(responseValid({ requestId: identity.requestId, actionToken: identity.actionToken, error: "raw diagnostics" }), false);
const handlers = new Map(); const f = fixture({ validateRequest: requestValid, validateResponse: responseValid });
f.adapter.registerIpc({ handle: (channel, handler) => handlers.set(channel, handler), removeHandler: (channel) => handlers.delete(channel) });
const pending = f.adapter.selectModel(f.context); await tick(); const request = f.messages.at(-1).payload;
assert.equal(await f.adapter.respond(f.sender, { requestId: request.requestId, actionToken: request.actionToken,
result: { hostHandle: identity.requestId, label: "Synthetic" } }), true);
assert.equal((await pending).label, "Synthetic"); f.adapter.shutdown(); assert.equal(handlers.size, 0);
});
+13 -7
View File
@@ -32,22 +32,28 @@ function canonicalizeStreamEndpoint(value) {
});
}
function normalizeStreamAddress(address) {
if (net.isIP(address) === 4) return address;
if (net.isIP(address) !== 6) return null;
const canonical = new URL(`http://[${address.split("%")[0]}]`).hostname.slice(1, -1);
if (!canonical.startsWith("::ffff:")) return canonical;
const words = canonical.slice(7).split(":");
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
}
function isLocalControlAddress(address) {
address = normalizeStreamAddress(address);
if (net.isIP(address) === 4) {
const [a, b] = address.split(".").map(Number);
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
}
if (net.isIP(address) !== 6) return true;
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
if (canonical.startsWith("::ffff:")) {
const words = canonical.slice(7).split(":");
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
}
const canonical = address;
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
return canonical.startsWith("::") || canonical.startsWith("ff")
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|| canonical.startsWith("2001:0:");
}
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress, normalizeStreamAddress };
+15 -3
View File
@@ -38,13 +38,14 @@ test("installed browser entrypoint uses registered SDK/RPC streams, denial and r
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
activationEvents: [`onCommand:${id}.run`],
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
contributes: { commands: ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
contributes: { commands: ["run", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
};
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(packageRoot, "browser.js"), `
export default { activate(context) {
let connection;
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
for (const name of ["run", "retain", "readCancelled", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
if (name === "readCancelled") return connection.readable.read();
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
if (name === "selected") {
const selected = await context.selectedContext.list({});
@@ -62,6 +63,15 @@ export default { activate(context) {
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
if (name === "pending") return { connected: true };
if (name === "cancelRead") {
const reading = connection.readable.read();
connection.readable.cancel();
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
}
if (name === "retain") {
await connection.writable.write(Uint8Array.of(42));
return Array.from(await connection.readable.read());
}
try {
await connection.writable.write(Uint8Array.of(0, 255, 42));
return Array.from(await connection.readable.read());
@@ -104,6 +114,7 @@ export default { activate(context) {
assert.equal(service.byteStreamService.broker.connections.size, 0);
allow = true;
assert.deepEqual(await run("run"), [0, 255, 42]);
assert.equal(await run("cancelRead"), true);
assert.equal(service.byteStreamService.records.size, 0);
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
await assert.rejects(run("spoof"), /contract/u);
@@ -114,10 +125,11 @@ export default { activate(context) {
]);
assert.equal((await run("selected")).text, "synthetic selected text");
await assert.rejects(run("aiSpoof"), /invalid/u);
assert.deepEqual(await run("pending"), { connected: true });
assert.deepEqual(await run("retain"), [42]);
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
assert.equal(service.byteStreamService.records.size, 0);
assert.equal(service.byteStreamService.broker.connections.size, 0);
await assert.rejects(run("readCancelled"), /cancel/u);
await run("cancel");
await run("pending");
await service.manager.setEnabled(id, false);
+9 -3
View File
@@ -2,6 +2,7 @@
const { randomUUID } = require("node:crypto");
const os = require("node:os");
const { normalizeStreamAddress } = require("./byteStreamEndpoint.cjs");
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { createDefinitionValidator } = require("./contractValidator.cjs");
@@ -27,7 +28,11 @@ class PluginByteStreamService {
permissionEngine: options.permissionEngine,
quotaManager: options.quotaManager,
// The public host service never forwards transport fixture overrides.
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
isControlEndpoint: (address) => {
const normalized = normalizeStreamAddress(address);
return normalized !== null && Object.values(os.networkInterfaces()).flat()
.some((item) => item?.address && normalizeStreamAddress(item.address) === normalized);
},
audit: (pluginId, operation, fields, state) => {
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
},
@@ -104,14 +109,15 @@ class PluginByteStreamService {
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
});
context.assertCommit();
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
if (record.closing) throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled");
context.signal?.addEventListener("abort", record.abort, { once: true });
// A cancelled attachment cannot leave a producer running after its RPC.
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
return { attached: true };
} catch (error) {
try { record.output?.cancel(); } catch {}
const alreadyClosing = record.closing;
this.closeRecord(record, error);
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
throw error;
}
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
+129
View File
@@ -0,0 +1,129 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const os = require("node:os");
const { PluginByteStreamService } = require("./byteStreamService.cjs");
test("own-machine denial normalizes both interface and destination addresses without blocking private peers", async (t) => {
let interfaces;
t.mock.method(os, "networkInterfaces", () => interfaces);
const service = new PluginByteStreamService({
permissionEngine: { authorize: async () => {}, onDidRevoke: () => ({ dispose() {} }) },
});
t.after(() => service.shutdown());
const dialed = [];
service.broker.connectSocket = ({ host }) => {
dialed.push(host);
throw new Error("synthetic dialer reached");
};
const context = { pluginId: "synthetic", runtimeId: "synthetic", assertActive: async () => {} };
for (const ownAddress of ["10.0.0.8", "::ffff:10.0.0.8", "::ffff:a00:8"]) {
interfaces = { synthetic: [{ address: ownAddress }] };
for (const endpoint of ["tcp://10.0.0.8:43210", "tcp://[::ffff:10.0.0.8]:43210", "tcp://[::ffff:a00:8]:43210"]) {
await assert.rejects(service.broker.connect(endpoint, context), /control endpoints/u);
}
}
assert.deepEqual(dialed, []);
interfaces = { synthetic: [{ address: "2001:0db8:0:0:0:0:0:8" }] };
await assert.rejects(service.broker.connect("tcp://[2001:db8::8]:43210", context), /control endpoints/u);
interfaces = { synthetic: [{ address: "10.0.0.8" }] };
for (const endpoint of ["tcp://10.0.0.9:43210", "tcp://[::ffff:10.0.0.9]:43210", "tcp://192.168.1.9:43210"]) {
await assert.rejects(service.broker.connect(endpoint, context), /synthetic dialer reached/u);
}
assert.deepEqual(dialed, ["10.0.0.9", "::ffff:a00:9", "192.168.1.9"]);
assert.equal(service.broker.connections.size, 0);
});
const net = require("node:net");
const { once } = require("node:events");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
async function fixture(t, options = {}) {
let peer;
const sockets = new Set();
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
server.listen(0, "127.0.0.1");
await once(server, "listening");
let service;
t.after(async () => {
service?.shutdown();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
let source = "first";
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
manifest: { features: { required: ["network.byte-streams"] } },
};
const audit = [];
const writes = [];
const registry = new PluginHostRpcRegistry();
service = new PluginByteStreamService({
database: {
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
getRetainedPluginSource: () => ({ id: source }),
privateOwner: () => `opaque-${source}`,
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
recordSecurityAudit: (...args) => audit.push(args),
},
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
})) },
});
service.broker.testLocalEndpoints.add(endpoint);
service.broker.isControlEndpoint = () => false;
service.register(registry);
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
}
test("source changes before grant publication cannot create a socket or grant", async (t) => {
let approve;
let grants = 0;
const f = await fixture(t, { authorize: async (_context, descriptor) => {
await new Promise((resolve) => { approve = resolve; });
descriptor.validateBeforeGrant();
grants++;
} });
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
while (!approve) await new Promise((resolve) => setImmediate(resolve));
f.swap(); approve();
await assert.rejects(pending, /source binding/u);
assert.equal(grants, 0);
assert.equal(f.peer(), undefined);
assert.equal(f.service.broker.connections.size, 0);
assert.equal(f.audit.length, 0);
});
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
const f = await fixture(t);
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
await f.call("network.stream.attach", { handleId: descriptor.handleId });
f.swap();
f.peer().write("synthetic old-source bytes");
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.writes.length, 0);
assert.equal(f.service.records.size, 0);
assert.equal(f.service.broker.connections.size, 0);
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
});
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
let opened;
let cancelled = 0;
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
while (!opened) await new Promise((resolve) => setImmediate(resolve));
f.service.releaseRuntime("runtime-one");
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
await assert.rejects(attaching, /cancelled/u);
assert.equal(cancelled, 1);
assert.equal(f.service.records.size, 0);
assert.equal(f.service.broker.connections.size, 0);
});
File diff suppressed because it is too large. Load diff
+4 -37
View File
@@ -61,7 +61,6 @@ class PluginHostInteractionService {
this.admissionRevision = 0;
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
this.owner = null;
this.ownerGeneration = 0;
this.ownerCleanup = null;
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
@@ -73,19 +72,15 @@ class PluginHostInteractionService {
metadata: { capability: "ui", mutating: true },
validateParams: assertInteractionRequest,
authorization: (params, context) => {
this.admitRequest(params, context);
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
return interactionAuthorization(params);
},
});
}
admitRequest(params, context) {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
}
setReady(sender, ready) {
const window = this.getWindow();
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
@@ -97,7 +92,6 @@ class PluginHostInteractionService {
if (this.owner === sender) return true;
if (this.owner) this.releaseWindow(this.owner);
this.owner = sender;
this.ownerGeneration++;
const close = () => this.releaseWindow(sender);
// A native permission dialog can temporarily blur its parent. Already
// displayed interactions still cancel immediately; pre-prompt admission
@@ -150,8 +144,6 @@ class PluginHostInteractionService {
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
});
}
@@ -266,25 +258,6 @@ class PluginHostInteractionService {
this.#audit(entry, "cancelled");
return this.#finish(entry, { cancelled: true });
}
if (this.vaultService?.isRequest(entry.params)) {
const outcome = await this.vaultService.respond(entry, response);
await entry.context.assertActive();
this.#assertWindow(sender);
if (this.pending.get(entry.id) !== entry) return false;
if (outcome.result) {
this.#audit(entry, "completed");
return this.#finish(entry, outcome.result);
}
this.#audit(entry, outcome.phase === "commit" ? "action-confirmed" : "action-claimed");
entry.phase = outcome.phase;
if (outcome.reviewedDraft !== undefined) entry.reviewedDraft = outcome.reviewedDraft;
if (entry.phase === "executing") {
entry.actionToken = this.#token(entry);
return { completionToken: entry.actionToken };
}
this.#present(entry);
return true;
}
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
this.#audit(entry, "preview-confirmed");
@@ -325,8 +298,6 @@ class PluginHostInteractionService {
releaseWindow(sender) {
if (this.owner !== sender) return;
this.admissionRevision++;
this.ownerGeneration++;
this.vaultService?.clearSelections();
this.owner = null;
this.ownerCleanup?.(); this.ownerCleanup = null;
for (const entry of [...this.pending.values()]) {
@@ -335,7 +306,6 @@ class PluginHostInteractionService {
}
releasePlugin(pluginId) {
this.admissionRevision++;
this.vaultService?.releasePlugin(pluginId);
for (const entry of [...this.pending.values()]) {
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
}
@@ -343,7 +313,6 @@ class PluginHostInteractionService {
releaseRuntime(runtimeId) {
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
this.rates.delete(runtimeId);
this.vaultService?.releaseRuntime(runtimeId);
}
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
const guard = this.admissionGuards.get(runtimeId);
@@ -355,7 +324,6 @@ class PluginHostInteractionService {
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) this.admissionRevision++;
if (invalidateAdmission) this.vaultService?.clearSelections();
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
@@ -365,7 +333,6 @@ class PluginHostInteractionService {
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
this.revocation.dispose(); this.unsubscribeLock?.();
this.key.fill(0);
this.vaultService?.shutdown();
}
}
+12 -17
View File
@@ -29,11 +29,8 @@ const { PluginManager } = require("./pluginManager.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginHostVaultService } = require("./hostVaultService.cjs");
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginProtocol } = require("./pluginProtocol.cjs");
const { PluginViewHost } = require("./pluginViewHost.cjs");
@@ -96,11 +93,12 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
let aiContextService;
let aiHostAdapter;
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -201,11 +199,6 @@ function createPluginHostService(options) {
subscribeLock: options.subscribeAppLock,
});
interactionService.registerRpcCapabilities(rpcRegistry);
const hostVaultService = new PluginHostVaultService({
interactionService,
sourceBinding: new PluginAISourceBinding({ database, getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId) }),
});
hostVaultService.registerRpcCapabilities(rpcRegistry);
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
if (configuredRegistry && typeof configuredRegistry.then === "function") {
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
@@ -273,14 +266,16 @@ function createPluginHostService(options) {
});
byteStreamService = new PluginByteStreamService({ database, permissionEngine, quotaManager, runtimeSupervisor });
byteStreamService.register(rpcRegistry);
aiHostAdapter = options.pluginAIHostAdapter ?? new PluginAITrustedHostAdapter({
getWindow: options.getInteractionWindow ?? (() => null),
isLocked: options.isAppLocked,
subscribeLock: options.subscribeAppLock,
dialog: options.electron.dialog,
});
if (options.electron.ipcMain) aiHostAdapter.registerIpc?.(options.electron.ipcMain);
aiContextService = new PluginAIContextService({ database, permissionEngine,
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
hostAdapter: options.pluginAIHostAdapter ?? {
assertActive: (context) => context.assertActive(),
selectModel: () => { throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin model adapter is unavailable"); },
listSelected: () => [],
reviewText: () => false,
},
hostAdapter: aiHostAdapter,
});
aiContextService.registerRpcCapabilities(rpcRegistry);
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
@@ -430,7 +425,6 @@ function createPluginHostService(options) {
return {
themeService,
interactionService,
hostVaultService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
@@ -463,6 +457,7 @@ function createPluginHostService(options) {
try { interactionService?.shutdown(); } catch {}
try { byteStreamService?.shutdown(); } catch {}
try { aiContextService?.shutdown(); } catch {}
if (!aiContextService) { try { aiHostAdapter?.shutdown?.(); } catch {} }
database.close();
throw error;
}
-153
View File
@@ -1,153 +0,0 @@
"use strict";
const { createHmac, randomBytes } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const METHODS = ["vault.selectHosts", "vault.requestHostDraft", "vault.requestConnect"];
const MAX_SELECTED = 50;
const MAX_REFERENCES = 500;
const TARGET_LIFETIME_MS = 5 * 60_000;
/** Typed host selection only. Selection references identify a reviewed target;
* the interaction service owns separate operation-specific commit authority. */
class PluginHostVaultService {
constructor({ sourceBinding, interactionService, clock = Date.now }) {
this.sourceBinding = sourceBinding;
this.interactionService = interactionService;
this.clock = clock;
this.key = randomBytes(32);
this.targets = new Map();
this.admissions = new WeakMap();
this.validators = Object.fromEntries([
"VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams",
"VaultHostSelectedResources", "VaultHostCreateFields", "VaultHostEditFields",
].map((name) => [name, createDefinitionValidator(name)]));
interactionService.vaultService = this;
}
isRequest(params) { return METHODS.includes(params?.kind); }
#validate(name, value) {
if (!this.validators[name](value)) throw fail("invalidArgument", "Host action parameters are invalid");
if (Buffer.byteLength(JSON.stringify(value)) > 65_536) throw fail("resourceExhausted", "Host action exceeds its size limit");
return structuredClone(value);
}
registerRpcCapabilities(registry) {
const definitions = ["VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams"];
for (const [index, method] of METHODS.entries()) {
registry.registerRequest(method, (params, context) => this.request(params, context), {
metadata: { capability: "vault", mutating: method !== "vault.selectHosts" },
validateParams: (params) => Object.freeze({ ...this.#validate(definitions[index], params), kind: method }),
authorization: async (params, context) => {
const binding = await this.sourceBinding.resolve(context);
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
this.interactionService.admitRequest(params, context);
this.admissions.set(params, binding);
return {
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
resources: ["*"], allowedScopes: ["once"],
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
operationId: method,
};
},
});
}
}
async request(params, originalContext) {
const binding = await this.sourceBinding.resolve(originalContext);
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
this.admissions.delete(params);
const assertActive = async () => {
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
};
await assertActive();
return this.interactionService.request(params, Object.freeze({ ...originalContext, assertActive, vaultBinding: binding }));
}
#resolveTarget(ref, expectedRevision, binding) {
const entry = this.targets.get(ref?.handle);
if (!entry || ref.kind !== "host.selection" || ref.scope !== "metadata" || entry.binding !== binding
|| ref.revision !== entry.revision || expectedRevision !== entry.revision || ref.expiresAt !== entry.expiresAt
|| this.clock() >= entry.expiresAt || entry.owner !== this.interactionService.owner
|| entry.generation !== this.interactionService.ownerGeneration) {
throw fail("permissionDenied", "Host selection expired or changed; select the host again");
}
return entry;
}
#issueTarget(resource, context) {
const entry = {
binding: context.vaultBinding, resourceId: resource.resourceId, revision: resource.revision,
owner: this.interactionService.owner, generation: this.interactionService.ownerGeneration,
expiresAt: this.clock() + TARGET_LIFETIME_MS,
};
const nonce = randomBytes(24).toString("base64url");
const signature = createHmac("sha256", this.key).update(JSON.stringify([
nonce, entry.binding, entry.owner.id, entry.generation, entry.resourceId,
entry.revision, entry.expiresAt, "host.selection", "metadata",
])).digest("base64url");
const handle = `${nonce}.${signature}`;
this.targets.set(handle, entry);
return { kind: "host.selection", scope: "metadata", handle, revision: entry.revision, expiresAt: entry.expiresAt };
}
describePrompt(entry) {
if (!entry.params.target) return {};
const target = this.#resolveTarget(entry.params.target, entry.params.expectedRevision, entry.context.vaultBinding);
return { vaultTarget: { resourceId: target.resourceId, revision: target.revision } };
}
async respond(entry, response) {
await entry.context.assertActive();
if (response.action === "fail") throw fail("cancelled", "Host resource changed or the action could not complete");
if (entry.phase === "request" && response.action === "submit") {
if (entry.params.kind === "vault.selectHosts") {
const resources = this.#validate("VaultHostSelectedResources", response.resources);
if (resources.length > MAX_SELECTED || new Set(resources.map((resource) => resource.resourceId)).size !== resources.length) {
throw fail("invalidArgument", "Host selection is invalid");
}
this.releaseSelections(entry.context.vaultBinding);
this.pruneExpired();
if (this.targets.size + resources.length > MAX_REFERENCES) throw fail("resourceExhausted", "Too many host selections");
const hosts = resources.map(({ resourceId, revision, ...metadata }) => ({
target: this.#issueTarget({ resourceId, revision }, entry.context), metadata,
}));
const groups = [...new Set(hosts.map((host) => host.metadata.group).filter(Boolean))].map((path) => ({
path, members: hosts.filter((host) => host.metadata.group === path).map((host) => host.target),
}));
return { result: { cancelled: false, hosts, groups } };
}
if (entry.params.kind === "vault.requestHostDraft") {
const draft = this.#validate(entry.params.target ? "VaultHostEditFields" : "VaultHostCreateFields", response.draft);
return { phase: "commit", reviewedDraft: draft };
}
if (typeof response.scriptsRun !== "boolean") throw fail("invalidArgument", "Connection script disclosure is required");
return { phase: "commit" };
}
if (entry.phase === "commit" && response.action === "commit") return { phase: "executing" };
if (entry.phase === "executing" && response.action === "complete") {
return { result: { cancelled: false, status: entry.params.kind === "vault.requestConnect" ? "requested" : "saved" } };
}
throw fail("invalidArgument", "Host action phase is invalid");
}
pruneExpired() { for (const [handle, entry] of this.targets) if (this.clock() >= entry.expiresAt) this.targets.delete(handle); }
releaseSelections(binding) { for (const [handle, entry] of this.targets) if (entry.binding === binding) this.targets.delete(handle); }
releaseRuntime(runtimeId) {
for (const [handle, entry] of this.targets) if (entry.binding.runtimeId === runtimeId) this.targets.delete(handle);
this.sourceBinding.releaseRuntime(runtimeId);
}
releasePlugin(pluginId) {
for (const [handle, entry] of this.targets) if (entry.binding.pluginId === pluginId) this.targets.delete(handle);
// Keep the source helper's pinned runtime binding until runtime stop. A
// revoked old runtime must never rebind itself to a replacement source.
}
clearSelections() { this.targets.clear(); }
shutdown() { this.targets.clear(); this.sourceBinding.shutdown(); this.key.fill(0); }
}
module.exports = { PluginHostVaultService, METHODS, MAX_SELECTED, TARGET_LIFETIME_MS };
@@ -2,3 +2,7 @@
// Include the isolated domain suites in npm run test:plugin-runtime.
require("./ai/modelInvocationBroker.test.cjs");
require("./context/selectedContextAuthority.test.cjs");
require("./ai/sourceBinding.test.cjs");
require("./ai/trustedHostAdapter.test.cjs");
require("./ai/aiContextService.test.cjs");
require("./ai/aiContextEntrypoint.test.cjs");
@@ -102,3 +102,70 @@ test("runtime byte streams reject concurrent reads and bound pending accept rese
endpoint.rejectReadable("reserved");
await assert.rejects(reserved, /cancel/i);
});
test("host cancellation discards buffered bytes and settles retained credit before the next read", async (t) => {
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
const { PluginStreamRouter } = require("./streamRouter.cjs");
const toHost = [];
const endpoint = createPluginStreamEndpoint({ post(message) { toHost.push(message); } });
const host = new PluginStreamRouter({ send(message) { endpoint.accept(message); } });
t.after(() => { endpoint.close(); host.close(); });
const pending = endpoint.acceptReadable("host-cancel");
const output = await host.openOutgoing("host-cancel", 1024);
const readable = await pending;
await output.write(Uint8Array.of(1));
assert.deepEqual([...await readable.read()], [1]);
await output.write(Uint8Array.of(2));
output.cancel();
const count = toHost.length;
await assert.rejects(readable.read(), /cancel/u);
await assert.rejects(readable.read(), /cancel/u);
assert.equal(toHost.length, count, "reads after cancellation must not return credit");
assert.equal(toHost.reduce((sum, message) => sum + message.frame.creditBytes, 0), 2);
for (const message of toHost) await host.accept(message);
assert.equal(host.outgoing.size, 0);
const otherPending = endpoint.acceptReadable("unrelated");
const other = await host.openOutgoing("unrelated", 1024);
await otherPending;
other.cancel();
});
test("runtime producer cancellation drains discarded credit and rejects buffered reads", async () => {
const { left, right } = await createPair();
const pending = right.acceptReadable("producer-cancel");
const writable = await left.openWritable("producer-cancel", 1024);
const readable = await pending;
await writable.write(Uint8Array.of(1));
assert.deepEqual([...await readable.read()], [1]);
await writable.write(Uint8Array.of(2));
writable.cancel();
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(readable.read(), /cancel/u);
left.close();
right.close();
});
test("browser producer cancellation settles host receive credit without resuming writes", async (t) => {
const { createPluginStreamEndpoint } = await import("./runtime/pluginStreamEndpoint.mjs");
const { PluginStreamRouter } = require("./streamRouter.cjs");
const toHost = [];
let release;
const endpoint = createPluginStreamEndpoint({ post(message) { toHost.push(message); } }, { maxStreams: 1 });
const host = new PluginStreamRouter({
send(message) { endpoint.accept(message); },
onIncomingStream({ bind }) { bind({ onChunk(_chunk, callback) { release = callback; } }); return true; },
});
t.after(() => { endpoint.close(); host.close(); });
const output = await endpoint.openWritable("browser-cancel", 1024);
await host.accept(toHost.shift());
await output.write(new Uint8Array(1024));
await host.accept(toHost.shift());
const queued = output.write(Uint8Array.of(1));
output.cancel();
await assert.rejects(queued, /cancel/u);
await host.accept(toHost.shift());
release();
assert.equal(toHost.length, 0);
assert.equal(host.incoming.size, 0);
await endpoint.openWritable("after-cancel", 1024);
});
@@ -86,16 +86,28 @@ export function createPluginStreamEndpoint(transport, options = {}) {
}
}
function closeIncoming(state, error, notify = false) {
function closeIncoming(state, error, notify = false, refundCancelledCredit = false) {
if (state.closed) return;
state.closed = true;
state.error = error ?? null;
incoming.delete(state.streamId);
if (error) {
// Discard unread bytes on cancellation/failure and settle their credit
// now. A later read must surface the error without sending more credit.
state.currentCreditBytes += state.queue.reduce((sum, chunk) => sum + chunk.creditBytes, 0);
state.queue.length = 0;
// A consumer-initiated cancel rejects the producer's queued writes.
// Returning credit first could let that producer send another chunk.
if (refundCancelledCredit) {
try { releaseCurrent(state); } catch {}
}
state.currentCreditBytes = 0;
}
if (notify) {
try {
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence,
sequence: Math.max(1, state.updateSequence + 1),
kind: "cancel",
});
} catch {}
@@ -221,16 +233,21 @@ export function createPluginStreamEndpoint(transport, options = {}) {
cancel() {
if (state.closed) return;
state.closed = true;
outgoing.delete(state.streamId);
state.terminal = "cancel";
const error = new PluginError("cancelled", "Plugin stream was cancelled");
for (const pending of state.queue.splice(0)) pending.reject(error);
state.queuedBytes = 0;
state.endReject?.(error);
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence + 1,
kind: "cancel",
});
try {
sendEnvelope({
streamId: state.streamId,
sequence: state.nextSequence + 1,
kind: "cancel",
});
state.terminalSent = true;
} finally {
if (!state.terminalSent || state.availableBytes === state.windowBytes) outgoing.delete(state.streamId);
}
},
dispose() { this.cancel(); },
});
@@ -314,6 +331,8 @@ export function createPluginStreamEndpoint(transport, options = {}) {
: frame.kind === "cancel"
? new PluginError("cancelled", `Plugin stream peer cancelled: ${frame.streamId}`)
: null,
false,
frame.kind === "cancel",
);
return true;
}
-4
View File
@@ -380,9 +380,6 @@ function createPluginContext(config, client, runtimeApi) {
quickPick: (title, items) => interact({ kind: "quickPick", title, items }),
input: (title, initialValue) => interact({ kind: "input", title, ...(initialValue === undefined ? {} : { initialValue }) }),
};
const vault = Object.fromEntries(["selectHosts", "requestHostDraft", "requestConnect"].map((method) => [
method, (params) => client.request(`vault.${method}`, params, { deadlineMs: PLUGIN_HOST_INTERACTION_LIMITS.timeoutMs }),
]));
const clipboard = {
readText: (title) => interact({ kind: "clipboard.read", title }),
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
@@ -577,7 +574,6 @@ function createPluginContext(config, client, runtimeApi) {
ai,
selectedContext,
ui,
vault,
clipboard,
filesystem,
companions,
+13 -3
View File
@@ -88,6 +88,7 @@ class PluginStreamRouter {
streamId: frame.streamId,
nextSequence: 1,
availableBytes: frame.windowBytes,
windowBytes: frame.windowBytes,
updateSequence: -1,
openController: new AbortController(),
closed: false,
@@ -139,7 +140,7 @@ class PluginStreamRouter {
if (outgoing.availableBytes > outgoing.maxCreditBytes) {
throw new Error(`Plugin stream credit exceeds its negotiated window: ${frame.streamId}`);
}
if (outgoing.terminalKind === "end") {
if (outgoing.terminalKind === "end" || outgoing.terminalKind === "cancel") {
if (outgoing.availableBytes === outgoing.maxCreditBytes) {
this.outgoing.delete(frame.streamId);
}
@@ -216,7 +217,17 @@ class PluginStreamRouter {
? frame.error
: new Error(`Plugin stream ${frame.kind}: ${frame.streamId}`);
state.openController.abort(abortReason);
await state.onClose?.(closeReason);
try {
if (frame.kind === "cancel" && state.availableBytes < state.windowBytes) {
// The producer has stopped. Return discarded credit once so it can
// retire its terminal state, without releasing more queued writes.
state.updateSequence += 1;
this.send({ frame: {
streamId: state.streamId, sequence: state.updateSequence,
kind: "windowUpdate", creditBytes: state.windowBytes - state.availableBytes,
} });
}
} finally { await state.onClose?.(closeReason); }
}
bindIncoming(streamId, handlers) {
@@ -379,7 +390,6 @@ class PluginStreamRouter {
state.queuedBytes = 0;
if (
!terminalSent
|| kind !== "end"
|| state.availableBytes === state.maxCreditBytes
) {
this.outgoing.delete(state.streamId);
+69
View File
@@ -5,6 +5,17 @@ const test = require("node:test");
const { PluginStreamRouter } = require("./streamRouter.cjs");
test("outgoing close hook observes idle peer cancellation exactly once", async () => {
const closed = [];
const router = new PluginStreamRouter({ send() {} });
const stream = await router.openOutgoing("idle-close-hook", 1024, { onClose: (reason) => closed.push(reason) });
await router.accept({ frame: { streamId: "idle-close-hook", sequence: 1, kind: "cancel" } });
stream.cancel();
router.close();
assert.deepEqual(closed, ["cancel"]);
assert.equal(router.outgoing.size, 0);
});
test("incoming stream credit is returned only after the consumer releases a chunk", async () => {
const sent = [];
const received = [];
@@ -144,6 +155,64 @@ test("ended outgoing streams accept final receive credit before retiring", async
assert.equal(router.outgoing.has("ended-output"), false);
});
test("cancelled outgoing streams accept only ordered outstanding credit and release their slot", async () => {
const router = new PluginStreamRouter({ send() {}, maxStreams: 1 });
const stream = await router.openOutgoing("cancel-in-flight", 1024);
await stream.write(new Uint8Array(1024));
const queued = stream.write(Uint8Array.of(1));
stream.cancel();
await assert.rejects(queued, /cancel/u);
await assert.rejects(stream.write(Uint8Array.of(1)), /closed/u);
const credit = (streamId, sequence, creditBytes) => ({ frame: { streamId, sequence, kind: "windowUpdate", creditBytes } });
await assert.rejects(router.accept(credit("unknown", 0, 1)), /Unknown outgoing/u);
await assert.rejects(router.accept(credit("cancel-in-flight", 1, 1)), /Out-of-order/u);
await assert.rejects(router.accept(credit("cancel-in-flight", 0, 0)), /contract|creditBytes/u);
await router.accept(credit("cancel-in-flight", 0, 512));
await assert.rejects(router.accept(credit("cancel-in-flight", 0, 1)), /Out-of-order/u);
await router.accept(credit("cancel-in-flight", 1, 512));
assert.equal(router.outgoing.size, 0);
await assert.rejects(router.accept(credit("cancel-in-flight", 2, 1)), /Unknown outgoing/u);
const other = await router.openOutgoing("next", 1024);
other.cancel();
assert.equal(router.outgoing.size, 0);
});
test("cancelled outgoing streams reject excess credit and clear retained state at runtime close", async () => {
const router = new PluginStreamRouter({ send() {} });
const stream = await router.openOutgoing("cancel-excess", 1024);
await stream.write(Uint8Array.of(1));
stream.cancel();
await assert.rejects(router.accept({ frame: {
streamId: "cancel-excess", sequence: 0, kind: "windowUpdate", creditBytes: 2,
} }), /exceeds/u);
router.close();
assert.equal(router.outgoing.size, 0);
});
test("failed cancellation credit refund still aborts and cleans up its incoming owner", async () => {
let ownerSignal;
let closeReason;
const router = new PluginStreamRouter({
send() { throw new Error("refund transport failed"); },
onIncomingStream({ signal, bind }) {
ownerSignal = signal;
bind({ onChunk() {}, onClose(reason) { closeReason = reason; } });
return true;
},
});
await router.accept({ frame: { streamId: "cancel-refund", sequence: 0, kind: "open", windowBytes: 1024 } });
await router.accept({ frame: {
streamId: "cancel-refund", sequence: 1, kind: "chunk",
data: { encoding: "base64", value: "AQ==", byteLength: 1 },
} });
await assert.rejects(router.accept({ frame: {
streamId: "cancel-refund", sequence: 2, kind: "cancel",
} }), /refund transport failed/u);
assert.equal(ownerSignal.aborted, true);
assert.equal(closeReason, "cancel");
assert.equal(router.incoming.size, 0);
});
test("unhandled incoming streams are cancelled immediately", async () => {
const sent = [];
const router = new PluginStreamRouter({ send(message) { sent.push(message); } });
+7
View File
@@ -108,6 +108,13 @@ function createPreloadApi(ctx) {
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
respondPluginAIHost: (response) => ipcRenderer.invoke("netcatty:plugins:ai-host-response", response),
onPluginAIHostRequest: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("netcatty:plugins:ai-host-request", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:ai-host-request", listener);
},
setPluginInteractionReady: (ready) => ipcRenderer.invoke("netcatty:plugins:interaction-ready", { ready }),
respondPluginInteraction: (response) => ipcRenderer.invoke("netcatty:plugins:interaction-response", response),
onPluginInteraction: (callback) => {
@@ -0,0 +1,23 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const { createPreloadApi } = require("./api.cjs");
test("trusted AI preload uses dedicated typed channels and disposes subscriptions", async () => {
const calls = []; const ipcRenderer = new EventEmitter();
ipcRenderer.invoke = async (...args) => { calls.push(args); return true; };
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
await api.setPluginAIHostReady(true);
const reply = { requestId: "request", actionToken: "token", error: true };
await api.respondPluginAIHost(reply);
assert.deepEqual(calls, [["netcatty:plugins:ai-host-ready", true], ["netcatty:plugins:ai-host-response", reply]]);
const received = []; const off = api.onPluginAIHostRequest((request) => received.push(request));
const request = { kind: "model.select" };
ipcRenderer.emit("netcatty:plugins:ai-host-request", {}, request); off();
ipcRenderer.emit("netcatty:plugins:ai-host-request", {}, { kind: "context.select" });
assert.deepEqual(received, [request]);
assert.doesNotMatch(fs.readFileSync(path.join(__dirname, "../plugins/runtime/viewPreload.cjs"), "utf8"), /ai-host-|PluginAIHost/);
});
+2
View File
@@ -0,0 +1,2 @@
.package/
*.ncpkg
+18 -13
View File
@@ -1,16 +1,21 @@
# Assistant example source
# Selected Note Assistant
This panel contains the minimal example UI: a host-selected model, one explicitly
selected Note, Send, streamed text, Stop, and token usage. It uses the existing
`netcattyView.executeCommand` surface. It has no direct network access or Node API.
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 runtime entry and package manifest are pending the parent's shared contract
and SDK decision. This directory is not yet an installable plugin.
The command names in `panel.js` are example-local command IDs, not host RPC methods.
A menu or command ID grants no authority to read a resource.
The host must support `ai.model-invocation` and `context.selected-read`, with the
trusted renderer adapter connected. AI invocation, selected-summary enumeration,
and selected-content reading have separate optional permissions. The host shows
the exact selected text for review before the plugin or model receives it.
A menu or command ID grants no authority over a resource.
The QA harness binds these commands to `ModelInvocationBroker` and
`SelectedContextAuthority`. The production runtime must bind them through the
approved canonical SDK methods, with host permission checks and provenance.
The local deterministic provider proves the pipeline. It does not perform model
inference, use paid credentials, or read a real user's data.
Build the six-file staging directory with `node build.mjs`, then pack `.package`
with Netcatty's plugin CLI. Build scripts are not included in the installed package.
The plugin uses only the canonical `context.ai` and `context.selectedContext` SDK
methods through its own declared view commands. No chat history is persisted.
For isolated QA, use the deterministic loopback provider and synthetic Note recipe
in `docs/plugin-platform/assistant-native-qa.md` in the Netcatty repository. Fixture
QA performs no model inference or paid API call. Native acceptance and the remaining
independent review are separate requirements.
+10 -2
View File
@@ -1,4 +1,12 @@
import { mkdir, copyFile } from 'node:fs/promises';
import { URL } from 'node:url';
import { mkdir, copyFile, rm } from 'node:fs/promises';
await mkdir(new URL('./dist/', import.meta.url), { recursive: true });
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', import.meta.url));
import { URL } from 'node:url';
// Build a six-file staging directory. Build scripts and source tooling are never
// part of the installed browser package.
const staging = new URL('./.package/', import.meta.url);
await rm(staging, { recursive: true, force: true });
await mkdir(new URL('./dist/', staging), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md', 'dist/index.js']) {
await copyFile(new URL(file, import.meta.url), new URL(file, staging));
}
+6 -1
View File
@@ -27,6 +27,8 @@ async function initialize() {
element('status').textContent = 'Ready';
busy(false);
} catch {
const failedChat = chat; chat = undefined;
if (failedChat) await invoke('close', { chatHandle: failedChat.chatHandle }).catch(() => {});
element('status').textContent = 'Choose a model and grant access in Netcatty.';
}
}
@@ -73,5 +75,8 @@ element('stop').addEventListener('click', async () => {
window.netcattyView.onDidReceiveMessage((message) => {
if (message?.qaFixture === true) element('qa').textContent = 'Isolated QA · deterministic local provider · no model inference or paid API';
});
window.addEventListener('pagehide', () => { if (turn) invoke('stop', { turnHandle: turn }).catch(() => {}); });
window.addEventListener('pagehide', () => {
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
});
void initialize();
@@ -0,0 +1,40 @@
# Read one disposable database row
This browser plugin tests the generic `network.byte-streams` feature and exact
`network.stream` permission. The database protocol and fixed SELECT query live
only in this example. The host transports bounded bytes.
Prepare a package for one private IPv4 fixture endpoint:
```sh
npm run build:plugin-runtime-deps
node examples/plugins/database-minimal/prepare.mjs tcp://10.77.0.20:3306 /tmp/database-example.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/database-example.ncpkg
sha256sum /tmp/database-example.ncpkg
```
The address above is a packaging example. Preparation does not connect to it.
Use the actual disposable fixture address for native testing. A different
endpoint produces a different manifest, bundle, and package hash.
Import the package through Settings > Plugins, enable it, and open **Disposable
database example**. Enter only the fixture's session credentials. Deny the first
endpoint prompt, then retry and grant that exact endpoint. A successful read
shows `synthetic stream result`. **Cancel** stops the active operation.
The fixture must be reachable at a private address belonging to a different
machine or container, rather than an address assigned to the app's machine.
Loopback, the app machine's interface addresses, and mapped aliases remain
blocked. Do not replace the endpoint with a local SSH forwarding address.
The example supports protocol 4.1 and `mysql_native_password` over plain TCP.
Its server account needs only SELECT on `plugin_demo.example_result`. The query
is `SELECT value FROM plugin_demo.example_result LIMIT 1`. Server grants enforce
read-only access. The example does not support database TLS or STARTTLS.
Credentials are session-only and are not written to plugin settings, logs, or
secrets. JavaScript does not guarantee memory zeroization. The plugin has no
query editor, schema browser, or connection manager.
Follow [the native handoff](../../../docs/plugin-platform/byte-stream-native-qa.md)
for fixture prerequisites, screenshots, video, cancellation, and cleanup.
@@ -0,0 +1,47 @@
"use strict";
/* global __dirname */
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const test = require("node:test");
test("the example clears session passwords before dispatch and renders result text safely", async () => {
const elements = new Map();
for (const id of ["username", "password", "query", "cancel", "result", "form", "endpoint"]) {
const element = { value: "", textContent: "", disabled: false, listeners: {},
addEventListener(event, listener) { this.listeners[event] = listener; },
};
Object.defineProperty(element, "innerHTML", { set() { throw new Error("HTML output is forbidden"); } });
elements.set(id, element);
}
elements.get("username").value = "plugin_demo";
elements.get("password").value = "synthetic-session-only";
let settle;
let submitted;
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "panel.js"), "utf8"), {
document: { getElementById: (id) => elements.get(id), documentElement: { style: { setProperty() {}, removeProperty() {} } } },
window: { netcattyView: {
onDidChangeEnvironment() {},
executeCommand: async (name, params) => {
if (name.endsWith(".endpoint")) return { endpoint: "tcp://172.30.247.2:3306" };
assert.ok(name.endsWith(".query"));
assert.equal(elements.get("password").value, "");
submitted = params;
return new Promise((resolve) => { settle = resolve; });
},
} },
});
const pending = elements.get("form").listeners.submit({ preventDefault() {} });
assert.equal(submitted.password, "synthetic-session-only");
assert.equal(elements.get("query").disabled, true);
settle({ columns: ["value"], rows: [["<img src=x onerror=alert(1)>"]] });
await pending;
assert.equal(submitted.password, "");
assert.equal(elements.get("password").value, "");
assert.equal(elements.get("query").disabled, false);
assert.match(elements.get("result").textContent, /<img/u);
const html = fs.readFileSync(path.join(__dirname, "panel.html"), "utf8");
assert.match(html, /id="password"[^>]*type="password"/u);
});
@@ -25,7 +25,7 @@ export async function prepareDatabaseExample(targetEndpoint, outputPath) {
for (const file of ["browser.js", "protocol.mjs", "panel.html", "panel.css", "panel.js"]) await fs.copyFile(path.join(root, file), path.join(temporary, file));
await fs.writeFile(path.join(temporary, "fixture-config.mjs"), `export const endpoint = ${JSON.stringify(endpoint)};\n`);
const bundle = await build({ entryPoints: [path.join(temporary, "browser.js")], write: false,
bundle: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
bundle: true, minify: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
await fs.rm(path.join(temporary, "protocol.mjs"));
await fs.rm(path.join(temporary, "fixture-config.mjs"));
@@ -1,4 +1,5 @@
"use strict";
/* global __dirname */
// Creates only an isolated disposable fixture. Never accepts a user host,
// existing database, database URL, or real credentials from the environment.
@@ -8,7 +9,13 @@ const { execFileSync } = require("node:child_process");
const { randomUUID, randomBytes } = require("node:crypto");
const { pathToFileURL } = require("node:url");
const path = require("node:path");
const { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
const process = require("node:process");
const { setTimeout } = require("node:timers");
const fs = require("node:fs/promises");
const os = require("node:os");
const { PluginByteStreamBroker } = require("../../../electron/plugins/byteStreamBroker.cjs");
const { createPluginHostService } = require("../../../electron/plugins/hostService.cjs");
const { inProcessBrowserRuntime } = require("../../../electron/plugins/fixtures/inProcessBrowserRuntime.cjs");
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
function docker(args, input) {
@@ -23,11 +30,15 @@ test("minimal browser protocol queries a disposable MariaDB through the real byt
const network = `netcatty-stream-test-${suffix}`;
const container = `netcatty-stream-test-${suffix}`;
const sessionPassword = randomBytes(24).toString("hex");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-db-entry-"));
let service;
docker(["network", "create", "--internal", network]);
let created = false;
t.after(() => {
t.after(async () => {
await service?.manager.shutdown();
if (created) docker(["rm", "--force", container]);
docker(["network", "rm", network]);
await fs.rm(root, { recursive: true, force: true });
});
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
@@ -59,7 +70,7 @@ GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
const { handleId } = await broker.connect(endpoint, context);
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
__dirname, "./protocol.mjs",
)).href);
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
const result = await queryExample({
@@ -69,6 +80,34 @@ GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
}, "plugin_demo", sessionPassword);
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
assert.equal(broker.connections.size, 0);
const { prepareDatabaseExample } = await import("./prepare.mjs");
const archive = path.join(root, "database-minimal.ncpkg");
const packed = await prepareDatabaseExample(endpoint, archive);
assert.equal(packed.fileCount, 5);
const decisions = [];
service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
electron: {}, env: {},
requestPermissionDecision: async (request) => {
decisions.push(request);
return { requestId: request.requestId, decision: "allow", scope: "application" };
},
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
await service.manager.install(archive, { enable: true });
const installedResult = await service.contributionService.executeCommand("com.netcatty.database-minimal.query", {
username: "plugin_demo", password: sessionPassword,
});
assert.deepEqual(installedResult, result);
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
assert.equal(service.byteStreamService.records.size, 0);
assert.equal(service.byteStreamService.broker.connections.size, 0);
const audits = JSON.stringify(service.database.listSecurityAudit("com.netcatty.database-minimal", 100));
assert.equal(audits.includes(sessionPassword), false);
assert.equal(audits.includes(READ_QUERY), false);
assert.equal(audits.includes("synthetic stream result"), false);
assert.equal(audits.includes("network.stream.open"), true);
// Server-side grants, not a SQL-prefix check, enforce the read-only fixture.
// MYSQL_PWD exists only inside this disposable test exec and is never logged.
assert.throws(() => execFileSync("docker", ["exec", "--interactive", container, "sh", "-c",
+13 -3
View File
@@ -128,7 +128,7 @@ function greeting(bytes) {
}
/** stream is a plugin-owned browser adapter over the generic host byte stream. */
export async function queryExample(stream, username, sessionPassword) {
async function queryFixedExample(stream, username, sessionPassword, database, query) {
if (!/^[A-Za-z0-9_]{1,32}$/u.test(username) || typeof sessionPassword !== "string" || sessionPassword.length > 256) {
throw new Error("Enter the disposable fixture username and session password");
}
@@ -139,10 +139,10 @@ export async function queryExample(stream, username, sessionPassword) {
sessionPassword = "";
try {
await packets.write(concat(uint32(CLIENT_FLAGS), uint32(MAX_PACKET), Uint8Array.of(45), new Uint8Array(23),
zeroText(username), Uint8Array.of(token.length), token, zeroText("plugin_demo"), zeroText("mysql_native_password")), 1);
zeroText(username), Uint8Array.of(token.length), token, zeroText(database), zeroText("mysql_native_password")), 1);
} finally { token.fill(0); nonce.fill(0); }
if ((await packets.read(2))[0] !== 0) reject();
await packets.write(concat(Uint8Array.of(3), encoder.encode(READ_QUERY)), 0);
await packets.write(concat(Uint8Array.of(3), encoder.encode(query)), 0);
const count = await packets.read(1);
if (count.length !== 1 || count[0] !== 1) reject();
const column = new Cursor(await packets.read(2));
@@ -161,3 +161,13 @@ export async function queryExample(stream, username, sessionPassword) {
await stream.close();
}
}
export function queryExample(stream, username, sessionPassword) {
return queryFixedExample(stream, username, sessionPassword, "plugin_demo", READ_QUERY);
}
// This second fixed profile is a public third-party acceptance candidate.
// No caller can supply a database, query, username, or password to this entry.
export function queryRfamReadOnly(stream) {
return queryFixedExample(stream, "rfamro", "", "Rfam", "SELECT 1 AS value");
}
@@ -0,0 +1,57 @@
"use strict";
const assert = require("node:assert/strict");
const { Buffer } = require("node:buffer");
const test = require("node:test");
function bytes(...parts) { return Buffer.concat(parts.map((part) => Buffer.from(part))); }
function text(value) { return bytes(Buffer.from(value), [0]); }
function packet(sequence, payload) { return bytes([payload.length & 255, payload.length >>> 8, 0, sequence], payload); }
function greeting(plugin = "mysql_native_password") {
const flags = 1 | 4 | 8 | 0x200 | 0x2000 | 0x8000 | 0x80000;
return bytes([10], text("synthetic-offline-server"), [1, 0, 0, 0], new Uint8Array(8).fill(1),
[0, flags & 255, (flags >>> 8) & 255, 45, 0, 0, (flags >>> 16) & 255, (flags >>> 24) & 255, 21],
new Uint8Array(10), new Uint8Array(12).fill(2), [0], text(plugin));
}
function streamFixture(plugin) {
const column = bytes(...["def", "synthetic", "", "", "value"].map((value) => bytes([value.length], Buffer.from(value))));
const eof = [0xfe, 0, 0, 2, 0];
let pending = bytes(packet(0, greeting(plugin)), packet(2, [0]), packet(1, [1]),
packet(2, column), packet(3, eof), packet(4, [1, 49]), packet(5, eof));
const writes = [];
let closes = 0;
return {
writes, get closes() { return closes; },
stream: {
async read() { const value = pending; pending = null; return value; },
async write(value) { writes.push(Buffer.from(value)); },
async close() { closes++; },
},
};
}
test("owned fixture and public Rfam entries each send only their fixed database and query offline", async () => {
const { queryExample, queryRfamReadOnly, READ_QUERY } = await import("./protocol.mjs");
for (const profile of [
{ run: (stream) => queryExample(stream, "plugin_demo", ""), user: "plugin_demo", database: "plugin_demo", query: READ_QUERY },
{ run: queryRfamReadOnly, user: "rfamro", database: "Rfam", query: "SELECT 1 AS value" },
]) {
const f = streamFixture();
assert.deepEqual(await profile.run(f.stream), { columns: ["value"], rows: [["1"]] });
assert.equal(f.writes.length, 2);
const response = f.writes[0].subarray(4);
const expectedTail = bytes(text(profile.user), [0], text(profile.database), text("mysql_native_password"));
assert.deepEqual(response.subarray(32), expectedTail);
assert.equal(f.writes[1][4], 3);
assert.equal(f.writes[1].subarray(5).toString(), profile.query);
assert.equal(f.closes, 1);
}
});
test("public profile rejects unsupported authentication offline without a fallback", async () => {
const { queryRfamReadOnly } = await import("./protocol.mjs");
const f = streamFixture("caching_sha2_password");
await assert.rejects(queryRfamReadOnly(f.stream), /Unsupported or invalid/u);
assert.equal(f.writes.length, 0);
assert.equal(f.closes, 1);
});
@@ -0,0 +1,77 @@
# Prepare the public Rfam read-only acceptance sample
This is a public third-party acceptance candidate, separate from the owned
MariaDB fixture. It uses the existing generic byte-stream capability. No host
database business, server-specific production API, or endpoint-policy exception
is added.
[Rfam's official database documentation](https://docs.rfam.org/en/latest/database.html)
lists `mysql-rfam-public.ebi.ac.uk:4497`, database `Rfam`, public user `rfamro`,
and no password. The sample sends only `SELECT 1 AS value`. It does not read Rfam
tables and offers no query, host, database, or credential input. The documented
public account name is compiled into the example. No private credential is used
or persisted, and the sample has no storage or secret permission.
## Build the candidate without connecting
Use the integration head and locked dependencies:
```sh
npm run build:plugin-runtime-deps
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 validate /tmp/database-rfam-readonly.ncpkg
sha256sum /tmp/database-rfam-readonly.ncpkg
```
The separate preparation command accepts only that exact endpoint. It performs
no DNS lookup, connection, query, plugin installation, or permission activation.
It creates five files: a manifest, browser bundle, and three panel assets.
The owned fixture's preparation command still rejects public hostnames.
Package identity and permission scope:
- ID: `com.netcatty.database-rfam-readonly`, version `0.1.0`.
- Runtime: browser only. Required feature: `network.byte-streams`.
- Required permissions: `commands` and `views` for its own callbacks and panel.
- Optional permission: `network.stream`, resource exactly
`tcp://mysql-rfam-public.ebi.ac.uk:4497`.
- Five-file package: 11,003 bytes. Two builds matched byte for byte with SHA-256
`a9d56b69a4d2c587a4712d1b2fe395c381bcf8b15e34d476c4ba8b5ca967c35a`.
- Expected result after a separately authorized compatible run:
`{"columns":["value"],"rows":[["1"]]}`.
## Obtain authorization before native acceptance
Record the exact integration commit, package hash, ID, version, and permissions.
The parent must obtain authorization for that specific package and public
endpoint before installation, enabling permissions, or querying the service.
No connection or query has been performed as part of preparing this candidate.
After authorization, use the available native desktop and a disposable profile.
Record the local-import source, exact endpoint prompt, denial, retry, fixed
result, cancellation, and post-revocation recovery. Opening the panel only reads
its endpoint metadata. A connection starts only from **Run fixed public query**.
Do not treat this public service as an isolated synthetic fixture or evidence of
database TLS or native sandbox certification.
## Stop if the protocol is unsupported
The reused example protocol supports only protocol 4.1 and
`mysql_native_password`. It uses plain TCP and does not implement MySQL STARTTLS,
auth-plugin switching, or other authentication methods. The service's current
authentication and TLS requirements remain unverified. An unsupported greeting
or server rejection fails closed and closes the byte stream. Record a sanitized
failure and stop. Do not add an authentication fallback or TLS bypass for QA.
Offline protocol tests inspect synthetic packet bytes for the two fixed
profiles and reject an unsupported auth plugin. They do not contact Rfam or
activate plugin permissions:
```sh
npm run test:plugin-examples
```
The owned MariaDB regression remains at
`examples/plugins/database-minimal/protocol.live.test.cjs`. Its explicit Docker
opt-in, isolated network, temporary account, SELECT result, and server-side write
denial remain separate from this public-service candidate.
@@ -0,0 +1,34 @@
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
import { queryRfamReadOnly } from "../database-minimal/protocol.mjs";
const endpoint = "tcp://mysql-rfam-public.ebi.ac.uk:4497";
export default {
activate(context) {
let pending;
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
`com.netcatty.database-rfam-readonly.${suffix}`, handler,
));
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
register("cancel", cancel);
register("endpoint", () => ({ endpoint }));
context.subscriptions.add({ dispose: cancel });
register("query", async (args) => {
if (args !== undefined && args !== null) throw new Error("The public sample accepts no query or credential parameters");
if (pending) throw new Error("A public sample request is already active");
const cancellation = new CancellationTokenSource();
pending = cancellation;
let connection;
try {
connection = await context.network.connect({ endpoint }, cancellation.token);
return await queryRfamReadOnly({
read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
close: () => connection.close(),
});
} finally {
await connection?.close().catch(() => {});
cancellation.dispose();
pending = undefined;
}
});
},
};
@@ -0,0 +1,26 @@
{
"manifestVersion": 1,
"id": "com.netcatty.database-rfam-readonly",
"name": "database-rfam-readonly",
"displayName": "Rfam public read-only acceptance sample",
"description": "Public third-party sample: one fixed SELECT 1 through an exact approved byte-stream endpoint. Compatibility is unverified.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"features": { "required": ["network.byte-streams"] },
"main": { "browser": "browser.js" },
"activationEvents": ["onCommand:com.netcatty.database-rfam-readonly.query", "onView:com.netcatty.database-rfam-readonly.panel"],
"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." }]
},
"contributes": {
"commands": [
{ "id": "com.netcatty.database-rfam-readonly.query", "title": "Run public Rfam SELECT 1" },
{ "id": "com.netcatty.database-rfam-readonly.cancel", "title": "Cancel public Rfam sample" },
{ "id": "com.netcatty.database-rfam-readonly.endpoint", "title": "Show public Rfam endpoint" }
],
"views": [{ "id": "com.netcatty.database-rfam-readonly.panel", "title": "Rfam public read-only sample", "location": "modal", "entry": "panel.html" }]
}
}
@@ -0,0 +1 @@
:root{color-scheme:dark light;font:14px system-ui,sans-serif;color:hsl(var(--foreground,210 20% 90%));background:hsl(var(--background,220 15% 12%))}*{box-sizing:border-box}body{margin:0}main{max-width:640px;padding:24px;margin:auto}h1{font-size:24px}.eyebrow{font-size:11px;letter-spacing:.1em;opacity:.7}p{line-height:1.5}#endpoint,pre{overflow-wrap:anywhere;white-space:pre-wrap}label{display:block;margin:16px 0 6px}input{width:100%;padding:10px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;background:hsl(var(--background,220 15% 12%));color:inherit}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}button{padding:10px 16px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;color:inherit;background:hsl(var(--secondary,220 12% 22%));cursor:pointer}button:disabled{opacity:.5;cursor:default}input:focus-visible,button:focus-visible{outline:2px solid hsl(var(--primary,210 90% 60%));outline-offset:2px}pre{padding:14px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;min-height:80px}
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Rfam public read-only sample</title><link rel="stylesheet" href="panel.css"></head>
<body><main><p class="eyebrow">PUBLIC THIRD-PARTY ACCEPTANCE SAMPLE</p><h1>Rfam: SELECT 1</h1>
<p>This contacts Rfam's public read-only MySQL service. It is not an isolated fixture. The fixed query is SELECT 1 AS value in Rfam, using the documented public account with no password.</p>
<p>This sample uses plain TCP. Authentication and TLS compatibility remain unverified. Run only after the package and exact endpoint are authorized.</p>
<p id="endpoint">Loading exact endpoint…</p>
<div class="actions"><button id="query" type="button">Run fixed public query</button><button id="cancel" type="button" disabled>Cancel</button></div>
<pre id="result" role="status" aria-live="polite">Prepared only. No connection starts until you click Run.</pre>
</main><script src="panel.js"></script></body></html>
@@ -0,0 +1,28 @@
/* global window, document */
"use strict";
const query = document.getElementById("query");
const cancel = document.getElementById("cancel");
const result = document.getElementById("result");
const command = (suffix) => window.netcattyView.executeCommand(`com.netcatty.database-rfam-readonly.${suffix}`);
window.netcattyView.onDidChangeEnvironment((environment) => {
document.documentElement.style.colorScheme = environment.theme === "light" ? "light" : "dark";
for (const name of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"]) {
const value = environment.themeTokens?.[name];
if (typeof value === "string" && value.length <= 96) document.documentElement.style.setProperty(name, value);
else document.documentElement.style.removeProperty(name);
}
});
void command("endpoint").then((value) => { document.getElementById("endpoint").textContent = value.endpoint; });
query.addEventListener("click", async () => {
query.disabled = true;
cancel.disabled = false;
result.textContent = "Requesting the exact public endpoint…";
try { result.textContent = JSON.stringify(await command("query"), null, 2); }
catch { result.textContent = "Public sample failed. Authentication, TLS requirements, and service reachability may be unsupported. No fallback is attempted."; }
finally { query.disabled = false; cancel.disabled = true; }
});
cancel.addEventListener("click", async () => {
cancel.disabled = true;
try { await command("cancel"); result.textContent = "Public sample cancelled."; }
catch { result.textContent = "Could not cancel the public sample."; }
});
@@ -0,0 +1,31 @@
import fs from "node:fs/promises";
import process from "node:process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildPluginPackage } from "@netcatty/plugin-cli";
import { build } from "esbuild";
export const RFAM_ENDPOINT = "tcp://mysql-rfam-public.ebi.ac.uk:4497";
const root = path.dirname(fileURLToPath(import.meta.url));
export async function prepareRfamExample(targetEndpoint, outputPath) {
// This separate sample accepts one documented hostname. It does not change
// the private-fixture preparer, host endpoint policy, or runtime permissions.
if (targetEndpoint !== RFAM_ENDPOINT) throw new Error("Use only the documented exact Rfam sample endpoint");
const temporary = await fs.mkdtemp(path.join(path.dirname(outputPath), ".rfam-example-"));
try {
for (const file of ["netcatty.plugin.json", "panel.html", "panel.css", "panel.js"]) {
await fs.copyFile(path.join(root, file), path.join(temporary, file));
}
const bundle = await build({ entryPoints: [path.join(root, "browser.js")], write: false,
bundle: true, minify: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
return await buildPluginPackage(temporary, outputPath);
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const [endpoint, outputPath] = process.argv.slice(2);
if (!endpoint || !outputPath) throw new Error("Usage: node prepare.mjs tcp://mysql-rfam-public.ebi.ac.uk:4497 <output.ncpkg>");
process.stdout.write(`${JSON.stringify(await prepareRfamExample(endpoint, path.resolve(outputPath)))}\n`);
}
@@ -0,0 +1,15 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
test("public preparation accepts only its exact documented endpoint and private preparation stays private", async () => {
const { prepareRfamExample } = await import("./prepare.mjs");
const { prepareDatabaseExample } = await import("../database-minimal/prepare.mjs");
for (const endpoint of ["tcp://example.org:4497", "tcp://mysql-rfam-public.ebi.ac.uk:3306",
"tcp://mysql-rfam-public.ebi.ac.uk:4497/", "tls://mysql-rfam-public.ebi.ac.uk:4497",
"tcp://127.0.0.1:4497", "tcp://10.0.0.9:4497"]) {
await assert.rejects(prepareRfamExample(endpoint, "/unused/output.ncpkg"), /exact Rfam/u);
}
await assert.rejects(prepareDatabaseExample("tcp://mysql-rfam-public.ebi.ac.uk:4497", "/unused/output.ncpkg"), /private IPv4/u);
});
-19
View File
@@ -1,19 +0,0 @@
# Host action sample
The sample adds four commands to the command palette. It runs as an offline,
sandboxed browser plugin and requests no network, filesystem, or credential access.
`select` asks Netcatty to share explicitly chosen current hosts. The result contains
saved labels, addresses, usernames, groups, tags, and metadata-only target references.
Group results contain the chosen current members and never include future members.
`create` opens a host-owned SSH draft for `synthetic.invalid`. `edit` proposes a
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.
All sample test resources use synthetic hostnames. Native acceptance must use
synthetic hosts and must not establish a real host connection.
-31
View File
@@ -1,31 +0,0 @@
// This offline BrowserWindow plugin has no filesystem, network or credential
// permissions. Netcatty owns every picker, field diff and connection prompt.
export default {
activate(context) {
const select = () => context.vault.selectHosts({ title: 'Share saved host names, addresses, usernames, groups and tags' });
const commands = {
select,
create: () => context.vault.requestHostDraft({ draft: {
label: 'Synthetic SSH host', hostname: 'synthetic.invalid', username: 'example', group: 'Samples', tags: ['synthetic'],
} }),
edit: async () => {
const selection = await select();
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
const host = selection.hosts[0];
return context.vault.requestHostDraft({
target: host.target, expectedRevision: host.target.revision,
draft: { label: `${host.metadata.label} (reviewed)` },
});
},
connect: async () => {
const selection = await select();
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
const target = selection.hosts[0].target;
return context.vault.requestConnect({ target, expectedRevision: target.revision });
},
};
for (const [name, execute] of Object.entries(commands)) {
context.subscriptions.add(context.commands.registerCommand(`com.netcatty.host-actions.${name}`, execute));
}
},
};
@@ -1,37 +0,0 @@
{
"manifestVersion": 1,
"id": "com.netcatty.host-actions",
"name": "host-actions",
"displayName": "Host action sample",
"description": "Explicit host metadata sharing, reviewed SSH drafts, and confirmed saved-host connections.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"features": { "required": ["vault.host-actions", "commands.interactive"] },
"main": { "browser": "browser.js" },
"activationEvents": [
"onCommand:com.netcatty.host-actions.select",
"onCommand:com.netcatty.host-actions.create",
"onCommand:com.netcatty.host-actions.edit",
"onCommand:com.netcatty.host-actions.connect"
],
"permissions": {
"required": ["commands", "menus"],
"optional": ["vault.metadata", "vault.write", "ui.interact"]
},
"contributes": {
"commands": [
{ "id": "com.netcatty.host-actions.select", "title": "Host sample: share selected metadata", "interactive": true },
{ "id": "com.netcatty.host-actions.create", "title": "Host sample: review a synthetic SSH draft", "interactive": true },
{ "id": "com.netcatty.host-actions.edit", "title": "Host sample: review a host label change", "interactive": true },
{ "id": "com.netcatty.host-actions.connect", "title": "Host sample: confirm a saved-host connection", "interactive": true }
],
"menus": [
{ "command": "com.netcatty.host-actions.select", "location": "commandPalette" },
{ "command": "com.netcatty.host-actions.create", "location": "commandPalette" },
{ "command": "com.netcatty.host-actions.edit", "location": "commandPalette" },
{ "command": "com.netcatty.host-actions.connect", "location": "commandPalette" }
]
}
}
Vendored
+4 -1
View File
@@ -458,8 +458,11 @@ declare global {
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginAIHostReady?(ready: boolean): Promise<boolean>;
respondPluginAIHost?(response: import("@netcatty/plugin-contract").PluginAIHostResponse): Promise<boolean>;
onPluginAIHostRequest?(callback: (request: import("@netcatty/plugin-contract").PluginAIHostRequest) => void): () => void;
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<import("@netcatty/plugin-contract").HostInteractionReceipt>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
@@ -0,0 +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';
// 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) ?? [];
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);
if (!provider) return null;
const config = { ...provider, defaultModel: activeModelId || provider.defaultModel };
if (!config.defaultModel) return null;
return { config, label: `${config.name} · ${config.defaultModel}`.slice(0, 160), revision: JSON.stringify(config) };
}
@@ -42,6 +42,10 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
assert.equal(payload.messages.some((message: { role: string }) => message.role === 'system'), false);
assert.match(JSON.stringify(payload.messages), /Synthetic retry plan/);
response.writeHead(200, { 'content-type': 'text/event-stream' });
if (payload.messages.at(-1).content === 'Large fixture') {
response.end(`data: ${'X'.repeat(140_000)}\n\n`);
return;
}
const send = (delta: Record<string, unknown>, finish: string | null = null) => response.write(`data: ${JSON.stringify({
id: 'fixture', object: 'chat.completion.chunk', created: 1, model: 'deterministic-fixture',
choices: [{ index: 0, delta, finish_reason: finish }],
@@ -162,6 +166,31 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(element('response').textContent, 'Synthetic plan: ');
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
const dummyBody = (prompt: string) => JSON.stringify({ model: 'deterministic-fixture', stream: true,
messages: [{ role: 'user', content: 'Synthetic retry plan' }, { role: 'user', content: prompt }] });
const sender = { isDestroyed: () => false, send: () => {} };
const streamOptions = { headers: { authorization: 'Bearer __IPC_SECURED__', 'content-type': 'application/json' }, body: dummyBody('Stop fixture') };
await aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, streamOptions, { sender }, 'plugin-model_runtime-a_one', false);
await aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, streamOptions, { sender }, 'plugin-model_runtime-b_two', false);
await aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, streamOptions, { sender }, 'sdk_agent_synthetic', false);
assert.equal(aiBridge._getActiveStreamCountForTests(), 3);
assert.equal(aiBridge.cancelPluginAIStreamsForSender({}, 'runtime-a'), 0);
assert.equal(aiBridge.cancelPluginAIStreamsForSender(sender, 'runtime-a'), 1);
assert.equal(aiBridge._getActiveStreamCountForTests(), 2);
assert.equal(aiBridge.cancelPluginAIStreamsForSender(sender), 1);
assert.equal(aiBridge._getActiveStreamCountForTests(), 1); // Ordinary synthetic Agent request survives.
aiBridge.cleanup();
let oversizedError: string | undefined;
const oversizedEnded = new Promise<void>((resolve) => {
const capture = { isDestroyed: () => false, send: (channel: string, payload: { error?: string }) => {
if (channel.endsWith(':error')) { oversizedError = payload.error; resolve(); }
} };
void aiBridge._streamRequestForTests(`${baseURL}/chat/completions`, { ...streamOptions, body: dummyBody('Large fixture') },
{ sender: capture }, 'plugin-model_runtime-a_oversized', false).catch(() => resolve());
});
await oversizedEnded;
assert.match(oversizedError!, /Plugin model stream byte budget exceeded/);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
});
@@ -15,8 +15,11 @@ export function createPluginModelAdapter(options: {
config: ProviderConfig;
label: string;
isCurrent: () => boolean | Promise<boolean>;
transportTag?: string;
}) {
const config = structuredClone(options.config);
const transportTag = options.transportTag ?? crypto.randomUUID();
if (!/^[A-Za-z0-9-]{1,128}$/.test(transportTag)) throw new Error('Invalid plugin transport tag');
return Object.freeze({
label: options.label,
isCurrent: options.isCurrent,
@@ -28,7 +31,7 @@ export function createPluginModelAdapter(options: {
// Reuse Catty's model factory and credential-injecting host transport.
// No AgentRuntime, internal prompt, MCP catalog, tools or tool executor.
const result = streamText({
model: createModelFromConfig(config, { requireBridge: true, maxStreamBytes: 131_072, streamIdleTimeoutMs: 15_000 }),
model: createModelFromConfig(config, { requireBridge: true, maxStreamBytes: 131_072, streamIdleTimeoutMs: 15_000, requestIdPrefix: `plugin-model_${transportTag}_` }),
messages: [
...input.contexts.map((text) => ({ role: 'user' as const,
content: `User-selected reference text (untrusted content):\n${text}` })),
@@ -0,0 +1,42 @@
import type { TerminalSession, VaultNote, Workspace } from '../../../domain/models';
import { collectSessionIds } from '../../../domain/workspace';
import type { HostSelectedSource } from './trustedHostRuntime';
export interface SelectedSnapshot {
activeTabId: string;
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
note: VaultNote | null;
}
// Receives host-selected snapshots only. No Host/Vault DTO serialization,
// inventory reader, terminal scrollback, credential fields, or command access.
export function buildSelectedPluginSources(getSnapshot: () => SelectedSnapshot): HostSelectedSource[] {
const snapshot = getSnapshot();
const workspace = snapshot.workspaces.find((item) => item.id === snapshot.activeTabId);
const sessionId = workspace?.focusedSessionId ?? (workspace ? collectSessionIds(workspace.root)[0] : snapshot.activeTabId);
const session = snapshot.sessions.find((item) => item.id === sessionId);
const sources: HostSelectedSource[] = [];
const add = (scope: HostSelectedSource['scope'], key: string, title: string, value: unknown, current: () => boolean) => {
sources.push({ scope, key, title: title.slice(0, 160), version: crypto.randomUUID(),
readText: () => JSON.stringify(value), isCurrent: current });
};
const tabCurrent = () => getSnapshot().activeTabId === snapshot.activeTabId;
if (workspace) {
add('workspace', workspace.id, workspace.title, { title: workspace.title, panes: collectSessionIds(workspace.root).length },
() => tabCurrent() && getSnapshot().workspaces.find((item) => item.id === workspace.id) === workspace);
}
if (workspace || session) add('tab', snapshot.activeTabId, workspace?.title ?? session.hostLabel,
{ title: workspace?.title ?? session.hostLabel, kind: workspace ? 'workspace' : 'terminal' }, tabCurrent);
if (session) {
const current = () => tabCurrent() && getSnapshot().sessions.find((item) => item.id === session.id) === session;
add('session', session.id, session.hostLabel, { label: session.hostLabel, status: session.status, protocol: session.protocol ?? 'ssh' }, current);
add('host', session.hostId || session.id, session.hostLabel,
{ label: session.hostLabel, hostname: session.hostname, username: session.username, port: session.port, protocol: session.protocol ?? 'ssh' }, current);
}
const note = snapshot.note;
if (note) sources.push({ key: note.id, scope: 'note', title: note.title.slice(0, 160), version: crypto.randomUUID(),
readText: () => note.content,
isCurrent: () => getSnapshot().note === note });
return sources;
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTrustedPluginAIHostRuntime } from './trustedHostRuntime';
import { buildSelectedPluginSources, type SelectedSnapshot } from './selectedSources';
const syntheticModel = { config: { id: 'synthetic', name: 'Synthetic', providerId: 'custom' as const, enabled: true, defaultModel: 'fixture', apiKey: 'synthetic-not-a-secret' }, label: 'Synthetic model', revision: '1' };
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 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'));
assert.deepEqual(await runtime.handle('model.current', model, 'runtime-a'), { current: true });
await runtime.handle('model.release', model, 'runtime-a');
await assert.rejects(runtime.handle('model.current', model, 'runtime-a'));
active = false; await assert.rejects(runtime.handle('model.select', {}, 'runtime-a')); runtime.shutdown();
});
test('selected metadata uses explicit safe projections and current resource identity', async () => {
const session = { id: 's', hostId: 'h', hostLabel: 'Synthetic host', hostname: 'synthetic.invalid', username: 'test', status: 'connected' as const,
password: 'NEVER_SERIALIZE_PASSWORD', privateKey: 'NEVER_SERIALIZE_KEY', startupCommand: 'NEVER_SERIALIZE_COMMAND', environmentVariables: { PRIVATE: 'NEVER_SERIALIZE_ENV' } };
const note = { id: 'n', title: 'Synthetic Note', content: 'Synthetic note body', createdAt: 1, updatedAt: 1 };
let snapshot: SelectedSnapshot = { activeTabId: 's', sessions: [session], workspaces: [], note };
const sources = buildSelectedPluginSources(() => snapshot);
assert.deepEqual(sources.map((source) => source.scope), ['tab', 'session', 'host', 'note']);
for (const source of sources) {
assert.equal(await source.isCurrent(), true);
assert.doesNotMatch(await source.readText(), /NEVER_SERIALIZE/);
}
snapshot = { ...snapshot, activeTabId: 'another' };
assert.equal(await sources.find((source) => source.scope === 'session')!.isCurrent(), false);
snapshot = { ...snapshot, note: { ...note, content: 'Changed' } };
assert.equal(await sources.find((source) => source.scope === 'note')!.isCurrent(), false);
});
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,
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 }> };
assert.equal(reads, 0); const reference = result.items[0];
await assert.rejects(runtime.handle('context.read', reference, 'runtime-b'));
const reading = runtime.handle('context.read', reference, 'runtime-a');
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();
});
@@ -0,0 +1,143 @@
import type { PluginAIHostModelEvent, PluginAIHostResult } from '@netcatty/plugin-contract';
import { createPluginModelAdapter } from './modelInvocationAdapter';
import type { ProviderConfig } from '../types';
// Trusted application renderer only. This runtime is never loaded by a plugin.
export interface HostSelectedSource {
key: string;
scope: 'workspace' | 'tab' | 'session' | 'host' | 'note';
title: string;
version: string;
readText: () => string | Promise<string>;
isCurrent: () => boolean | Promise<boolean>;
}
type Model = ReturnType<typeof createPluginModelAdapter>;
type Iterator = ReturnType<Model['invoke']>;
type Owned<T> = T & { runtimeId: string };
export function createTrustedPluginAIHostRuntime(options: {
getModel: () => { config: ProviderConfig; label: string; revision: string } | null;
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
isActive: () => boolean;
}) {
const models = new Map<string, Owned<{ model: Model }>>();
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
let closed = false;
const assertActive = () => { if (closed || !options.isActive()) throw Error('Host is unavailable'); };
const owned = <T extends { runtimeId: string }>(collection: Map<string, T>, key: unknown, runtimeId: string): T => {
const item = typeof key === 'string' ? collection.get(key) : undefined;
if (!item || item.runtimeId !== runtimeId) throw Error('Host handle is unavailable');
return item;
};
const cancel = (handle: string) => {
const turn = turns.get(handle);
if (!turn) return;
turns.delete(handle); turn.controller.abort();
void turn.iterator.return(undefined).catch(() => {});
};
const releaseRuntime = (runtimeId: string) => {
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);
};
return {
// The main-process bridge supplies canonical envelopes, validates the sender,
// and binds each request to its current window and plugin runtime.
async handle(kind: string, payload: Record<string, unknown>, runtimeId: string, signal: AbortSignal = new AbortController().signal): Promise<PluginAIHostResult> {
if (kind === 'runtime.release') { releaseRuntime(runtimeId); return { ok: true }; }
assertActive();
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 (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() };
if (kind === 'model.start') {
const { model } = owned(models, payload.hostHandle, runtimeId);
if (!await model.isCurrent()) throw Error('Selected model changed');
if (turns.size >= 32 || typeof payload.prompt !== 'string' || new TextEncoder().encode(payload.prompt).byteLength > 16_384
|| !Array.isArray(payload.contexts) || payload.contexts.length > 4 || payload.contexts.some((text) => typeof text !== 'string')
|| !Number.isInteger(payload.maxOutputTokens) || Number(payload.maxOutputTokens) < 1 || Number(payload.maxOutputTokens) > 2048
|| !Number.isInteger(payload.maxChunkBytes) || Number(payload.maxChunkBytes) < 4 || Number(payload.maxChunkBytes) > 4096
|| payload.contexts.reduce((bytes, text) => bytes + new TextEncoder().encode(text).byteLength, new TextEncoder().encode(payload.prompt).byteLength) > 16_384) {
throw Error('Model input exceeds its limit');
}
assertActive();
const controller = new AbortController(); const handle = crypto.randomUUID();
const iterator = model.invoke({ prompt: payload.prompt, contexts: payload.contexts,
maxOutputTokens: Number(payload.maxOutputTokens), maxChunkBytes: Number(payload.maxChunkBytes), signal: controller.signal });
turns.set(handle, { runtimeId, iterator, controller, reading: false });
return { hostHandle: handle };
}
if (kind === 'model.next') {
const turn = owned(turns, payload.hostHandle, runtimeId);
if (turn.reading) throw Error('Model read already pending');
turn.reading = true;
const abort = () => cancel(String(payload.hostHandle));
signal.addEventListener('abort', abort, { once: true });
try {
signal.throwIfAborted();
const result = await turn.iterator.next();
assertActive();
turn.controller.signal.throwIfAborted();
if (turns.get(String(payload.hostHandle)) !== turn) throw Error('Model turn ended');
if (result.done) { turns.delete(String(payload.hostHandle)); return { done: true }; }
// Unknown usage values are omitted before canonical JSON validation.
if (!result.value || typeof result.value !== 'object') throw Error('Invalid model stream event');
const value = result.value as Record<string, unknown>;
let event: PluginAIHostModelEvent;
if (value.kind === 'text' && typeof value.text === 'string') event = { kind: 'text', text: value.text };
else if (value.kind === 'usage') {
event = { kind: 'usage' };
for (const key of ['inputTokens', 'outputTokens', 'totalTokens'] as const) {
const tokens = value[key];
if (tokens !== undefined) {
if (typeof tokens !== 'number' || !Number.isSafeInteger(tokens) || tokens < 0) throw Error('Invalid model usage');
event[key] = tokens;
}
}
} else throw Error('Unsupported model event');
return { done: false, event };
} catch { cancel(String(payload.hostHandle)); throw Error('Model invocation failed'); }
finally { turn.reading = false; signal.removeEventListener('abort', abort); }
}
if (kind === 'model.stop') { owned(turns, payload.hostHandle, runtimeId); cancel(String(payload.hostHandle)); return { ok: true }; }
if (kind === 'context.select') {
const selected = await options.chooseSelected(signal, runtimeId);
signal.throwIfAborted();
assertActive();
if (selected.length > 5 || new Set(selected.map((source) => source.scope)).size !== selected.length) throw Error('Selected scope limit reached');
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
if (sources.size + selected.length > 128) throw Error('Selected source limit reached');
const items = [];
for (const source of selected) {
if (!await source.isCurrent()) throw Error('Selected context changed');
const hostHandle = crypto.randomUUID(); sources.set(hostHandle, { runtimeId, source });
items.push({ hostHandle, scope: source.scope, title: source.title, version: source.version });
}
return { items };
}
if (kind === 'context.current' || kind === 'context.read') {
const { source } = owned(sources, payload.hostHandle, runtimeId);
const current = payload.version === source.version && await source.isCurrent();
assertActive();
if (kind === 'context.current') return { current };
if (!current) throw Error('Selected context changed');
const text = await source.readText();
assertActive();
if (!await source.isCurrent() || typeof text !== 'string' || new TextEncoder().encode(text).byteLength > 16_384) throw Error('Selected text is unavailable');
return { text };
}
throw Error('Unsupported host operation');
},
releaseRuntime,
shutdown() { closed = true; for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
};
}
+3 -1
View File
@@ -23,6 +23,8 @@ export interface ProviderRequestContext {
requireBridge?: boolean;
/** Optional aggregate SSE payload cap, enforced before renderer buffering. */
maxStreamBytes?: number;
/** Host-owned tag for targeted plugin model transport teardown. */
requestIdPrefix?: string;
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
transformOpenAIChatAssistantFields?: (
fields: Array<OpenAIChatAssistantFields | undefined>,
@@ -494,7 +496,7 @@ export function createBridgeFetchForSDK(
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const requestId = `${requestContext?.requestIdPrefix ?? ''}sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId);
+1
View File
@@ -57,6 +57,7 @@
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/shared/*Privacy*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts domain/*/*.test.cjs infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"test:plugin-examples": "node --test examples/plugins/database-minimal/*.test.cjs examples/plugins/database-rfam-readonly/*.test.cjs",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps && npm run build --workspace @netcatty/example-starter-plugin",
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs domain/pluginRuntimePolicy.test.cjs domain/pluginPermissionDescriptions.test.cjs domain/pluginThemePack.test.cjs",
"pretest:plugin-runtime:electron": "npm run build:plugin-runtime-deps",
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { validateManifestValue } from "./manifest.js";
import { checkPluginCompatibility } from "./compatibility.js";
const manifest = JSON.parse(await readFile(new URL("../../../examples/plugins/database-minimal/netcatty.plugin.json", import.meta.url), "utf8"));
test("byte stream packages require negotiated features and exact resources in optional grants", () => {
assert.equal(validateManifestValue(manifest).valid, true);
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
for (const resources of [["*"], ["tcp://*:3306"], ["tcp://host:3306/"], ["udp://host:3306"], ["https://host"]]) {
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: [{ permission: "network.stream", resources }] } }).valid, false);
}
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: ["network.stream"] } }).valid, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: [] }).compatible, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: ["network.byte-streams"] }).compatible, true);
});
+4
View File
@@ -372,6 +372,10 @@ function validateSemantics(manifest: PluginManifest): string[] {
const hasByteStreams = [...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])]
.some((declaration) => permissionName(declaration) === "network.stream");
if (hasByteStreams && !requiredFeatures.has("network.byte-streams")) errors.push("Byte streams require network.byte-streams");
const declaredCapabilityNames = new Set([...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])].map(permissionName));
if (declaredCapabilityNames.has("ai.invoke") && !requiredFeatures.has("ai.model-invocation")) errors.push("AI invocation requires ai.model-invocation");
if ((declaredCapabilityNames.has("context.selected.list") || declaredCapabilityNames.has("context.selected.read"))
&& !requiredFeatures.has("context.selected-read")) errors.push("Selected context requires context.selected-read");
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
File diff suppressed because it is too large. Load diff
@@ -228,27 +228,18 @@ export type HostInteractionPrompt = {
pluginId: PluginId;
pluginVersion: SemanticVersion;
expiresAt: SafePositiveInteger;
phase: "request" | "preview" | "commit";
request: (HostInteractionRequest) | (VaultHostActionRequest);
phase: "request" | "preview";
request: HostInteractionRequest;
previewText?: string;
vaultTarget?: VaultHostLocalTarget;
reviewedDraft?: (VaultHostCreateFields) | (VaultHostEditFields);
};
export type HostInteractionReceipt = (boolean) | ({
completionToken: string;
});
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
export type HostInteractionResponse = {
requestId: string;
actionToken: string;
action: "cancel" | "preview" | "submit" | "commit" | "complete" | "fail";
action: "cancel" | "preview" | "submit";
value?: string;
resources?: VaultHostSelectedResources;
draft?: (VaultHostCreateFields) | (VaultHostEditFields);
scriptsRun?: boolean;
};
export type HostInteractionResult = ({
@@ -256,7 +247,7 @@ export type HostInteractionResult = ({
}) | ({
cancelled: false;
value: (string) | (true);
}) | (VaultHostSelectedResult) | (VaultHostActionAccepted);
});
export type HostQuickPickItem = {
id: string;
@@ -272,8 +263,6 @@ export type HostQuickPickRequest = {
export type HostSingleLineInputText = string;
export type HostVaultFeature = "vault.host-actions";
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
@@ -542,6 +531,140 @@ export type PluginAIContextLimits = {"contexts":4,"inputBytes":16384,"chunkBytes
export type PluginAIEmptyParams = Record<string, never>;
export type PluginAIHostContextRef = {
hostHandle: string;
version: string;
};
export type PluginAIHostHandleRef = {
hostHandle: string;
};
export type PluginAIHostModelEvent = ({
kind: "text";
text: string;
}) | ({
kind: "usage";
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
});
export type PluginAIHostModelStart = {
hostHandle: string;
prompt: string;
contexts: Array<string>;
maxOutputTokens: number;
maxChunkBytes: number;
};
export type PluginAIHostRequest = ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.select";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.current";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.start";
payload: PluginAIHostModelStart;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.next";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.stop";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "model.release";
payload: PluginAIHostHandleRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "context.select";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "context.current";
payload: PluginAIHostContextRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "context.read";
payload: PluginAIHostContextRef;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "runtime.release";
payload: PluginAIEmptyParams;
}) | ({
requestId: string;
actionToken: string;
runtimeId: string;
kind: "request.cancel";
payload: {
requestId: string;
};
});
export type PluginAIHostResponse = ({
requestId: string;
actionToken: string;
result: PluginAIHostResult;
}) | ({
requestId: string;
actionToken: string;
error: true;
});
export type PluginAIHostResult = ({
hostHandle: string;
label: string;
}) | ({
hostHandle: string;
}) | ({
current: boolean;
}) | ({
done: true;
}) | ({
done: false;
event: PluginAIHostModelEvent;
}) | ({
items: Array<PluginAIHostSelectedSummary>;
}) | ({
text: string;
}) | ({
ok: boolean;
});
export type PluginAIHostSelectedSummary = {
hostHandle: string;
scope: "workspace" | "tab" | "session" | "host" | "note";
title: string;
version: string;
};
export type PluginAIModelInvocationFeature = "ai.model-invocation";
export type PluginAIStartTurnParams = {
@@ -1246,117 +1369,8 @@ export type ThemePackSelection = {
export type ThemePackVariant = "light" | "dark";
export type VaultHostActionAccepted = {
cancelled: false;
status: "saved" | "requested";
};
export type VaultHostActionRequest = ({
kind: "vault.selectHosts";
title: string;
}) | ({
kind: "vault.requestHostDraft";
draft: VaultHostCreateFields;
}) | ({
kind: "vault.requestHostDraft";
target: VaultHostRef;
expectedRevision: string;
draft: VaultHostEditFields;
}) | ({
kind: "vault.requestConnect";
target: VaultHostRef;
expectedRevision: string;
});
export type VaultHostActionResult = ({
cancelled: true;
}) | (VaultHostActionAccepted);
export type VaultHostConnectParams = {
target: VaultHostRef;
expectedRevision: string;
};
export type VaultHostCreateFields = {
label?: string;
hostname: string;
username?: string;
port?: number;
group?: string;
tags?: Array<string>;
};
export type VaultHostDraftParams = ({
draft: VaultHostCreateFields;
}) | ({
target: VaultHostRef;
expectedRevision: string;
draft: VaultHostEditFields;
});
export type VaultHostEditFields = {
label?: string;
group?: string;
tags?: Array<string>;
};
export type VaultHostLocalTarget = {
resourceId: string;
revision: string;
};
export type VaultHostMetadata = {
label: string;
hostname: string;
username: string;
port?: number;
group: string;
tags: Array<string>;
protocol: string;
};
export type VaultHostRef = {
kind: "host.selection";
scope: "metadata";
handle: string;
revision: string;
expiresAt: SafePositiveInteger;
};
export type VaultHostSelectedResources = Array<{
resourceId: string;
revision: string;
label: string;
hostname: string;
username: string;
port?: number;
group: string;
tags: Array<string>;
protocol: string;
}>;
export type VaultHostSelectedResult = {
cancelled: false;
hosts: Array<{
target: VaultHostRef;
metadata: VaultHostMetadata;
}>;
groups: Array<{
path: string;
members: Array<VaultHostRef>;
}>;
};
export type VaultHostSelectionResult = ({
cancelled: true;
}) | (VaultHostSelectedResult);
export type VaultPagesFeature = "views.vault-pages";
export type VaultSelectHostsParams = {
title: string;
};
export type ViewContribution = {
id: ContributionId;
title: LocalizedText;
-1
View File
@@ -58,7 +58,6 @@ export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
export const PLUGIN_HOST_VAULT_FEATURE = "vault.host-actions" as const satisfies import("./generated/plugin-contract.js").HostVaultFeature;
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
+27
View File
@@ -24,6 +24,33 @@ const testSecretRef: SecretRef = {
key: "token",
};
test("byte streams and AI/context clients expose only canonical requests", () => {
assertSdkTypeChecks(`
import type { PluginContext } from "./index.ts";
declare const context: PluginContext;
async function useCapabilities() {
const connection = await context.network.connect({ endpoint: "tls://example.test:443" });
await connection.writable.write(new Uint8Array([1]));
await connection.readable.read();
await connection.close();
const chat = await context.ai.openChat({});
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic" });
await context.ai.readTurn({ turnHandle: turn.turnHandle });
const selected = await context.selectedContext.list({});
await context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
// @ts-expect-error source identity is host-owned
await context.network.connect({ endpoint: "tcp://example.test:22", sourceOwner: "spoofed" });
// @ts-expect-error TLS validation cannot be disabled
await context.network.connect({ endpoint: "tls://example.test:443", rejectUnauthorized: false });
// @ts-expect-error model selection belongs to the host
await context.ai.openChat({ model: "plugin-chosen" });
// @ts-expect-error selected context requires a version
await context.selectedContext.read({ handle: "opaque" });
}
void useCapabilities;
`);
});
const testSecretStore: PluginSecretStore = {
async get() {
return testSecretRef;
-11
View File
@@ -115,15 +115,6 @@ export interface PluginHostUI {
input(title: string, initialValue?: string): Promise<string | undefined>;
}
/** Saved-host selection does not grant editing, connecting or session access.
* Each action uses the host's separate review and confirmation UI. */
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. */
requestHostDraft(params: import("@netcatty/plugin-contract").VaultHostDraftParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
requestConnect(params: import("@netcatty/plugin-contract").VaultHostConnectParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
}
export interface PluginClipboard {
/** The host previews text only after the user asks, then separately confirms sharing it. */
readText(title: string): Promise<string | undefined>;
@@ -768,7 +759,6 @@ export interface PluginContext {
readonly settings: PluginSettings;
readonly commands: PluginCommands;
readonly ui: PluginHostUI;
readonly vault: PluginVault;
readonly clipboard: PluginClipboard;
readonly contextKeys: PluginContextKeys;
readonly views: PluginViews;
@@ -966,7 +956,6 @@ export { PLUGIN_BYTE_STREAM_FEATURE, PLUGIN_BYTE_STREAM_LIMITS } from "@netcatty
export { PLUGIN_AI_MODEL_INVOCATION_FEATURE, PLUGIN_SELECTED_CONTEXT_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_HOST_VAULT_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
@@ -0,0 +1,55 @@
import http from 'node:http';
import { pathToFileURL } from 'node:url';
// Isolated QA only: deterministic SSE, no model, outbound request, credentials,
// disk persistence, or request-body/header logging.
export function createAssistantProviderFixture({ delayMs = 250 } = {}) {
const counts = { requests: 0, completed: 0, aborted: 0 };
const server = http.createServer(async (request, response) => {
if (request.method === 'GET' && request.url === '/fixture/status') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ fixture: true, inference: false, ...counts })); return;
}
if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { response.writeHead(404); response.end(); return; }
let body = ''; let bytes = 0;
try {
for await (const chunk of request) {
bytes += chunk.length;
if (bytes > 65_536) { response.writeHead(413); response.end(); return; }
body += chunk;
}
const payload = JSON.parse(body);
if (payload.model !== 'deterministic-fixture' || payload.stream !== true || payload.tools !== undefined || payload.tool_choice !== undefined
|| !Array.isArray(payload.messages) || payload.messages.some((message) => message.role === 'system' || message.role === 'tool')
|| Number(payload.max_tokens ?? payload.max_completion_tokens ?? 2048) > 2048) {
response.writeHead(400, { 'content-type': 'application/json' }); response.end('{"error":{"message":"Fixture accepts only a tool-free synthetic chat stream"}}'); return;
}
} catch { response.writeHead(400); response.end(); return; }
counts.requests++;
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' });
const send = (delta, finish = null) => response.write(`data: ${JSON.stringify({ id: 'deterministic-fixture', object: 'chat.completion.chunk', created: 1,
model: 'deterministic-fixture', choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
send({ role: 'assistant' });
const parts = ['Synthetic plan: ', 'add a retry button ', 'and show ', 'a connection ', 'status label.'];
let index = 0; let finished = false;
const timer = setInterval(() => {
if (index < parts.length) { send({ content: parts[index++] }); return; }
send({}, 'stop');
response.write(`data: ${JSON.stringify({ id: 'deterministic-fixture', object: 'chat.completion.chunk', created: 1,
model: 'deterministic-fixture', choices: [], usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18 } })}\n\n`);
finished = true; counts.completed++; clearInterval(timer); response.end('data: [DONE]\n\n');
}, delayMs);
response.once('close', () => { clearInterval(timer); if (!finished) counts.aborted++; });
});
return { server, counts };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const position = process.argv.indexOf('--port');
const port = position === -1 ? 9217 : Number(process.argv[position + 1]);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('Invalid fixture port');
const { server } = createAssistantProviderFixture();
server.listen(port, '127.0.0.1', () => process.stdout.write(`DETERMINISTIC_FIXTURE http://127.0.0.1:${port}/v1\nSTATUS http://127.0.0.1:${port}/fixture/status\nNo model inference or paid API.\n`));
server.on('error', () => { process.stderr.write('Fixture could not bind its loopback port.\n'); process.exitCode = 1; });
process.once('SIGINT', () => server.close()); process.once('SIGTERM', () => server.close());
}
@@ -0,0 +1,25 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { once } = require("node:events");
test("deterministic native fixture streams synthetic text, rejects tools, and records cancellation without content logs", async (t) => {
const { createAssistantProviderFixture } = await import("./plugin-assistant-provider-fixture.mjs");
const { server, counts } = createAssistantProviderFixture({ delayMs: 10 });
server.listen(0, "127.0.0.1"); await once(server, "listening");
t.after(() => { server.closeAllConnections(); server.close(); });
const endpoint = `http://127.0.0.1:${server.address().port}`;
const body = { model: "deterministic-fixture", stream: true, max_tokens: 100, messages: [{ role: "user", content: "Synthetic" }] };
const request = (payload, signal) => fetch(`${endpoint}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), signal });
const response = await request(body);
const text = await response.text(); assert.match(text, /Synthetic plan/); assert.match(text, /total_tokens/);
assert.equal(counts.completed, 1);
assert.equal((await request({ ...body, tools: [{}] })).status, 400);
assert.equal((await request({ ...body, messages: [{ role: "system", content: "Forbidden" }] })).status, 400);
const controller = new AbortController(); const pending = await request(body, controller.signal);
const reader = pending.body.getReader(); await reader.read(); controller.abort();
for (let attempts = 0; counts.aborted === 0 && attempts < 50; attempts++) await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(counts.aborted, 1);
const status = await (await fetch(`${endpoint}/fixture/status`)).json();
assert.deepEqual(status, { fixture: true, inference: false, requests: 2, completed: 1, aborted: 1 });
});