Compare commits

..
85 changed files with 3028 additions and 4412 deletions

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ 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';
@@ -92,6 +93,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
<PluginHostActionHost />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
-2
View File
@@ -26,7 +26,6 @@ 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 {
@@ -857,7 +856,6 @@ 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,5 +1380,20 @@ 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,5 +1380,20 @@ 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,4 +1275,19 @@ 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,5 +1295,20 @@ 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,4 +1298,19 @@ 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 部目前的主機。',
};
@@ -0,0 +1,128 @@
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);
});
@@ -0,0 +1,102 @@
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';
}
@@ -0,0 +1,32 @@
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);
});
@@ -0,0 +1,34 @@
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],
};
}
}
@@ -1,36 +0,0 @@
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(); }
});
@@ -1,21 +0,0 @@
/** 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();
}
@@ -1,85 +0,0 @@
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, prepareConfiguredPluginModel } 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,
prepareModel: prepareConfiguredPluginModel,
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseSelected: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A host selection is already pending')); return; }
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
@@ -0,0 +1,168 @@
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,13 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<HostInteractionPrompt | null>(null);
const current = useRef<UiPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -17,7 +18,8 @@ export function usePluginHostInteractions() {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
@@ -26,7 +26,6 @@ import {
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -308,17 +307,6 @@ 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,6 +776,13 @@ 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);
@@ -1248,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
assertAuthorized?: () => void | Promise<void>,
): Promise<
| {
status: "persisted";
@@ -1322,6 +1330,10 @@ 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
@@ -2519,6 +2531,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
groupConfigs,
updateHosts,
readPersistedHosts,
readCurrentHostActionState,
updateKeys,
importOrReuseKey,
updateIdentities,
-223
View File
@@ -1,223 +0,0 @@
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('cold plugin host reaches the local fixture through the actual main provider allowlist without the AI sidebar', { timeout: 20_000 }, async (t) => {
// JSDOM + the in-process runtime prove app wiring; they are not native sandbox
// evidence. All state is synthetic, with no real provider, key, Note or host.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
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 { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
const { server, counts } = createAssistantProviderFixture({ delayMs: 25 });
const oldNotes = getNotesSnapshot(); const oldLock = getAppAppLockRuntime();
let service: ReturnType<typeof createPluginHostService> | undefined;
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[] = [];
const streamResults: Array<{ ok: boolean; statusCode?: number; allowlistRejected: boolean }> = [];
const providerSyncs: Array<{ count: number; selectedPresent: boolean }> = [];
const sdkErrorNames: string[] = [];
let diagnosticPhase = 'setup';
const originalConsoleError = console.error;
// node:sqlite was imported above. Node 22 delivers its ExperimentalWarning
// asynchronously through console.error. Let module warnings reach normal
// stderr before observing SDK/renderer errors; do not suppress that warning.
await new Promise<void>((resolve) => setImmediate(resolve));
// The expected pre-fix SDK error contains request details. Keep diagnostics
// categorical even on the failing-before path; never print payloads/headers.
console.error = (value: unknown) => {
let category = 'unknown';
if (typeof value === 'string' && /not wrapped in act/.test(value)) category = 'react-act-warning';
else if (typeof value === 'string' && /act.*environment|environment.*act/.test(value)) category = 'react-act-environment';
else if (typeof value === 'string' && /ExperimentalWarning: SQLite is an experimental feature/.test(value)) category = 'node-sqlite-experimental';
else if (typeof value === 'string') category = 'other-string';
else if (value && typeof value === 'object') {
const name = (value as { name?: unknown }).name;
if (typeof name === 'string' && ['AbortError', 'AI_APICallError', 'AI_LoadAPIKeyError', 'TypeError', 'Error'].includes(name)) category = name;
}
sdkErrorNames.push(`${diagnosticPhase}:${category}`);
};
t.after(() => { console.error = originalConsoleError; });
try {
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string');
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 });
const providerContext = {
ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => ipcHandlers.set(channel, handler) },
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender,
// Only credential injection is synthetic. Admission, sync and the HTTP
// transport are the production implementations, not a direct-fetch seam.
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const config = providerContext.providerConfigs.find((item) => item.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) =>
[key, config?.apiKey ? value.replace('__IPC_SECURED__', config.apiKey) : value])) };
},
shouldSkipTLSVerify: () => false,
streamRequest: aiBridge._streamRequestForTests,
};
registerProviderHandlers(providerContext);
assert.equal(providerContext.providerConfigs.length, 0);
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { url: string; headers: Record<string, string>; body: string; requestId: string };
assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.doesNotMatch(JSON.stringify(request), /fixture-only/);
}
if (channel === 'netcatty:ai:chat:cancel') return aiBridge.cancelPluginAIStreamsForSender(sender) > 0;
const handler = ipcHandlers.get(channel);
assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:ai:sync-providers') providerSyncs.push({ count: providerContext.providerConfigs.length,
selectedPresent: providerContext.providerConfigs.some((item) => item.id === 'qa-provider') });
if (channel === 'netcatty:ai:chat:stream') {
const status = result as { ok: boolean; statusCode?: number; error?: string };
streamResults.push({ ok: status.ok, statusCode: status.statusCode, allowlistRejected: status.error === 'URL host is not in the allowed list' });
}
return result;
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
Object.defineProperty(dom.window, 'netcatty', { value: api });
const syntheticNote = { id: 'synthetic-note', title: 'Synthetic retry plan', content: 'Synthetic retry plan: add a retry button.', createdAt: 1, updatedAt: 1 };
publishNotesSnapshot({ notes: [syntheticNote], noteGroups: [] });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: 'qa-provider', name: 'QA fixture', providerId: 'custom', enabled: true, baseURL: endpoint, defaultModel: 'deterministic-fixture', apiKey: 'fixture-only' },
{ id: 'qa-secondary', name: 'Synthetic secondary', providerId: 'custom', enabled: true, baseURL: 'https://secondary.invalid/v1', defaultModel: 'synthetic', apiKey: 'secondary-fixture-marker' },
]));
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_PROVIDER, 'qa-provider');
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_MODEL, 'deterministic-fixture');
service = createPluginHostService({
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() {} }),
} });
diagnosticPhase = 'initial-selection';
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
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 }));
diagnosticPhase = 'deny-content';
send(); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, 0); assert.equal(reviews.length, 0);
contentAllowed = true;
diagnosticPhase = 'cancel-review';
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;
diagnosticPhase = 'complete-stream';
send(); await waitFor(() => counts.completed === 1 || streamResults.some((result) => !result.ok));
assert.deepEqual(streamResults.at(-1), { ok: true, statusCode: 200, allowlistRejected: false },
'Cold plugin startup must synchronize host providers before the main allowlist admits transport');
assert.ok(providerSyncs.some((sync) => sync.selectedPresent && sync.count === 2), 'Preserve other host-configured providers in the shared registry');
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.completed, 1); assert.match(element('response').textContent!, /Synthetic plan/); assert.match(element('usage').textContent!, /12 input tokens/);
diagnosticPhase = 'stop-stream';
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
element('stop').dispatchEvent(new panel.window.Event('click'));
await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stoppedText = element('response').textContent; await new Promise((resolve) => setTimeout(resolve, 50)); assert.equal(element('response').textContent, stoppedText);
diagnosticPhase = 'lock-stream';
send(); await waitFor(() => element('response').textContent!.startsWith('Synthetic plan: '));
locked = true; registerAppAppLockRuntime({ locked: true } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
for (const listener of lockListeners) listener({ locked: true });
await waitFor(() => counts.aborted === 2 && service!.aiContextService.broker.turns.size === 0);
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
assert.doesNotMatch(JSON.stringify(trustedPayloads), /fixture-only|secondary-fixture-marker|apiKey|privateOwner|permissionMode|mcp/);
assert.deepEqual(sdkErrorNames, []);
} finally {
console.error = originalConsoleError;
panel?.window.close();
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve()));
dom.window.close();
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(fixtureRoot, { recursive: true, force: true });
}
});
@@ -0,0 +1,290 @@
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); }
}
});
@@ -0,0 +1,38 @@
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(); }
});
+3 -1
View File
@@ -50,6 +50,7 @@ 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[];
@@ -79,6 +80,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSelect,
onSelectionChange,
onConfirm,
confirmDisabled = false,
onNewHost,
availableKeys = [],
identities = [],
@@ -643,7 +645,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={selectedHostIds.length === 0}
disabled={confirmDisabled || selectedHostIds.length === 0}
onClick={onConfirm}
>
{multiSelect
@@ -1,22 +0,0 @@
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>;
}
@@ -0,0 +1,45 @@
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>;
}
@@ -0,0 +1,58 @@
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>;
}
+45 -202
View File
@@ -2,29 +2,23 @@
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
Branch: `feat/plugin-ai-context`.
Status: source-bound services, default trusted main/renderer adapters, app preload,
the AppView host chooser, canonical private IPC types, and an installable minimal
example are connected. Complete independent review and native acceptance remain
pending. This source is not ready to merge or release.
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.
## Public boundaries
## Proposed public boundaries
The parent approved `ai.model-invocation` and `context.selected-read`. The
transport task owns the public schema, SDK, and runtime peer. Its
`c4433d4961ecf4a81bea92b0a14f47b4ec12f679` head contains the public AI/context
routes and is merged into this branch. The parent assigned this task the additive
trusted-host IPC schema, preload, AppView mount, and AI-only hostService wiring.
The base later advanced to `f1410f8bb`; its source/stream changes are incorporated
by ordinary merge. Three overlaps retain this branch's permission explanations,
bounded-stream assertions, and six-file assistant staging. The source database
and sidecar changes come from their owning task, without local modifications.
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 model API uses a host-selected configured model. Plugins receive a chat
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
`read` pulls one chunk with one pending read per turn through the registered SDK
and runtime routes.
`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.
Context summary enumeration returns only references explicitly shared with the
current runtime and source owner. Content reads require an opaque handle and an
@@ -38,10 +32,7 @@ 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` |
| 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 |
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
| 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 |
@@ -53,18 +44,8 @@ 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`,
`resolveBudgetOwner`, and `selectModel` adapters. The service derives the stable
budget owner from the authenticated source key and opaque private owner. It never
accepts a plugin-supplied budget key or falls back to a bare plugin ID. Strict
runtime binding objects still govern handle access; their replacement does not
reset request or token budgets. It keeps chats and turns in memory. It stores no chat
`ModelInvocationBroker` requires host-provided `resolveDataOwner` and
`selectModel` adapters. It keeps chats and turns in memory. It stores no chat
history and makes no database changes. Data owners are opaque host identities;
no private storage is keyed by `pluginId`.
@@ -86,10 +67,7 @@ trusted sharing UI must preview the selected text before sharing it.
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
`streamText`, through the existing host provider transport. Credential injection
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
SSE payloads have an aggregate 128 KiB cap before SDK buffering. The main
transport also enforces a plugin-specific 128 KiB response cap and a 60-second
total deadline. Host-owned request tags allow sender/runtime cancellation
without cancelling the internal Agent's ordinary streams. SDK retries are
SSE payloads have an aggregate 128 KiB cap before SDK buffering. 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.
@@ -107,35 +85,25 @@ a tokenizer or a monetary billing cap. Provider usage is reported separately.
Reservations are not refunded after cancellation. Rate limits survive runtime
restart within the broker's lifetime. Host shutdown clears this in-memory state.
## Connected host path
## Required host adapters
`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.
Production integration is blocked on these parent-owned contracts:
`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.
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.
## QA evidence
@@ -166,144 +134,19 @@ node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
```
The installed assistant command test passes on this branch's ordinary merge with
transport head `c4433d496`. It installs and
activates the six-file sample through the actual package manager and runtime peer,
then checks canonical SDK/RPC, independent model/content denial, text-review
cancellation, text/usage streaming, spoofed fields, revocation, and stale versions.
The in-process browser harness is a Node test transport, not native sandbox proof.
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.
`components/PluginAIContextHost.test.tsx` connects the default main adapter, actual
app preload factory, mounted host chooser, installed sample command/runtime peer,
existing provider factory, and existing main HTTP transport to the loopback
fixture. It checks content denial, review cancellation, streaming usage, Stop,
lock revocation, and absence of keys/configuration/policy on the trusted wire.
The view-visibility tests cover chooser appearance, dismissal, hidden surfaces,
and teardown without restoring an old view. DOM tests do not establish native
WebContentsView stacking or Chromium sandbox behavior.
The 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.
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 |
| `netcatty-ai-fixed-final-tests.log` | Before the later review corrections, `173ef5abb`: 15,248 tests, 15,205 passed, 0 failed, 0 cancelled, 43 skipped |
| `netcatty-ai-review-final-runtime.log` | After both review corrections: 614 tests, 612 passed, 0 failed, 0 cancelled, 2 skipped |
| `netcatty-ai-review-final-tests.log` | Review-corrected head `d81896b0d`: 15,252 tests, 15,209 passed, 0 failed, 0 cancelled, 43 skipped, before the later base merge |
| `netcatty-ai-review-merged-tests.log` | Integrated head `07eb68c5e`: 15,255 tests, 15,212 passed, 0 failed, 0 cancelled, 43 skipped, before the selected-Note expiry correction |
| `netcatty-ai-note-expiry-runtime.log` | Selected-Note expiry correction: 625 tests, 624 passed, 0 failed, 0 cancelled, 1 skipped |
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 review was initially
blocked. After receiving newer exact maintainer security-task evidence, the same
worker completed its bounded read-only review of `173ef5abb` without a repeated
restriction or alternate route/model. It confirmed the earlier repair and found
two additional P2 defects: runtime-bound budget keys and the sample's expired
chat recovery.
Commit `ee5a4a406` separates stable source budgets from strict runtime handle
identity. Its fake-clock regression uses the actual source-binding class,
replaces runtime binding objects, tests request and reserved-token budgets,
isolates another source with the same public plugin ID, and checks the minute
boundary. Both budget subtests fail on the reviewed source and pass after repair.
Missing or invalid host budget identities fail closed.
Commit `e1cb95ee8` reopens an expired sample chat before an explicit Send and adds
**Refresh model** for changed-model or failed sessions. Refresh preserves the
draft and does not send inference. A failed send/read is never automatically
retried. Stop or page teardown invalidates pending opens and closes late sessions.
Four controlled-time DOM regressions fail before the repair and pass afterward,
including an uncertain inference outcome and Stop during reopening. They use no
network or paid provider. Failed-before logs are
`/tmp/netcatty-ai-budget-review-before.log` and
`/tmp/netcatty-ai-panel-review-before.log`; passing logs use the corresponding
`-after.log` names. Correction review independently confirmed the stable budget
fix, but found the remaining selected-Note expiry path at `07eb68c5e`.
The corrected broker suite passes all 17 tests, the sample's controlled-time
panel suite passes all 4, and the connected provider/UI suite passes all 4. Full
lint still reports zero errors and seven existing warnings; codegen drift and
the production build pass. Two independent package builds reproduce SHA-256
`c0f733697b8f43a8591f39c02aeea5d8b7b4bd544b642ed87e758e62d98696a5`
for that earlier sample checkpoint.
Commit `9e7c9df58` adds explicit **Choose reference** recovery. The panel retains
summary expiry locally, blocks an expired selection before Send, and never sends
that local metadata as an authorization field. Refresh model never re-shares a
Note. Re-selection opens the existing host chooser and resets the dropdown to
No reference; the user selects the fresh handle and clicks Send explicitly.
The service's identical-selection cache now reuses only complete live summaries.
After an explicit host re-selection, expired/revoked handles remain invalid and
fresh eligible handles are issued. No authority lifetime or revocation rule is
changed. Summary selection does not read text. Every new content handle still
requires the existing per-context grant and exact-text review.
The new fake-clock panel regression uses the actual host service and selected
context authority with a real synthetic Note handle. It covers expiry, model
refresh without re-sharing, explicit re-selection, denial of the fresh content
grant, cancellation of text review, and one successful explicit send with the
draft preserved. It repeats the expiry/re-selection with both chat and Note
lifetimes expired, then succeeds without a manual model refresh. A separate
service regression reproduces the formerly empty
same-Note cache after expiry. Both fail on `07eb68c5e`; failed-before evidence is
`/tmp/netcatty-ai-note-expiry-panel-before.log` and
`/tmp/netcatty-ai-note-expiry-service-before.log`. The combined panel/service
suite now passes all 10 tests; the connected installed UI/provider suite passes
all 4. The replacement six-file artifact has SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Independent verification of the latest correction and native acceptance remain
pending. CI is not a substitute for either.
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.
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.
-108
View File
@@ -1,108 +0,0 @@
# Plugin model transport cold start
Commit `092438803bed96753bfe68cb78da9ca1137dee25` removes an independent startup
dependency on the internal AI sidebar. It does not establish the cause or outcome
of the original native failure; that still requires an authorized native retest.
## Reproduced boundary
At native candidate `677aac33b1eec9dbd4a367417a49c964cdea63e7`, the plugin model
selector reads the saved host provider, while main's provider registry starts
empty. The existing sidebar synchronizes that registry when visible or sending.
The plugin startup path did not. A nondefault configured loopback port can
therefore fail main's provider allowlist before HTTP transport starts.
The revised `components/PluginAIContextHost.test.tsx` installs the browser sample,
mounts the trusted host component, uses the real application preload, and routes
model requests through `registerProviderHandlers` and its actual allowlist. It
does not mount the internal sidebar or manually alter the allowlist. Credential
injection uses synthetic fixture markers; the HTTP edge and deterministic local
fixture use the existing transport. No paid service, real key, Note, or host is
used. This remains DOM/in-process evidence, not native sandbox acceptance.
Failed-before evidence is `/tmp/netcatty-ai-cold-provider-before-final.log`:
```text
ok: false
allowlistRejected: true
statusCode: undefined
```
The corrected regression passes and preserves both the selected fixture provider
and a second host-configured synthetic provider. It checks that provider markers
and configuration do not enter plugin-facing model/context envelopes. Expected
SDK error output is suppressed during this diagnostic so request payloads and
headers are not logged.
## Correction
Before publishing a selected model handle, the trusted renderer synchronizes the
current host-owned provider settings through the existing `aiSyncProviders`
application API. It checks that the selected model revision matches the snapshot
being synchronized. The runtime rechecks activity, cancellation, model revision,
and pending-selection ownership after the await. Pending selections count toward
the model-handle cap; release and shutdown invalidate late results.
Plugin requests supply no URL, credential, or provider configuration. The existing
main sender validation, endpoint allowlist, source binding, and model invocation
budgets are unchanged. No new schema feature, plugin permission, or consent
dialog is added. The assistant package is unchanged and retains SHA-256
`c7d784d5c3bd2feb463afd58eae3a9594615395b168174cb8a3d0677584ae395`.
Production integration touches `configuredModel.ts`, `trustedHostRuntime.ts`, and
`usePluginAIContextHost.ts`. The hook's import and `prepareModel` option may
overlap host-selection work; preserve both when integrating the separate policy
task. Permission-engine, consent-dialog, and sample-UI changes are outside this
correction.
## Native and policy status
The native open/close sidebar comparison was incomplete. Execution auto-review
denied the Application-scope `context.selected.list` grant; that action did not
execute. No post-sidebar inference was performed, the prompt expired, and the
sample was disabled while the worker cleaned up. This is not native confirmation
of the startup cause. The original `677aac33` app remains frozen for comparison;
this fix does not authorize retrying the denied execution action.
In the earlier permission engine, **Application** grants are memory-only for the
application lifetime; **Always** grants are persisted. An explicitly authorized
Application grant can fit bounded temporary QA. A one-use prompt on every stream
read is not an unavoidable target UX. The separate policy task implements the
new direction: default Allow for declared supported capabilities, per-plugin
Allow/Ask/Deny settings, audit, and in-app Ask dialogs. That product change is
separate from execution-tool authorization and must not be used to evade a tool
denial. This transport correction does not redesign that policy.
Native outcome, independent verification, and the sample's light-theme appearance
follow-up remain separate acceptance work. No GUI launch, sandbox bypass, paid
model invocation, main merge, or release was performed for this correction.
## Node 22 CI observer correction
The new cold-start test failed on exact head `9ca70f329` in test run
`40436198585`, Linux job `121965743101`: 15,206 passed, 1 failed, 50 skipped.
The sole failure was the empty SDK/renderer-error assertion receiving one
`SDK_OR_RENDERER_ERROR` marker. This red result is retained as evidence; it is
not an unrelated baseline failure.
The CI runtime was Node `22.23.3`; local validation had used Node `24.19.0`.
Reproduction on the exact CI version identified the safe category
`setup:node-sqlite-experimental`, before any model operation. The existing
`node:sqlite` import schedules its ExperimentalWarning on the next tick via
`console.error`. The newly added observer was installed before that warning
drained. The same test passed on Node 24, which did not emit that warning.
The test now lets pending module warnings reach normal stderr before installing
the SDK/renderer observer. The warning remains visible, and the zero-runtime-error
assertion is unchanged. Safe phase/error categories make future failures
diagnosable without request payloads or headers. No production behavior, timeout,
permission, or cancellation assertion changed. Both Node versions pass the
focused test; the Node 22 parallel fixture/UI/provider/preload group passes all
16 tests with concurrency 4.
Diagnostic evidence: `/tmp/netcatty-ai-cold-ci-node22-sqlite-category.log` fails
before correction, `/tmp/netcatty-ai-cold-ci-node22-after.log` passes with the
SQLite warning still visible, and `/tmp/netcatty-ai-cold-ci-node22-parallel.log`
records the parallel check. The appearance-only sample change is independently
published as `e325742abf9fd329f1f7858eda66367e7a62357c`; its package hash is
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
-155
View File
@@ -1,155 +0,0 @@
# 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
`7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274`.
This replaces the earlier `c7d784d5...` artifact with the sample-only appearance
update. The host provider cold-start correction remains a separate app change.
## Start the deterministic fixture
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.
12. Type a synthetic draft and click **Refresh model** while idle. The model
session must reopen, the draft must remain, and the fixture request count
must not increase. Change the selected configured model in host Settings,
refresh again, and check the displayed model before explicitly sending.
Refresh and failed requests must never automatically resend inference.
13. Keep a selected synthetic Note until its five-minute handle expires. Send
must explain that the reference expired without reading text or calling the
fixture. Refresh model must not share the Note again. Click **Choose
reference**, confirm the Note in the host chooser, select its fresh reference
in the dropdown, and click Send yourself. Deny the fresh content permission
once and cancel text review once before allowing a successful fixture run.
The draft must survive all steps, and no request may be silently retried.
14. With the sample idle, switch the host between light and dark and change its
accent. The page, controls, muted surfaces, text, focus ring, and Send button
must follow the existing view environment tokens without model requests or
new permission prompts. Capture both appearances on the same host window.
This check does not change or bypass the separately owned permission policy.
The expiry regression in `scripts/plugin-assistant-panel.test.cjs` controls the
panel and actual host service/selected-context authority clocks and advances past
300,000 ms using a real selected Note handle, without network calls. It checks
fresh host selection, old-handle rejection, independent content permission and
exact-text review, and a successful explicit send. The broker budget
regression controls its clock across runtime replacement and the minute boundary.
Do not change the host clock or production limits to reproduce those cases in
native QA.
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 review was initially
blocked; after newer exact maintainer security-task evidence, the same worker
completed its bounded read-only review at `173ef5abb`, with no repeated restriction
or alternate route/model. Two additional P2 findings were reproduced and repaired
in `ee5a4a406` (stable source budgets) and `e1cb95ee8` (sample session recovery).
Correction review independently confirmed the stable source-budget fix, then
identified the remaining selected-Note expiry path at `07eb68c5e`. Commit
`9e7c9df58` repairs that path with explicit reference re-selection and fresh
handles after host confirmation. The real-Note panel and service regressions
failed before repair and pass afterward. Independent verification of this latest
correction remains pending. CI and native evidence do not replace it.
@@ -1,86 +0,0 @@
# Byte stream acceptance evidence
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.
@@ -1,7 +1,7 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
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.
@@ -106,6 +106,6 @@ 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.
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
sample hash, and remaining native/exact-head review work. Do not disable the
Chromium sandbox to obtain that evidence.
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.
@@ -0,0 +1,125 @@
# 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.
@@ -1,44 +0,0 @@
# 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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,77 @@
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);
}
+1 -31
View File
@@ -241,7 +241,6 @@ 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:";
@@ -614,11 +613,7 @@ 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 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 { idleTimeoutMs, totalTimeoutMs } = resolveAIStreamTimeouts(options);
const maxErrorBodyBytes = Math.max(1, Number(options.maxErrorBodyBytes) || 64 * 1024);
let lifecycleFinished = false;
let idleTimer;
@@ -628,7 +623,6 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(totalTimer);
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -642,7 +636,6 @@ 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();
@@ -789,19 +782,10 @@ 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
@@ -1021,25 +1005,12 @@ 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) {
@@ -1085,7 +1056,6 @@ module.exports = {
buildExternalAgentSystemContext,
buildExternalAgentContextualPrompt,
_streamRequestForTests: streamRequest,
cancelPluginAIStreamsForSender,
_resolveAIStreamTimeoutsForTests: resolveAIStreamTimeouts,
_getActiveStreamCountForTests: () => activeStreams.size,
DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS,
@@ -1,73 +0,0 @@
"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);
});
}
+5 -15
View File
@@ -30,7 +30,6 @@ class PluginAIContextService {
this.runtimeContexts = new Map();
this.registrations = [];
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
resolveBudgetOwner: (binding) => JSON.stringify([binding.sourceKey, binding.privateOwner]),
selectModel: async (context) => {
await this.#guard(context);
const model = await this.host.selectModel(context);
@@ -50,16 +49,10 @@ class PluginAIContextService {
}
async #guard(context) {
try {
const owner = await this.resolveOwner(context);
await this.host.assertActive(context);
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
return owner;
} catch (error) {
const binding = this.source.bindings.get(context.runtimeId);
if (!context.signal?.aborted && binding?.pluginId === context.pluginId && binding.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
throw error;
}
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;
}
#authorization(permission, params) {
@@ -118,10 +111,7 @@ class PluginAIContextService {
}
const previous = this.runtimeContexts.get(context.runtimeId);
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
const items = await this.selected.summaries(context);
if (items.length === selections.length) return { items };
// The host has just explicitly selected these resources again. Expired
// or revoked handles stay invalid; issue fresh handles for this selection.
return { items: await this.selected.summaries(context) };
}
this.selected.releaseRuntime(context.runtimeId);
const created = [];
@@ -1,79 +0,0 @@
"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);
});
test("an explicit host re-selection issues fresh handles after identical selected Note summaries expire", async (t) => {
const f = fixture(); t.after(() => f.service.shutdown());
let now = 0; f.service.selected.clock = () => now;
const { items: [old] } = await f.service.list({}, f.context);
now = 300_001;
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
const { items } = await f.service.list({}, f.context);
assert.equal(items.length, 1);
assert.notEqual(items[0].handle, old.handle); assert.equal(items[0].expiresAt, 600_001);
await assert.rejects(f.service.selected.assertCurrent({ handle: old.handle, version: old.version }, f.context));
assert.equal(f.reads(), 0); assert.equal(f.authorizations.length, 0); assert.equal(f.invoked(), 0);
});
+21 -45
View File
@@ -28,16 +28,14 @@ function abortable(promise, signal) {
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
class ModelInvocationBroker {
constructor({ resolveDataOwner, resolveBudgetOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof resolveBudgetOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
constructor({ resolveDataOwner, selectModel, contexts, clock = Date.now, limits = {} }) {
if (typeof resolveDataOwner !== "function" || typeof selectModel !== "function") throw new TypeError("Host adapters required");
this.resolveDataOwner = resolveDataOwner;
this.resolveBudgetOwner = resolveBudgetOwner;
this.selectModel = selectModel;
this.contexts = contexts;
this.clock = clock;
this.limits = boundedLimits(limits);
this.chats = new Map();
this.openings = new Set();
this.turns = new Map();
this.rates = new Map();
this.closed = false;
@@ -55,21 +53,13 @@ class ModelInvocationBroker {
const owner = await this.resolveDataOwner(context);
await context.assertActive();
if (!owner || (typeof owner !== "object" && typeof owner !== "string")) throw fail("permissionDenied", "Host data owner is unavailable");
// Handle identity remains runtime-specific. Budget identity is separately
// supplied by the host and must survive runtime replacement/regrant.
const budgetOwner = await this.resolveBudgetOwner(owner, context);
await context.assertActive();
if (typeof budgetOwner !== "string" || !budgetOwner || Buffer.byteLength(budgetOwner) > 8_192) {
throw fail("permissionDenied", "Host budget owner is unavailable");
}
return { owner, budgetOwner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
}
async #ownedChat(chat, context) {
const binding = await this.#binding(context);
if (this.closed || this.chats.get(chat.handle) !== chat
|| chat.owner !== binding.owner || chat.budgetOwner !== binding.budgetOwner
|| chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|| chat.owner !== binding.owner || chat.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
throw fail("permissionDenied", "Plugin chat is unavailable");
}
}
@@ -91,33 +81,22 @@ 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.openings.size >= this.limits.chats
|| [...this.chats.values(), ...this.openings].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
if (this.closed || this.chats.size >= this.limits.chats
|| [...this.chats.values()].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
throw fail("resourceExhausted", "Plugin chat limit reached");
}
// Reserve before awaiting the host picker. Revoke/close can invalidate this
// admission even if a host callback ignores cancellation and returns late.
const opening = { ...binding, active: true };
this.openings.add(opening);
let model;
try {
model = await this.selectModel(context);
const current = await this.#binding(context);
if (!opening.active || this.closed || current.owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
throw fail("unavailable", "Choose a configured model in the host first");
}
await context.assertActive();
if (!opening.active || this.closed || (await this.#binding(context)).owner !== binding.owner) throw fail("cancelled", "Model selection was revoked");
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
this.chats.set(chat.handle, chat);
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
} catch (error) {
try { Promise.resolve(model?.dispose?.()).catch(() => {}); } catch {}
throw this.#safe(error);
} finally { this.openings.delete(opening); }
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 };
}
async start({ chatHandle, prompt, contexts = [] }, context) {
@@ -158,14 +137,14 @@ class ModelInvocationBroker {
}
await this.#guard(turn, context);
this.#prune();
const rate = this.rates.get(chat.budgetOwner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
const rate = this.rates.get(chat.owner) ?? { startedAt: this.clock(), count: 0, tokens: 0 };
// Reserve conservatively using UTF-8 input bytes plus the output cap. Do
// not refund unknown provider usage after cancellation or disconnect.
const reserved = bytes + 1_024 + this.limits.outputTokens;
if ((!this.rates.has(chat.budgetOwner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
if ((!this.rates.has(chat.owner) && this.rates.size >= this.limits.rateOwners) || rate.count >= this.limits.requestsPerMinute || rate.tokens + reserved > this.limits.reservedTokensPerMinute) {
throw fail("resourceExhausted", "Model request/token budget reached");
}
rate.count++; rate.tokens += reserved; this.rates.set(chat.budgetOwner, rate);
rate.count++; rate.tokens += reserved; this.rates.set(chat.owner, rate);
await this.#guard(turn, context);
const iterator = await abortable(chat.model.invoke({ prompt, contexts: selected, signal: controller.signal,
maxOutputTokens: this.limits.outputTokens, maxChunkBytes: this.limits.chunkBytes }), controller.signal);
@@ -264,7 +243,6 @@ 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);
@@ -272,16 +250,14 @@ 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 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(); }
shutdown() { this.closed = true; for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
}
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
@@ -3,12 +3,11 @@ const assert = require("node:assert/strict");
const test = require("node:test");
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
const { RPC_ERRORS } = require("../rpcRouter.cjs");
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
const denied = (error) => error.code === RPC_ERRORS.permissionDenied;
function fixture(options = {}) {
const owner = { budgetOwner: "synthetic-source-a" }; let currentOwner = owner; let current = true; let active = true; let now = 0;
const owner = {}; let currentOwner = owner; let current = true; let active = true; let now = 0;
const request = new AbortController();
const context = { pluginId: "same-id", pluginVersion: "1", runtimeId: "r1", signal: request.signal,
assertActive: async () => { request.signal.throwIfAborted(); if (!active) throw Error("stopped"); } };
@@ -18,8 +17,7 @@ function fixture(options = {}) {
yield { kind: "usage", inputTokens: 7, outputTokens: 2, totalTokens: 9, secret: "DO_NOT_SERIALIZE" }; })(); }, ...options.model };
const resolveDataOwner = async () => currentOwner;
const contexts = new SelectedContextAuthority({ resolveDataOwner, clock: () => now });
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: (binding) => binding.budgetOwner,
selectModel: async () => model, contexts,
const broker = new ModelInvocationBroker({ resolveDataOwner, selectModel: async () => model, contexts,
clock: () => now, limits: options.limits });
return { broker, context, contexts, calls, request, owner, model, setOwner: (value) => { currentOwner = value; },
setCurrent: (value) => { current = value; }, setActive: (value) => { active = value; }, setTime: (value) => { now = value; } };
@@ -211,76 +209,3 @@ 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();
});
test("same-source runtime replacement preserves minute budgets while distinct sources stay isolated", async (t) => {
for (const limits of [{ requestsPerMinute: 1 }, { reservedTokensPerMinute: 3_074 }]) {
await t.test(Object.keys(limits)[0], async (t) => {
let now = 1_000; let sourceId = "source-a"; let runtimeId = "runtime-1"; let invocations = 0;
const pluginId = "com.example.assistant";
const context = () => ({ pluginId, pluginVersion: "1", runtimeId, assertActive: async () => {} });
const source = () => ({ id: sourceId, kind: "third-party", origin: `https://${sourceId}.example` });
const binding = new PluginAISourceBinding({
database: {
getActivePlugin: () => ({ enabled: true, activeVersion: "1", runtime: { status: "running" }, source: source() }),
getRetainedPluginSource: source,
privateOwner: () => `opaque:${sourceId}:assistant`,
publicPluginId: (owner) => owner === `opaque:${sourceId}:assistant` ? pluginId : null,
},
getRuntimeIdentity: () => ({ ...context(), runtimeKind: "browser" }),
});
const broker = new ModelInvocationBroker({
resolveDataOwner: (caller) => binding.resolve(caller),
resolveBudgetOwner: (owner) => JSON.stringify([owner.sourceKey, owner.privateOwner]),
selectModel: () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
invocations++; return (async function* () {})();
} }), clock: () => now, limits,
});
t.after(() => { broker.shutdown(); binding.shutdown(); });
const start = (chat) => broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, context());
const replace = (nextSource) => {
broker.releaseRuntime(runtimeId); binding.releaseRuntime(runtimeId);
sourceId = nextSource; runtimeId = `${runtimeId}-next`;
};
const firstOwner = await binding.resolve(context());
const first = await broker.open(context()); const firstTurn = await start(first);
await broker.stop(firstTurn.turnHandle, context());
replace("source-a");
assert.notEqual(await binding.resolve(context()), firstOwner);
const restarted = await broker.open(context());
await assert.rejects(start(restarted), (error) => error.code === RPC_ERRORS.resourceExhausted);
assert.equal(invocations, 1);
assert.equal(await broker.close(first.chatHandle, context()), false);
replace("source-b");
const other = await broker.open(context()); const otherTurn = await start(other);
await broker.stop(otherTurn.turnHandle, context()); assert.equal(invocations, 2);
replace("source-a");
const returned = await broker.open(context());
now = 60_999;
await assert.rejects(start(returned), (error) => error.code === RPC_ERRORS.resourceExhausted);
now = 61_000;
const afterWindow = await start(returned); assert.equal(invocations, 3);
await broker.stop(afterWindow.turnHandle, context());
});
}
});
test("budget ownership requires an explicit host adapter and never falls back to the plugin ID", async (t) => {
assert.throws(() => new ModelInvocationBroker({ resolveDataOwner: () => ({}), selectModel: () => ({}) }), /Host adapters required/);
for (const budgetOwner of [undefined, "", {}, "x".repeat(8_193)]) {
const f = fixture(); t.after(() => f.broker.shutdown());
f.broker.resolveBudgetOwner = () => budgetOwner;
await assert.rejects(f.broker.open(f.context), denied);
assert.equal(f.calls.length, 0); assert.equal(f.broker.chats.size, 0);
}
});
-216
View File
@@ -1,216 +0,0 @@
"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 };
@@ -1,96 +0,0 @@
"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);
});
@@ -1,5 +1,4 @@
"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.
@@ -9,13 +8,7 @@ const { execFileSync } = require("node:child_process");
const { randomUUID, randomBytes } = require("node:crypto");
const { pathToFileURL } = require("node:url");
const path = require("node:path");
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 { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
function docker(args, input) {
@@ -30,15 +23,11 @@ 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(async () => {
await service?.manager.shutdown();
t.after(() => {
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]);
@@ -70,7 +59,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, "./protocol.mjs",
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
)).href);
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
const result = await queryExample({
@@ -80,34 +69,6 @@ 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",
@@ -38,13 +38,13 @@ 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", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
contributes: { commands: ["run", "pending", "cancel", "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", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
if (name === "selected") {
const selected = await context.selectedContext.list({});
@@ -62,11 +62,6 @@ 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(); }
}
try {
await connection.writable.write(Uint8Array.of(0, 255, 42));
return Array.from(await connection.readable.read());
@@ -109,7 +104,6 @@ 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);
+2 -3
View File
@@ -104,15 +104,14 @@ class PluginByteStreamService {
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
});
context.assertCommit();
if (record.closing) throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled");
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
context.signal?.addEventListener("abort", record.abort, { once: true });
// A cancelled attachment cannot leave a producer running after its RPC.
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
return { attached: true };
} catch (error) {
const alreadyClosing = record.closing;
try { record.output?.cancel(); } catch {}
this.closeRecord(record, error);
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
throw error;
}
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
@@ -1,97 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const { once } = require("node:events");
const { PluginByteStreamService } = require("./byteStreamService.cjs");
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
+37 -4
View File
@@ -61,6 +61,7 @@ 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));
@@ -72,15 +73,19 @@ class PluginHostInteractionService {
metadata: { capability: "ui", mutating: true },
validateParams: assertInteractionRequest,
authorization: (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 });
this.admitRequest(params, context);
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?.()) {
@@ -92,6 +97,7 @@ 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
@@ -144,6 +150,8 @@ 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 }),
});
}
@@ -258,6 +266,25 @@ 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");
@@ -298,6 +325,8 @@ 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()]) {
@@ -306,6 +335,7 @@ 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"));
}
@@ -313,6 +343,7 @@ 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);
@@ -324,6 +355,7 @@ 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() {
@@ -333,6 +365,7 @@ class PluginHostInteractionService {
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
this.revocation.dispose(); this.unsubscribeLock?.();
this.key.fill(0);
this.vaultService?.shutdown();
}
}
+17 -12
View File
@@ -29,8 +29,11 @@ const { PluginManager } = require("./pluginManager.cjs");
const { PluginNetworkBroker } = require("./networkBroker.cjs");
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
const { PluginAITrustedHostAdapter } = require("./ai/trustedHostAdapter.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.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");
@@ -93,12 +96,11 @@ function createPluginHostService(options) {
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const 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 database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
let aiContextService;
let aiHostAdapter;
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
@@ -199,6 +201,11 @@ 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");
@@ -266,16 +273,14 @@ 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: aiHostAdapter,
hostAdapter: options.pluginAIHostAdapter ?? {
assertActive: (context) => context.assertActive(),
selectModel: () => { throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin model adapter is unavailable"); },
listSelected: () => [],
reviewText: () => false,
},
});
aiContextService.registerRpcCapabilities(rpcRegistry);
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
@@ -425,6 +430,7 @@ function createPluginHostService(options) {
return {
themeService,
interactionService,
hostVaultService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
@@ -457,7 +463,6 @@ 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
@@ -0,0 +1,153 @@
"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,7 +2,3 @@
// 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");
+4
View File
@@ -380,6 +380,9 @@ 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),
@@ -574,6 +577,7 @@ function createPluginContext(config, client, runtimeApi) {
ai,
selectedContext,
ui,
vault,
clipboard,
filesystem,
companions,
-11
View File
@@ -5,17 +5,6 @@ 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 = [];
-7
View File
@@ -108,13 +108,6 @@ 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) => {
@@ -1,23 +0,0 @@
"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
@@ -1,2 +0,0 @@
.package/
*.ncpkg
+13 -35
View File
@@ -1,38 +1,16 @@
# Selected Note Assistant
# Assistant example source
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.
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.
The page follows light/dark mode and accent updates through the existing
`netcattyView.onDidChangeEnvironment()` API and seven public HSL theme tokens.
Missing tokens fall back to the standard sample palettes. Its theme subscription
is disposed on page hide and restored on page show.
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.
An expired chat is reopened before the next explicit Send. If the configured
model changes or a request fails, use **Refresh model** to reopen the session,
then click **Send** yourself. Refresh keeps your draft and never retries model
inference. If a request failed after reaching the provider, check its outcome
before choosing to send it again.
Selected references expire too. If a selected Note expires or changes, click
**Choose reference**, confirm the Note in Netcatty, and select its fresh reference
in the dropdown before clicking **Send**. Refresh model does not share a Note
again. Re-selection keeps your draft and does not read content or run inference;
the new reference still requires its own content permission and text review.
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.
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.
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.
+2 -10
View File
@@ -1,12 +1,4 @@
import { URL } from 'node:url';
import { mkdir, copyFile, rm } from 'node:fs/promises';
import { mkdir, copyFile } 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));
// 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));
}
import { URL } from 'node:url';
+12 -16
View File
@@ -1,29 +1,25 @@
:root { color-scheme: light dark; --background:0 0% 100%; --foreground:222 47% 12%; --muted:210 20% 96%; --muted-foreground:215 20% 35%; --border:215 20% 75%; --primary:221 83% 45%; --primary-foreground:0 0% 100%; font: 15px/1.5 system-ui,sans-serif; color:hsl(var(--foreground)); background:hsl(var(--background)); }
:root[data-theme="dark"] { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --background:221 39% 11%; --foreground:210 20% 98%; --muted:219 27% 21%; --muted-foreground:220 13% 91%; --border:218 11% 65%; --primary:213 97% 87%; --primary-foreground:221 39% 11%; } }
:root { color-scheme: dark; font: 15px/1.5 system-ui,sans-serif; color:#e9edf6; background:#10141c; }
* { box-sizing:border-box; }
body { margin:0; }
main { max-width:760px; padding:36px; margin:auto; }
header { margin-bottom:26px; }
.badge { color:hsl(var(--primary)); font-size:11px; font-weight:700; letter-spacing:1.6px; }
.badge { color:#8bb9ff; font-size:11px; font-weight:700; letter-spacing:1.6px; }
h1 { font-size:27px; font-weight:650; letter-spacing:-.6px; margin:8px 0; }
p { color:hsl(var(--muted-foreground)); margin:4px 0; }
.card { border:1px solid hsl(var(--border)); border-radius:10px; padding:16px 18px; margin:14px 0; background:hsl(var(--muted)); }
label { display:block; font-size:12px; font-weight:600; color:hsl(var(--muted-foreground)); margin-bottom:8px; }
p { color:#a9b4c7; margin:4px 0; }
.card { border:1px solid #303c4f; border-radius:10px; padding:16px 18px; margin:14px 0; background:#181f2a; }
label { display:block; font-size:12px; font-weight:600; color:#aabbd5; margin-bottom:8px; }
#model { font-weight:600; }
select, textarea { width:100%; background:hsl(var(--background)); color:inherit; border:1px solid hsl(var(--border)); padding:10px 12px; border-radius:7px; font:inherit; }
select:focus,textarea:focus,button:focus-visible { outline:2px solid hsl(var(--primary)); outline-offset:2px; }
select, textarea { width:100%; background:#10161f; color:#e9edf6; border:1px solid #455570; padding:10px 12px; border-radius:7px; font:inherit; }
select:focus,textarea:focus,button:focus-visible { outline:2px solid #8bb9ff; outline-offset:2px; }
textarea { resize:vertical; min-height:84px; }
form { margin:22px 0; }
.actions { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin-top:12px; }
button { border:1px solid hsl(var(--border)); border-radius:7px; background:transparent; color:inherit; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
#send { border-color:transparent; background:hsl(var(--primary)); color:hsl(var(--primary-foreground)); }
.actions { display:flex; align-items:center; gap:10px; margin-top:12px; }
button { border:1px solid #4e8bdf; border-radius:7px; background:#3274c6; color:white; padding:9px 24px; font:inherit; font-weight:600; cursor:pointer; }
button:disabled { opacity:.42; cursor:default; }
#choose-reference { margin-top:10px; }
#status { margin-left:4px; color:hsl(var(--muted-foreground)); font-size:12px; }
#stop { background:transparent; border-color:#56657b; }
#status { margin-left:4px; color:#b6c6dc; font-size:12px; }
.response { min-height:160px; }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:16px/1.7 system-ui,sans-serif; margin:0 0 12px; }
.hint,footer { font-size:12px; color:hsl(var(--muted-foreground)); margin-top:8px; }
.hint,footer { font-size:12px; color:#9aabc3; margin-top:8px; }
footer { padding-top:8px; }
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
@media (forced-colors: active) { button, select, textarea { border:1px solid ButtonText; } }
+2 -2
View File
@@ -11,8 +11,8 @@
<main>
<header><span class="badge">PLUGIN EXAMPLE</span><h1>Summarize a selected Note</h1><p>Use the model and reference shared by Netcatty.</p></header>
<section class="card"><label>Model selected in Netcatty</label><div id="model">Connecting...</div></section>
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p><button id="choose-reference" type="button" disabled>Choose reference</button></section>
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><button id="refresh" type="button" disabled>Refresh model</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
<section class="card"><label for="context">Selected reference</label><select id="context"><option value="">No reference</option></select><p class="hint">Only references explicitly shared with this plugin appear here.</p></section>
<form id="compose"><label for="prompt">Message</label><textarea id="prompt" rows="3" maxlength="4000">Summarize this Note in one sentence.</textarea><div class="actions"><button id="send" type="submit" disabled>Send</button><button id="stop" type="button" disabled>Stop</button><span id="status" role="status" aria-live="polite">Connecting...</span></div></form>
<section class="card response"><label>Response</label><pre id="response" aria-live="polite">Your response appears here.</pre><p id="usage" class="hint"></p></section>
<footer id="qa"></footer>
</main>
+23 -108
View File
@@ -2,124 +2,44 @@
'use strict';
const element = (id) => document.getElementById(id);
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
// Follow the same seven public semantic tokens as the other browser samples.
// Only this page's own DOM is updated; no host settings or DOM access is used.
(() => {
const names = ['--background', '--foreground', '--muted', '--muted-foreground', '--border', '--primary', '--primary-foreground'];
let subscription;
const subscribe = () => {
if (subscription) return;
subscription = window.netcattyView.onDidChangeEnvironment?.((environment) => {
const root = document.documentElement;
const theme = ['light', 'dark'].includes(environment.theme) ? environment.theme : 'system';
root.dataset.theme = theme;
root.style.colorScheme = theme === 'system' ? 'light dark' : theme;
root.dataset.reducedMotion = String(Boolean(environment.reducedMotion));
root.dataset.highContrast = String(Boolean(environment.highContrast));
for (const name of names) {
const value = environment.themeTokens?.[name];
if (typeof value === 'string' && value.length > 0 && value.length <= 96) root.style.setProperty(name, value);
else root.style.removeProperty(name);
}
});
};
subscribe();
window.addEventListener('pagehide', () => { subscription?.dispose(); subscription = undefined; });
window.addEventListener('pageshow', subscribe);
})();
let chat;
let turn;
let generation = 0;
let closed = false;
const references = new Map();
function busy(value) {
element('send').disabled = value || !chat;
element('stop').disabled = !value;
element('refresh').disabled = value;
element('choose-reference').disabled = value;
element('context').disabled = value;
element('prompt').disabled = value;
}
async function replaceChat(run) {
const previous = chat; chat = undefined;
if (previous) await invoke('close', { chatHandle: previous.chatHandle }).catch(() => {});
if (closed || run !== generation) return false;
const opened = await invoke('open');
if (closed || run !== generation) {
await invoke('close', { chatHandle: opened.chatHandle }).catch(() => {});
return false;
}
chat = opened;
element('model').textContent = chat.model;
return true;
}
async function selectReferences(run) {
const summaries = await invoke('contexts');
if (closed || run !== generation) return false;
references.clear();
const placeholder = document.createElement('option');
placeholder.value = ''; placeholder.textContent = 'No reference';
element('context').replaceChildren(placeholder);
for (const reference of summaries) {
if (reference.scope !== 'note') continue;
references.set(reference.handle, { handle: reference.handle, version: reference.version, expiresAt: reference.expiresAt });
const option = document.createElement('option');
option.value = reference.handle; option.textContent = reference.title;
element('context').append(option);
}
return true;
}
async function initialize() {
const run = ++generation;
busy(true);
try {
if (!await replaceChat(run)) return;
if (!await selectReferences(run)) return;
chat = await invoke('open');
element('model').textContent = chat.model;
const summaries = await invoke('contexts');
for (const reference of summaries) {
if (reference.scope !== 'note') continue;
references.set(reference.handle, { handle: reference.handle, version: reference.version });
const option = document.createElement('option');
option.value = reference.handle; option.textContent = reference.title;
element('context').append(option);
}
element('status').textContent = 'Ready';
busy(false);
} catch {
if (run !== generation) return;
const failedChat = chat; chat = undefined;
if (failedChat) await invoke('close', { chatHandle: failedChat.chatHandle }).catch(() => {});
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
} finally { if (run === generation) busy(false); }
element('status').textContent = 'Choose a model and grant access in Netcatty.';
}
}
element('refresh').addEventListener('click', async () => {
if (closed || element('refresh').disabled) return;
const run = ++generation;
busy(true); element('status').textContent = 'Refreshing model...';
try {
if (await replaceChat(run)) element('status').textContent = 'Ready';
} catch {
if (run === generation) element('status').textContent = 'Choose a model and grant access in Netcatty, then refresh the model.';
} finally { if (run === generation) busy(false); }
});
element('choose-reference').addEventListener('click', async () => {
if (closed || element('choose-reference').disabled) return;
const run = ++generation;
busy(true); element('status').textContent = 'Choosing reference...';
try {
if (await selectReferences(run)) element('status').textContent = 'References updated. Select a reference, then Send.';
} catch {
if (run === generation) element('status').textContent = 'Reference selection ended. Choose reference again when access is available.';
} finally { if (run === generation) busy(false); }
});
element('compose').addEventListener('submit', async (event) => {
event.preventDefault();
if (!chat || element('send').disabled) return;
const reference = references.get(element('context').value);
if (reference && reference.expiresAt <= Date.now()) {
element('status').textContent = 'Selected reference expired. Choose reference again, then Send.';
return;
}
const run = ++generation;
busy(true); element('status').textContent = 'Streaming...';
element('response').textContent = ''; element('usage').textContent = '';
try {
// No inference has been requested yet, so an expired session can be
// replaced before this explicit Send. Never retry a failed send/read.
if (chat.expiresAt <= Date.now() && !await replaceChat(run)) return;
const reference = references.get(element('context').value);
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
contexts: reference ? [{ handle: reference.handle, version: reference.version }] : [] });
contexts: reference ? [reference] : [] });
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
turn = started.turnHandle;
for (;;) {
@@ -131,32 +51,27 @@ element('compose').addEventListener('submit', async (event) => {
}
element('status').textContent = 'Complete';
} catch {
if (run === generation) element('status').textContent = 'Request ended. Check access, choose reference again, or refresh the model before sending again.';
if (run === generation) element('status').textContent = 'Request ended. Check access or try again.';
} finally {
if (run === generation) { turn = undefined; busy(false); }
}
});
element('stop').addEventListener('click', async () => {
if (element('stop').disabled) return;
const run = ++generation;
++generation;
const stoppedTurn = turn; turn = undefined;
element('status').textContent = 'Stopping...'; element('stop').disabled = true;
element('status').textContent = 'Stopping...';
try {
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
else if (chat) {
const stoppedChat = chat; chat = undefined;
await invoke('close', { chatHandle: stoppedChat.chatHandle });
await invoke('close', { chatHandle: chat.chatHandle });
chat = await invoke('open');
}
} catch { /* Closing a revoked session may already have stopped the turn. */ }
finally { if (run === generation) { element('status').textContent = 'Stopped'; busy(false); } }
}
finally { element('status').textContent = 'Stopped'; busy(false); }
});
// QA supplies this text through the regular view message surface.
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', () => {
closed = true; ++generation;
if (turn) invoke('stop', { turnHandle: turn }).catch(() => {});
if (chat) invoke('close', { chatHandle: chat.chatHandle }).catch(() => {});
});
window.addEventListener('pagehide', () => { if (turn) invoke('stop', { turnHandle: turn }).catch(() => {}); });
void initialize();
-111
View File
@@ -1,111 +0,0 @@
# Run the disposable database example
Use the Linux cloud GUI machine assigned by the parent. Keep the existing
Chromium sandbox settings. Use a fresh, isolated Netcatty QA profile. This
example does not use saved SSH or AI credentials.
## Prepare the fixture and package
1. Check out the exact transport PR head and run `npm ci` followed by
`npm run build:plugin-runtime-deps`.
2. Create a dedicated internal Docker network. Do not alter an existing
network if this subnet is already in use.
```sh
docker network create --internal --subnet 172.30.247.0/24 netcatty-plugin-db-qa
docker run --detach --rm --name netcatty-plugin-db-qa \
--network netcatty-plugin-db-qa --ip 172.30.247.2 \
--env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 \
mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873
```
3. Wait for `docker exec netcatty-plugin-db-qa mariadb-admin --host=127.0.0.1 ping --silent`
to succeed. The container publishes no host ports and has no external route.
The empty administrative password exists only in this disposable container.
4. Open its local client with
`docker exec -it netcatty-plugin-db-qa mariadb --protocol=socket --user=root`.
Create the fixture below. Replace the password placeholder with a new
synthetic session password. Do not put a real password in this example.
```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 '<new-synthetic-session-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
5. Build the normal browser package for this exact endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs \
tcp://172.30.247.2:3306 /tmp/database-minimal.ncpkg
```
The tool prints the package hash, not credentials. Its temporary staging
directory is removed. Only the manifest, browser bundle, and three view
assets enter the five-file package. No Node preparation tool, server setup,
dependency tree, or credential file enters the package.
If a different private subnet is needed, prepare a new package for its exact
fixture IP. Record the new package hash and review its endpoint permission.
The example page does not accept arbitrary hosts or SQL.
## Run automated entrypoint acceptance
From the repository root, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
The test creates its own isolated server, random session password, and temporary
package. It exercises both the transport core and the installed example entry
through the registered SDK/RPC path. It removes the container, network, package
and profile afterward. The in-process runtime harness does not prove native
browser sandbox behavior.
## Record native acceptance
1. Start the exact build with the existing sandbox settings and isolated
profile. Install the `.ncpkg` through the ordinary plugin manager. Its source
is a local import, not a claim of official maintenance.
2. Enable the plugin and open **Disposable database example** from its view
action. Record the plugin detail screen, source, version, and exact endpoint
resource. The plugin has a browser entry only.
3. Enter `plugin_demo` and the synthetic session password. The password input
is masked and clears before dispatch. Click **Connect and read** and deny
the endpoint prompt. The page must show a bounded failure and remain usable.
4. Retry and allow only `tcp://172.30.247.2:3306`. Record the permission prompt
and the result `{"columns":["value"],"rows":[["synthetic stream result"]]}`.
Do not record the password or administrative client session.
5. Revoke the stream grant in the manager, retry, and deny. Check that no
connection remains. Test **Cancel**, close/reopen the view, and disable the
plugin during a pending request. Restart with the same QA profile: the
plugin must require the session password again.
6. Inspect generic connection audit records. Record endpoint, open/close and
byte counts; verify that neither the password, SQL nor result body appears.
Verify the account's server-side write denial in the disposable server.
Save screenshots of install/details, denial, endpoint approval and the result,
plus a short recording of denial/retry/query/revoke/disable. This recipe does
not claim that native QA has run. The automated Node entrypoint harness is not
a BrowserWindow sandbox test.
The example database connection is plaintext only inside the disposable
network. It does not test MySQL STARTTLS. Generic TLS transport has separate
real-socket certificate and hostname validation tests. Do not use this example
with real data or an existing database.
## Remove the fixture
Disable and uninstall the example, then remove the temporary container and
network:
```sh
docker rm --force netcatty-plugin-db-qa
docker network rm netcatty-plugin-db-qa
```
The container, its synthetic credential hashes, and data disappear. Remove the
prepared package and isolated QA profile after retaining the required evidence.
@@ -1,47 +0,0 @@
"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,8 +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", minify: true,
nodePaths: [path.resolve(root, "../../../node_modules")] });
bundle: 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"));
+19
View File
@@ -0,0 +1,19 @@
# 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
@@ -0,0 +1,31 @@
// 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));
}
},
};
@@ -0,0 +1,37 @@
{
"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
+1 -4
View File
@@ -458,11 +458,8 @@ 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<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<import("@netcatty/plugin-contract").HostInteractionReceipt>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
@@ -1,33 +0,0 @@
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
import { STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_PROVIDERS } from '../../config/storageKeys';
import type { ProviderConfig } from '../types';
import { netcattyBridge } from '../../services/netcattyBridge';
// Host renderer only. Read the existing configured provider/model without
// loading AgentRuntime or syncing/inheriting the Agent's permission policy.
export function getConfiguredPluginModel() {
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
return selectedModel(providers);
}
function selectedModel(providers: ProviderConfig[]) {
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
const provider = providers.find((item) => item.id === activeProviderId && item.enabled);
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) };
}
// The existing main provider transport needs its host-owned registry for both
// credentials and endpoint admission. Do not depend on mounting the AI sidebar,
// or replace the registry with a plugin-supplied URL / just one selected entry.
export async function prepareConfiguredPluginModel(revision: string) {
const providers = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? [];
if (selectedModel(providers)?.revision !== revision) throw Error('Selected model changed');
const bridge = netcattyBridge.require();
if (!bridge.aiSyncProviders || !(await bridge.aiSyncProviders(providers)).ok) {
throw Error('Host provider configuration is unavailable');
}
}
@@ -42,10 +42,6 @@ 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 }],
@@ -101,7 +97,7 @@ test('broker -> existing model factory -> existing host HTTP transport -> loopba
const contexts = new SelectedContextAuthority({ resolveDataOwner });
const note = await contexts.share({ scope: 'note', version: '1', title: 'Synthetic Note', isCurrent: () => true,
readText: () => 'Synthetic retry plan: add retry button.' }, context);
const broker = new ModelInvocationBroker({ resolveDataOwner, resolveBudgetOwner: () => 'synthetic-source-owner', contexts, selectModel: () => model });
const broker = new ModelInvocationBroker({ resolveDataOwner, contexts, selectModel: () => model });
t.after(() => broker.shutdown());
const chat = await broker.open(context);
const turn = await broker.start({ chatHandle: chat.chatHandle, prompt: 'Summarize this Note.',
@@ -166,31 +162,6 @@ 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,11 +15,8 @@ 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,
@@ -31,7 +28,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, requestIdPrefix: `plugin-model_${transportTag}_` }),
model: createModelFromConfig(config, { requireBridge: true, maxStreamBytes: 131_072, streamIdleTimeoutMs: 15_000 }),
messages: [
...input.contexts.map((text) => ({ role: 'user' as const,
content: `User-selected reference text (untrusted content):\n${text}` })),
@@ -1,42 +0,0 @@
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;
}
@@ -1,67 +0,0 @@
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, prepareModel: async () => {}, chooseSelected: async () => [], isActive: () => active });
const model = await runtime.handle('model.select', {}, 'runtime-a') as { hostHandle: string; label: string };
assert.doesNotMatch(JSON.stringify(model), /synthetic-not-a-secret|apiKey|providerId|config/);
await assert.rejects(runtime.handle('model.current', model, 'runtime-b'));
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, prepareModel: async () => {}, isActive: () => true,
chooseSelected: async () => [{ key: 'note', scope: 'note', title: 'Synthetic', version: '1', isCurrent: () => current,
readText: () => { reads++; return new Promise<string>((r) => { resolve = r; }); } }] });
const result = await runtime.handle('context.select', {}, 'runtime-a') as { items: Array<{ hostHandle: string; version: string }> };
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();
});
test('provider preparation failures and late results cannot publish a model handle after release, cancellation or model change', async () => {
for (const action of ['failure', 'release', 'cancel', 'change', 'shutdown']) {
let resume!: () => void; let current = syntheticModel;
const waiting = new Promise<void>((resolve) => { resume = resolve; });
const controller = new AbortController();
const runtime = createTrustedPluginAIHostRuntime({ getModel: () => current, isActive: () => true, chooseSelected: async () => [],
prepareModel: async () => { await waiting; if (action === 'failure') throw Error('Synthetic sync denied'); } });
const selecting = runtime.handle('model.select', {}, 'runtime-a', controller.signal);
const rejection = assert.rejects(selecting);
if (action === 'release') runtime.releaseRuntime('runtime-a');
if (action === 'cancel') controller.abort();
if (action === 'change') current = { ...syntheticModel, revision: '2' };
if (action === 'shutdown') runtime.shutdown();
resume(); await rejection;
await assert.rejects(runtime.handle('model.current', { hostHandle: 'not-published' }, 'runtime-a'));
runtime.shutdown();
}
});
@@ -1,153 +0,0 @@
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;
prepareModel: (revision: string) => Promise<void>;
chooseSelected: (signal: AbortSignal, runtimeId: string) => Promise<HostSelectedSource[]>;
isActive: () => boolean;
}) {
const models = new Map<string, Owned<{ model: Model }>>();
const selections = new Set<{ runtimeId: string; active: boolean }>();
const turns = new Map<string, Owned<{ iterator: Iterator; controller: AbortController; reading: boolean }>>();
const sources = new Map<string, Owned<{ source: HostSelectedSource }>>();
let closed = false;
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 selection of selections) if (selection.runtimeId === runtimeId) { selection.active = false; selections.delete(selection); }
for (const [handle, turn] of turns) if (turn.runtimeId === runtimeId) cancel(handle);
for (const [handle, model] of models) if (model.runtimeId === runtimeId) models.delete(handle);
for (const [handle, source] of sources) if (source.runtimeId === runtimeId) sources.delete(handle);
};
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 + selections.size >= 32) throw Error('Model handle limit reached');
const selection = { runtimeId, active: true };
selections.add(selection);
try {
await options.prepareModel(selected.revision);
signal.throwIfAborted(); assertActive();
if (!selection.active || options.getModel()?.revision !== selected.revision) throw Error('Selected model changed');
const handle = crypto.randomUUID();
const model = createPluginModelAdapter({ config: selected.config, label: selected.label, transportTag: runtimeId,
isCurrent: () => !closed && options.isActive() && options.getModel()?.revision === selected.revision });
models.set(handle, { runtimeId, model });
return { hostHandle: handle, label: selected.label };
} finally { selections.delete(selection); }
}
if (kind === 'model.release') { owned(models, payload.hostHandle, runtimeId); models.delete(String(payload.hostHandle)); return { ok: true }; }
if (kind === 'model.current') return { current: await owned(models, payload.hostHandle, runtimeId).model.isCurrent() };
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 selection of selections) selection.active = false; selections.clear(); for (const handle of [...turns.keys()]) cancel(handle); models.clear(); sources.clear(); },
};
}
+1 -3
View File
@@ -23,8 +23,6 @@ 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>,
@@ -496,7 +494,7 @@ export function createBridgeFetchForSDK(
// Streaming path
if (isStreamingRequest(resolvedInit)) {
const requestId = `${requestContext?.requestIdPrefix ?? ''}sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext);
const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId);
@@ -1,18 +0,0 @@
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,10 +372,6 @@ 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,18 +228,27 @@ export type HostInteractionPrompt = {
pluginId: PluginId;
pluginVersion: SemanticVersion;
expiresAt: SafePositiveInteger;
phase: "request" | "preview";
request: HostInteractionRequest;
phase: "request" | "preview" | "commit";
request: (HostInteractionRequest) | (VaultHostActionRequest);
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";
action: "cancel" | "preview" | "submit" | "commit" | "complete" | "fail";
value?: string;
resources?: VaultHostSelectedResources;
draft?: (VaultHostCreateFields) | (VaultHostEditFields);
scriptsRun?: boolean;
};
export type HostInteractionResult = ({
@@ -247,7 +256,7 @@ export type HostInteractionResult = ({
}) | ({
cancelled: false;
value: (string) | (true);
});
}) | (VaultHostSelectedResult) | (VaultHostActionAccepted);
export type HostQuickPickItem = {
id: string;
@@ -263,6 +272,8 @@ export type HostQuickPickRequest = {
export type HostSingleLineInputText = string;
export type HostVaultFeature = "vault.host-actions";
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
@@ -531,140 +542,6 @@ 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 = {
@@ -1369,8 +1246,117 @@ 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,6 +58,7 @@ 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,33 +24,6 @@ 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,6 +115,15 @@ 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>;
@@ -759,6 +768,7 @@ export interface PluginContext {
readonly settings: PluginSettings;
readonly commands: PluginCommands;
readonly ui: PluginHostUI;
readonly vault: PluginVault;
readonly clipboard: PluginClipboard;
readonly contextKeys: PluginContextKeys;
readonly views: PluginViews;
@@ -956,6 +966,7 @@ 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";
-224
View File
@@ -1,224 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { JSDOM } = require("jsdom");
async function fixture(t, options = {}) {
const root = path.resolve(__dirname, "../examples/plugins/assistant");
const dom = new JSDOM(fs.readFileSync(path.join(root, "panel.html"), "utf8"), { runScripts: "outside-only" });
t.after(() => dom.window.close());
const stylesheet = dom.window.document.createElement("style");
stylesheet.textContent = fs.readFileSync(path.join(root, "panel.css"), "utf8");
dom.window.document.head.append(stylesheet);
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
const calls = [];
const environmentListeners = new Set(); let environment;
const element = (id) => dom.window.document.getElementById(id);
dom.window.Date.now = options.clock ?? (() => now);
Object.defineProperty(dom.window, "netcattyView", { value: {
onDidReceiveMessage: () => () => {},
onDidChangeEnvironment: (listener) => {
environmentListeners.add(listener);
if (environment) {
const replay = environment;
queueMicrotask(() => { if (environmentListeners.has(listener) && replay === environment) listener(replay); });
}
return { dispose: () => environmentListeners.delete(listener) };
},
executeCommand: async (id, params) => {
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
if (options.commands) return options.commands(operation, structuredClone(params));
if (operation === "open") {
const count = ++opens;
if (count > 1 && options.opening) await options.opening;
return { chatHandle: `chat-${count}`, model: `Synthetic model ${count}`, expiresAt: now + 300_000 };
}
if (operation === "contexts") return [];
if (operation === "close" || operation === "stop") return { ok: true };
if (operation === "send") {
if (params.chatHandle === "chat-1" && (changed || now >= 300_000)) throw Error("Session unavailable");
modelCalls++;
if (options.uncertain) throw Error("Reply lost after invocation");
return { turnHandle: "synthetic-turn" };
}
if (operation === "read") return { kind: "end", sequence: 0 };
throw Error(`Unexpected operation ${operation}`);
},
} });
const waitFor = async (condition) => {
const deadline = Date.now() + 2_000;
while (!condition()) {
if (Date.now() >= deadline) throw Error(`Panel state timed out: ${element("status").textContent}`);
await new Promise(setImmediate);
}
};
dom.window.eval(fs.readFileSync(path.join(root, "panel.js"), "utf8"));
await waitFor(() => element("status").textContent === "Ready");
element("prompt").value = "Synthetic draft must survive session recovery";
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
emitEnvironment: (value) => { environment = value; for (const listener of environmentListeners) listener(value); },
environmentListeners: () => environmentListeners.size,
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
}
test("assistant follows host light/dark and accent updates with safe fallbacks and subscription cleanup", async (t) => {
const f = await fixture(t); const root = f.window.document.documentElement;
const computed = () => f.window.getComputedStyle(root);
const calls = f.calls.length;
assert.equal(computed().colorScheme, "light dark");
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
assert.equal(f.environmentListeners(), 1);
f.emitEnvironment({ theme: "light", themeTokens: { "--background": "20 20% 98%", "--primary": "145 60% 35%", "--muted": "30 10% 90%", "--private-token": "ignored" } });
assert.equal(root.style.colorScheme, "light"); assert.equal(root.dataset.theme, "light");
assert.equal(root.style.getPropertyValue("--primary"), "145 60% 35%");
assert.equal(root.style.getPropertyValue("--private-token"), "");
f.emitEnvironment({ theme: "dark", themeTokens: { "--background": "220 20% 10%", "--primary": "280 80% 75%" } });
assert.equal(root.style.colorScheme, "dark");
assert.equal(root.style.getPropertyValue("--primary"), "280 80% 75%");
assert.equal(root.style.getPropertyValue("--muted"), "");
assert.equal(computed().getPropertyValue("--muted").trim(), "219 27% 21%");
f.emitEnvironment({ theme: "light", themeTokens: { "--primary": "x".repeat(97) } });
assert.equal(root.style.colorScheme, "light"); assert.equal(root.style.getPropertyValue("--primary"), "");
assert.equal(computed().getPropertyValue("--background").trim(), "0 0% 100%");
f.emitEnvironment({ theme: "system" }); assert.equal(root.style.colorScheme, "light dark");
assert.equal(f.calls.length, calls); assert.equal(f.modelCalls(), 0);
f.window.dispatchEvent(new f.window.Event("pagehide")); assert.equal(f.environmentListeners(), 0);
f.emitEnvironment({ theme: "dark", themeTokens: { "--primary": "210 90% 70%" } });
assert.equal(root.style.colorScheme, "light dark");
f.window.dispatchEvent(new f.window.Event("pageshow"));
await new Promise(setImmediate);
assert.equal(f.environmentListeners(), 1); assert.equal(root.style.colorScheme, "dark");
assert.equal(root.style.getPropertyValue("--primary"), "210 90% 70%");
});
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
const f = await fixture(t); f.timeTo(300_001); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.opens(), 2);
assert.deepEqual(f.calls.filter((call) => call.operation === "send").map((call) => call.params.chatHandle), ["chat-2"]);
assert.equal(f.modelCalls(), 1);
assert.equal(f.element("status").textContent, "Complete");
assert.match(f.element("prompt").value, /must survive/);
});
test("changed-model rejection offers explicit reopen without automatically resending", async (t) => {
const f = await fixture(t); f.changeModel(); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.opens(), 1); assert.equal(f.modelCalls(), 0);
assert.ok(f.element("refresh"), "A changed model needs an explicit session refresh control");
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 0);
assert.match(f.element("model").textContent, /model 2/);
assert.match(f.element("prompt").value, /must survive/);
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(f.modelCalls(), 1);
});
test("uncertain inference outcome is never automatically retried by session refresh", async (t) => {
const f = await fixture(t, { uncertain: true }); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.modelCalls(), 1); assert.equal(f.opens(), 1);
assert.ok(f.element("refresh"), "Recovery must reopen only, never retry inference");
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 1);
assert.match(f.element("prompt").value, /must survive/);
});
test("Stop during expired-session reopening discards the late session without sending", async (t) => {
let resume; const opening = new Promise((resolve) => { resume = resolve; });
t.after(() => resume());
const f = await fixture(t, { opening }); f.timeTo(300_001); f.send();
await f.waitFor(() => f.opens() === 2);
f.element("stop").click(); resume();
await f.waitFor(() => f.calls.some((call) => call.operation === "close" && call.params.chatHandle === "chat-2"));
assert.equal(f.modelCalls(), 0); assert.equal(f.element("status").textContent, "Stopped");
assert.match(f.element("prompt").value, /must survive/);
});
test("expired real selected Note requires explicit re-selection, then fresh content permission and review", async (t) => {
const { PluginAIContextService } = require("../electron/plugins/ai/aiContextService.cjs");
let now = 0; let selections = 0; let reads = 0; let modelCalls = 0;
let contentAllowed = false; let reviewAllowed = false;
const grants = []; const reviews = [];
const caller = { pluginId: "com.netcatty.assistant", runtimeId: "selected-note-panel", pluginVersion: "0.1.0", assertActive: async () => {} };
const source = { id: "synthetic-source", kind: "third-party", origin: "https://synthetic.example" };
const service = new PluginAIContextService({
database: {
getActivePlugin: () => ({ source, enabled: true, activeVersion: caller.pluginVersion, runtime: { status: "running" } }),
getRetainedPluginSource: () => source, privateOwner: () => "synthetic-private-owner", publicPluginId: () => caller.pluginId,
},
getRuntimeIdentity: () => ({ ...caller, runtimeKind: "browser" }),
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: async (_caller, grant) => {
grants.push(grant); if (!contentAllowed) throw Error("Synthetic content grant denied");
} },
hostAdapter: {
assertActive: async () => {},
selectModel: async () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
modelCalls++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; })();
} }),
listSelected: async () => {
selections++;
return [{ key: "synthetic-note", scope: "note", title: "Synthetic selected Note", version: "v1", isCurrent: () => true,
readText: () => { reads++; return "Synthetic selected Note body"; } }];
},
reviewText: async (text) => { reviews.push(text); return reviewAllowed; },
},
});
service.selected.clock = () => now; service.broker.clock = () => now;
t.after(() => service.shutdown());
const commands = {
open: () => service.open({}, caller), contexts: async () => (await service.list({}, caller)).items,
send: (params) => service.start(params, caller), read: (params) => service.read(params, caller),
stop: (params) => service.stop(params, caller), close: (params) => service.close(params, caller),
};
const f = await fixture(t, { clock: () => now, commands: (operation, params) => commands[operation](params) });
const selector = f.element("context"); const oldHandle = selector.options[1].value;
selector.value = oldHandle;
now = 300_001;
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.equal(f.calls.filter((call) => call.operation === "send").length, 0);
assert.equal(selections, 1); assert.equal(modelCalls, 0); assert.equal(reads, 0);
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(selections, 1); // Refresh model must not silently share the Note again.
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.ok(f.element("choose-reference"));
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
assert.equal(selections, 2); assert.equal(selector.value, "");
const newHandle = selector.options[1].value;
assert.notEqual(newHandle, oldHandle);
const [fresh] = await service.selected.summaries(caller);
assert.equal(fresh.handle, newHandle); assert.equal(fresh.expiresAt, 600_001);
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
assert.equal(reads, 0); assert.equal(grants.length, 0); assert.equal(modelCalls, 0);
selector.value = newHandle;
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
assert.equal(grants.length, 1); assert.equal(reads, 0); assert.equal(modelCalls, 0);
contentAllowed = true;
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
assert.equal(reviews.length, 1); assert.equal(reviews[0].text, "Synthetic selected Note body"); assert.equal(modelCalls, 0);
reviewAllowed = true;
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(modelCalls, 1); assert.equal(reviews.length, 2);
assert.ok(grants.every((grant) => grant.permission === "context.selected.read" && grant.allowedScopes[0] === "once"
&& grant.resources[0] === `selected:${newHandle}:v1`));
assert.match(f.element("prompt").value, /must survive/);
// Repeat with both lifetimes expired, without manually refreshing the model.
now = 600_002;
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.equal(modelCalls, 1); assert.equal(selections, 2);
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
const repeatedHandle = selector.options[1].value;
assert.notEqual(repeatedHandle, newHandle); assert.equal(selector.value, "");
selector.value = repeatedHandle;
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(modelCalls, 2); assert.equal(grants.at(-1).resources[0], `selected:${repeatedHandle}:v1`);
await assert.rejects(service.selected.assertCurrent({ handle: newHandle, version: "v1" }, caller));
assert.match(f.element("prompt").value, /must survive/);
});
@@ -1,55 +0,0 @@
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());
}
@@ -1,25 +0,0 @@
"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 });
});