mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730fa643ba | ||
|
|
267bcbfb8b | ||
|
|
17c48b4d79 | ||
|
|
739cab2965 | ||
|
|
cecfa3a47e | ||
|
|
0a590b4ebc | ||
|
|
69c2cc8d1f | ||
|
|
ccfe41f39a | ||
|
|
d5cb8cfd3b | ||
|
|
c4433d4961 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 |
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
};
|
||||
@@ -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 текущих хостов.',
|
||||
};
|
||||
@@ -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 个当前主机。',
|
||||
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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)} → {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>;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# Plugin AI and selected context: implementation checkpoint
|
||||
|
||||
Base: ThemePack #3978, `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
Branch: `feat/plugin-ai-context`.
|
||||
Status: isolated host internals and fixture-tested transport reuse. Public contract,
|
||||
production adapters, installable example, independent review, and native acceptance
|
||||
are pending. This source is not ready to merge or release.
|
||||
|
||||
## Proposed public boundaries
|
||||
|
||||
The proposed optional features are `ai.model-invocation` and
|
||||
`context.selected-read`. The parent has not confirmed these names. No canonical
|
||||
schema, generated contract, SDK, runtime peer, hostService, or installer changes
|
||||
are included in this checkpoint.
|
||||
|
||||
The model API uses a host-selected configured model. Plugins receive a chat
|
||||
handle, a display label, an expiry, turn handles, text chunks, and usage totals.
|
||||
Plugins do not supply provider IDs, URLs, credentials, tools, or Agent policies.
|
||||
The internal operations are `open`, `start`, `read`, `stop`, and `close`.
|
||||
`read` pulls one chunk with one pending read per turn. The parent can map this
|
||||
iterator onto the existing stream router after the canonical contract is agreed.
|
||||
|
||||
Context summary enumeration returns only references explicitly shared with the
|
||||
current runtime and source owner. Content reads require an opaque handle and an
|
||||
expected version. Summary enumeration never calls the content reader. Supported
|
||||
selected scopes are workspace, tab, session, host, and Note. There is no inventory
|
||||
or arbitrary entity-ID lookup.
|
||||
|
||||
## File ownership
|
||||
|
||||
| Owner | Files or responsibility |
|
||||
| --- | --- |
|
||||
| This task | `electron/plugins/ai/`, `electron/plugins/context/`, `infrastructure/ai/plugin/`, `examples/plugins/assistant/`, this document |
|
||||
| This task | Opt-in `requireBridge` and `maxStreamBytes` request context in `infrastructure/ai/sdk/providers.ts` |
|
||||
| Parent coordination | Canonical schema, generated files, SDK, runtime forwarding, hostService registration, trusted renderer adapters |
|
||||
| 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 |
|
||||
|
||||
The existing `feat/plugin-terminal-sharing` commit
|
||||
`3cdc97782ef3262969eee9d540e03ebe6fdee210` is preserved. Its
|
||||
`terminal-sharing-wip.md` describes incomplete selection publication and review.
|
||||
This branch does not claim that workflow is complete.
|
||||
|
||||
## Implemented authority
|
||||
|
||||
`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`.
|
||||
|
||||
Each handle is bound to a data owner, a runtime ID, and a plugin version.
|
||||
Every read rechecks runtime authority, the current model, handle lifetime, and
|
||||
selected context versions. Revocation aborts pending turns. Late provider results
|
||||
are discarded. Another source with the same plugin ID cannot read, cancel, or
|
||||
close the original source's handles.
|
||||
|
||||
`SelectedContextAuthority.share` is a trusted host operation. A plugin must never
|
||||
call it directly. Its descriptor accepts only a scope, a title, a version, a text
|
||||
reader, and a current-selection guard. Unknown Host fields are rejected. Content
|
||||
has a 16 KiB cap, excludes NUL, and rejects PEM private keys. The production host
|
||||
reader must project safe fields explicitly and exclude passwords, private keys,
|
||||
TOTP values, identity credentials, startup commands, and environment variables.
|
||||
Free-form Notes can contain secrets that field projection cannot detect. The
|
||||
trusted sharing UI must preview the selected text before sharing it.
|
||||
|
||||
`createPluginModelAdapter` calls the existing `createModelFromConfig` and
|
||||
`streamText`, through the existing host provider transport. Credential injection
|
||||
stays in Netcatty. Direct-fetch fallback is disabled for this adapter. Incoming
|
||||
SSE payloads have an aggregate 128 KiB cap before SDK buffering. SDK retries are
|
||||
disabled. Only text and allowlisted token totals reach the broker. Provider
|
||||
errors, raw chunks, reasoning, headers, and tool payloads do not cross it.
|
||||
|
||||
No AgentRuntime, internal prompt, full MCP catalog, tool executor, Node runtime,
|
||||
raw host IPC, or global renderer store is exposed to plugins. A model tool request
|
||||
ends the turn without executing an action. Notes edits and terminal input are
|
||||
outside this checkpoint. No command is written to a terminal or followed by Enter.
|
||||
|
||||
The default broker limits are defined in `MODEL_INVOCATION_LIMITS` and
|
||||
`SELECTED_CONTEXT_LIMITS`. They include concurrent handles, turn and chat lifetime,
|
||||
input and output bytes, output token caps, event count, requests per minute, and
|
||||
reserved tokens per minute. Input token reservation uses UTF-8 bytes plus 1,024
|
||||
bytes for framing and the output token cap. This is a conservative estimate, not
|
||||
a tokenizer or a monetary billing cap. Provider usage is reported separately.
|
||||
Reservations are not refunded after cancellation. Rate limits survive runtime
|
||||
restart within the broker's lifetime. Host shutdown clears this in-memory state.
|
||||
|
||||
## Required host adapters
|
||||
|
||||
Production integration is blocked on these parent-owned contracts:
|
||||
|
||||
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
|
||||
|
||||
The domain suites and transport suite use synthetic data only. The transport
|
||||
suite starts a deterministic OpenAI-compatible HTTP server on loopback and
|
||||
checks this path:
|
||||
|
||||
`ModelInvocationBroker` -> `createPluginModelAdapter` -> `createModelFromConfig`
|
||||
-> `createBridgeFetchForSDK` -> existing `_streamRequestForTests` main transport
|
||||
-> local fixture -> text and usage.
|
||||
|
||||
This is not real model inference or paid API testing. It uses no paid keys, real
|
||||
Notes, hosts, or chat history. The credential value is a fixed synthetic marker;
|
||||
the existing model factory replaces it with its IPC placeholder. The test does
|
||||
not exercise native credential storage or production credential injection.
|
||||
|
||||
The tests cover source, runtime, and version denial; separate summaries and
|
||||
content reads; context invalidation; provider cancellation and late results;
|
||||
deadlines; request, token, event, input, output, and stream-byte limits; and
|
||||
allowlisted response serialization. Raw provider diagnostics remain inside the
|
||||
host.
|
||||
|
||||
Commands:
|
||||
|
||||
```sh
|
||||
node --test electron/plugins/modelInvocation.test.cjs
|
||||
node --test --import tsx infrastructure/ai/plugin/modelInvocationAdapter.test.ts
|
||||
npx eslint infrastructure/ai/plugin/*.ts infrastructure/ai/sdk/providers.ts examples/plugins/assistant/panel.js
|
||||
```
|
||||
|
||||
The final focused domain run passes 16 tests. The transport suite passes three
|
||||
tests, including the actual assistant panel's Send, streaming, usage, and Stop
|
||||
handlers in JSDOM. The test bridge uses structured clones to model the existing
|
||||
view command boundary. It does not replace installed-plugin or native coverage.
|
||||
The wider plugin runtime run passes 556 tests with one skipped. The existing
|
||||
provider regression suites pass 34 tests.
|
||||
|
||||
The 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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
|
||||
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
No source database or sync implementation is changed by the transport delta.
|
||||
|
||||
## Registered transport
|
||||
|
||||
Canonical definitions are in `packages/plugin-contract/schema/plugin-contract.schema.json`.
|
||||
`network.byte-streams` negotiates the capability. The separate `network.stream`
|
||||
permission requires explicit `tcp://host:port` or `tls://host:port` resources.
|
||||
Wildcard declarations and grants cannot authorize raw streams. HTTP grants and
|
||||
payload limits are unchanged.
|
||||
|
||||
The SDK exposes `context.network.connect({ endpoint }, cancellationToken?)`.
|
||||
The returned connection has `endpoint`, `readable`, `writable`, `close()`, and
|
||||
`dispose()`. The control methods are `network.stream.connect`,
|
||||
`network.stream.attach`, and `network.stream.close`. Descriptors, request
|
||||
shapes, feature names, limits, generated SDK types, and host validators share
|
||||
the canonical schema. Unknown request properties are rejected.
|
||||
|
||||
The registered service authenticates the host runtime before source lookup.
|
||||
Runtime startup captures the existing opaque source owner. Every asynchronous
|
||||
commit revalidates the runtime, active and retained source, and opaque owner
|
||||
through the source task's APIs. Caller-supplied owner/source parameters are
|
||||
rejected. Audit commits also verify the source binding.
|
||||
|
||||
Connection and stream IDs are host-issued and bound to that runtime and owner.
|
||||
The SDK prepares its incoming waiter before attach starts the host producer.
|
||||
Both directions reuse the existing binary stream router and credit windows.
|
||||
Input credit returns after the socket write callback. Output backpressure
|
||||
bounds the producer. The router has one optional producer `onClose` hook;
|
||||
peer cancellation releases the socket even while it is idle.
|
||||
|
||||
The host validates all DNS answers and pins a literal IP. Production rejects
|
||||
loopback, link-local, unspecified, multicast, mapped local addresses, selected
|
||||
IPv6 tunnel forms, and the machine's own interface addresses. The production
|
||||
host constructor never forwards the core's explicit local-test override.
|
||||
There is no proxy discovery, reconnect, UDP, listen, generic IPC, shell, Node
|
||||
plugin runtime, or filesystem escape.
|
||||
|
||||
Limits are four connections per runtime, 64 globally, 64 KiB chunks and socket
|
||||
buffer thresholds, and 8 MiB combined bytes per connection, plus the existing
|
||||
runtime byte quota. Connect, idle, and lifetime deadlines are 10, 30, and 120
|
||||
seconds. Close tombstones are capped at 256 small identity records. Revocation,
|
||||
runtime stop, disable, shutdown, and stream cancellation release resources.
|
||||
|
||||
TLS requires trust and original-hostname validation with TLS 1.2 or later.
|
||||
Plugins cannot set custom CAs, SNI, or validation exceptions. STARTTLS is not
|
||||
implemented. Generic audit records contain endpoint, runtime, handle, and byte
|
||||
counts. The host does not inspect SQL or record payloads or credentials.
|
||||
|
||||
## Additive AI interface for the parallel owner
|
||||
|
||||
The same canonical schema defines the relayed `AI_CONTEXT_ROUTES` types.
|
||||
The SDK/runtime expose `context.ai.openChat/startTurn/readTurn/stopTurn/closeChat`
|
||||
and `context.selectedContext.list/read`. Host registration advertises
|
||||
`ai.model-invocation` and `context.selected-read`, and wires runtime cleanup,
|
||||
plugin disable, revocation through the service, and shutdown.
|
||||
|
||||
The trusted host adapter constructor option is `pluginAIHostAdapter`. It has
|
||||
the service owner's `assertActive`, `selectModel`, `listSelected`, `reviewText`,
|
||||
and optional lifecycle callbacks. These callbacks are never exposed to plugin
|
||||
views. The default adapter rejects model invocation, returns no selected
|
||||
resources, and refuses text review. The AI owner still owns real renderer
|
||||
wiring and model/context UX. A registered synthetic adapter test is not proof
|
||||
of that remaining production wiring.
|
||||
|
||||
## Database example
|
||||
|
||||
`examples/plugins/database-minimal` is an ordinary browser plugin. Its developer
|
||||
preparation tool creates an `.ncpkg` for one exact disposable private IPv4
|
||||
endpoint. The output contains a browser bundle, a normal manifest, and view
|
||||
assets. The preparation tool and its Node dependencies are not in the plugin.
|
||||
|
||||
Only the example contains the database protocol: common protocol 4.1,
|
||||
`mysql_native_password`, and
|
||||
`SELECT value FROM plugin_demo.example_result LIMIT 1`.
|
||||
The view accepts session-only fixture credentials and clears its password
|
||||
field before dispatch. It does not save view state, settings, logs, or secrets.
|
||||
The host never exposes SSH or AI credentials. JavaScript does not promise
|
||||
memory zeroization. The account's server-side SELECT grant enforces read-only
|
||||
access. The fixed query is not a SQL security classifier.
|
||||
|
||||
The example has no query editor, schema browser, pool, arbitrary SQL, or host
|
||||
connection-management subsystem. See the example README for native setup.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- Both source-reviewed races have executable real-loopback regressions here.
|
||||
Delayed final-byte consumption previously lost EOF after the socket closed.
|
||||
Double-microtask cancellation previously created an untracked late socket.
|
||||
Each regression failed before its respective fix and passed afterward.
|
||||
- The broker tests use real TCP/TLS sockets, including trusted fixture CA,
|
||||
untrusted certificate and wrong-hostname denial. Connect and idle deadline
|
||||
cases use mock clocks around real sockets or delayed DNS.
|
||||
- The installed entrypoint test builds and installs a normal package, executes
|
||||
its actual browser entry module through SDK/RPC/binary streams, and exercises
|
||||
grant denial, exact resources, unknown-field rejection, close, revoke, and
|
||||
disable. It also exercises registered AI/context SDK routes with a synthetic
|
||||
trusted adapter. Its runtime harness is Node and is explicitly not a browser
|
||||
sandbox or OS sandbox test.
|
||||
- The disposable MariaDB test checks a synthetic SELECT result and server-side
|
||||
INSERT denial. It creates an internal network, publishes no host ports, and
|
||||
removes the container and network. Database transport is plain TCP inside
|
||||
that disposable network, not database TLS.
|
||||
|
||||
Normal native BrowserWindow, full app/package checks, exact-head independent
|
||||
review, and screenshot/video acceptance remain separate required evidence.
|
||||
Do not disable the Chromium sandbox to obtain that evidence.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
|
||||
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
|
||||
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
|
||||
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
|
||||
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
|
||||
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
|
||||
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
|
||||
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
|
||||
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
@@ -26,6 +29,7 @@ const PERMISSION_DESCRIPTIONS = {
|
||||
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
|
||||
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
|
||||
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
|
||||
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
|
||||
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
|
||||
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
|
||||
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("../contractValidator.cjs");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.cjs");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const FEATURES = Object.freeze(["ai.model-invocation", "context.selected-read"]);
|
||||
const ROUTES = Object.freeze([
|
||||
["ai.openChat", "PluginAIEmptyParams", "PluginAIChatResult", "ai.invoke", "open"],
|
||||
["ai.startTurn", "PluginAIStartTurnParams", "PluginAITurnResult", "ai.invoke", "start"],
|
||||
["ai.readTurn", "PluginAITurnRef", "PluginAIStreamEvent", "ai.invoke", "read"],
|
||||
["ai.stopTurn", "PluginAITurnRef", "PluginAIBooleanResult", "ai.invoke", "stop"],
|
||||
["ai.closeChat", "PluginAIChatRef", "PluginAIBooleanResult", "ai.invoke", "close"],
|
||||
["context.selected.list", "PluginAIEmptyParams", "PluginSelectedContextListResult", "context.selected.list", "list"],
|
||||
["context.selected.read", "PluginSelectedContextRef", "PluginSelectedContextContent", "context.selected.read", "readSelected"],
|
||||
]);
|
||||
|
||||
class PluginAIContextService {
|
||||
constructor(options) {
|
||||
this.host = options.hostAdapter;
|
||||
for (const method of ["selectModel", "listSelected", "reviewText", "assertActive"]) {
|
||||
if (typeof this.host?.[method] !== "function") throw new TypeError(`AI host adapter ${method} required`);
|
||||
}
|
||||
this.source = new PluginAISourceBinding(options);
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.resolveOwner = (context) => this.source.resolve(context);
|
||||
this.selected = new SelectedContextAuthority({ resolveDataOwner: this.resolveOwner });
|
||||
this.runtimeContexts = new Map();
|
||||
this.registrations = [];
|
||||
this.broker = new ModelInvocationBroker({ resolveDataOwner: this.resolveOwner,
|
||||
selectModel: async (context) => {
|
||||
await this.#guard(context);
|
||||
const model = await this.host.selectModel(context);
|
||||
await this.#guard(context);
|
||||
return model;
|
||||
},
|
||||
contexts: {
|
||||
read: (reference, context) => this.readSelected(reference, context),
|
||||
assertCurrent: (reference, context) => this.selected.assertCurrent(reference, context),
|
||||
onDidInvalidate: (listener) => this.selected.onDidInvalidate(listener),
|
||||
releaseRuntime: (runtimeId) => this.selected.releaseRuntime(runtimeId),
|
||||
releaseOwner: (owner) => this.selected.releaseOwner(owner),
|
||||
},
|
||||
});
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
this.unsubscribeHost = this.host.onDidInvalidate?.(() => this.releaseAll());
|
||||
}
|
||||
|
||||
async #guard(context) {
|
||||
const owner = await this.resolveOwner(context);
|
||||
await this.host.assertActive(context);
|
||||
if (await this.resolveOwner(context) !== owner) throw fail("permissionDenied", "Plugin source changed");
|
||||
return owner;
|
||||
}
|
||||
|
||||
#authorization(permission, params) {
|
||||
const resource = permission === "context.selected.read" ? `selected:${params.handle}:${params.version}` : "*";
|
||||
return { permission, resources: [resource],
|
||||
reason: permission === "ai.invoke" ? "Use the model selected in Netcatty without tools"
|
||||
: permission === "context.selected.list" ? "List summaries of the current selected context"
|
||||
: "Review and share this selected context version",
|
||||
...(permission === "context.selected.read" ? { allowedScopes: ["once"] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
if (this.registrations.length) throw new Error("AI/context routes are already registered");
|
||||
// Canonical validators are created only when transport integration registers
|
||||
// this service. There is no second plugin wire schema in these modules.
|
||||
const prepared = ROUTES.map(([method, paramsType, resultType, permission, operation]) => ({
|
||||
method, permission, operation,
|
||||
validateParams: createDefinitionValidator(paramsType),
|
||||
validateResult: createDefinitionValidator(resultType),
|
||||
}));
|
||||
for (const route of prepared) {
|
||||
this.registrations.push(registry.registerRequest(route.method, async (params, context) => {
|
||||
await this.#guard(context);
|
||||
const result = await this[route.operation](params, context);
|
||||
await this.#guard(context);
|
||||
if (!route.validateResult(result)) throw fail("internal", "AI/context response is invalid");
|
||||
return result;
|
||||
}, {
|
||||
metadata: { capability: "plugin-ai-context", mutating: false, permission: route.permission },
|
||||
validateParams: (params) => {
|
||||
if (!route.validateParams(params)) throw fail("invalidArgument", "AI/context request is invalid");
|
||||
return Object.freeze(structuredClone(params));
|
||||
},
|
||||
authorization: async (params, context) => {
|
||||
await this.#guard(context);
|
||||
if (route.operation === "readSelected") await this.selected.assertCurrent(params, context);
|
||||
return this.#authorization(route.permission, params);
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
open(_params, context) { return this.broker.open(context); }
|
||||
start(params, context) { return this.broker.start(params, context); }
|
||||
read({ turnHandle }, context) { return this.broker.read(turnHandle, context); }
|
||||
async stop({ turnHandle }, context) { return { ok: await this.broker.stop(turnHandle, context) }; }
|
||||
async close({ chatHandle }, context) { return { ok: await this.broker.close(chatHandle, context) }; }
|
||||
|
||||
async list(_params, context) {
|
||||
const owner = await this.#guard(context);
|
||||
const selections = await this.host.listSelected(context);
|
||||
await this.#guard(context);
|
||||
if (!Array.isArray(selections) || selections.length > 5 || new Set(selections.map((item) => item.scope)).size !== selections.length) {
|
||||
throw fail("resourceExhausted", "Selected scope limit reached");
|
||||
}
|
||||
const previous = this.runtimeContexts.get(context.runtimeId);
|
||||
if (previous?.owner === owner && previous.keys === JSON.stringify(selections.map(({ key, version }) => [key, version]))) {
|
||||
return { items: await this.selected.summaries(context) };
|
||||
}
|
||||
this.selected.releaseRuntime(context.runtimeId);
|
||||
const created = [];
|
||||
try {
|
||||
for (const { key, scope, title, version, readText, isCurrent } of selections) {
|
||||
if (typeof key !== "string" || !key || key.length > 256) throw fail("invalidArgument", "Host selection key is invalid");
|
||||
const summary = await this.selected.share({ scope, title, version, readText, isCurrent }, context);
|
||||
created.push(summary.handle);
|
||||
}
|
||||
await this.#guard(context);
|
||||
this.runtimeContexts.set(context.runtimeId, { owner, keys: JSON.stringify(selections.map(({ key, version }) => [key, version])) });
|
||||
return { items: await this.selected.summaries(context) };
|
||||
} catch (error) { for (const handle of created) this.selected.release(handle); throw error; }
|
||||
}
|
||||
|
||||
async readSelected(reference, context) {
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
const authorization = this.#authorization("context.selected.read", reference);
|
||||
// Direct RPC reads have already passed the registry permission middleware.
|
||||
// A model turn must obtain its separate content grant here.
|
||||
if (context.method !== "context.selected.read" || context.authorization?.permission !== authorization.permission
|
||||
|| context.authorization.resources?.[0] !== authorization.resources[0]) {
|
||||
await this.permissionEngine.authorize(context, authorization);
|
||||
}
|
||||
await this.#guard(context);
|
||||
const selected = await this.selected.read(reference, context);
|
||||
await this.#guard(context);
|
||||
if (!await this.host.reviewText({ scope: selected.scope, title: selected.title, version: selected.version,
|
||||
text: selected.text, pluginId: context.pluginId }, context)) throw fail("cancelled", "Selected context sharing was cancelled");
|
||||
await this.#guard(context);
|
||||
await this.selected.assertCurrent(reference, context);
|
||||
return selected;
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
this.broker.releaseRuntime(runtimeId);
|
||||
this.runtimeContexts.delete(runtimeId);
|
||||
this.source.releaseRuntime(runtimeId);
|
||||
this.host.releaseRuntime?.(runtimeId);
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, { owner }] of [...this.runtimeContexts]) if (owner.pluginId === pluginId) this.releaseRuntime(runtimeId);
|
||||
for (const binding of [...this.source.bindings.values()]) if (binding.pluginId === pluginId) this.releaseRuntime(binding.runtimeId);
|
||||
this.source.releasePlugin(pluginId);
|
||||
}
|
||||
releaseAll() { for (const binding of [...this.source.bindings.values()]) this.releaseRuntime(binding.runtimeId); }
|
||||
shutdown() {
|
||||
for (const registration of this.registrations.splice(0)) registration.dispose();
|
||||
this.releaseAll(); this.broker.shutdown(); this.selected.shutdown(); this.source.shutdown();
|
||||
this.revocation.dispose(); this.unsubscribeHost?.(); this.host.shutdown?.();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES: FEATURES, AI_CONTEXT_ROUTES: ROUTES };
|
||||
@@ -0,0 +1,263 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const LIMITS = Object.freeze({ chats: 32, perRuntime: 2, turnMs: 60_000, chatMs: 300_000,
|
||||
inputBytes: 16_384, outputBytes: 32_768, outputTokens: 2_048, requestsPerMinute: 8,
|
||||
reservedTokensPerMinute: 65_536, contexts: 4, chunkBytes: 4_096, events: 8_192, rateOwners: 128 });
|
||||
|
||||
function boundedLimits(overrides) {
|
||||
return Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
|
||||
const value = overrides[key] ?? max;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
|
||||
return [key, value];
|
||||
})));
|
||||
}
|
||||
|
||||
function abortable(promise, signal) {
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
// In-memory, source-isolated handles. No AgentRuntime, tools, chat persistence,
|
||||
// provider configuration, URLs, credentials or arbitrary system prompts cross it.
|
||||
class ModelInvocationBroker {
|
||||
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.selectModel = selectModel;
|
||||
this.contexts = contexts;
|
||||
this.clock = clock;
|
||||
this.limits = boundedLimits(limits);
|
||||
this.chats = new Map();
|
||||
this.turns = new Map();
|
||||
this.rates = new Map();
|
||||
this.closed = false;
|
||||
this.contextSubscription = contexts?.onDidInvalidate((handle) => {
|
||||
for (const turn of [...this.turns.values()]) {
|
||||
if (turn.references.some((reference) => reference.handle === handle)) {
|
||||
this.#end(turn, fail("cancelled", "Selected context was revoked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async #binding(context) {
|
||||
await context.assertActive();
|
||||
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");
|
||||
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.runtimeId !== binding.runtimeId || chat.pluginVersion !== binding.pluginVersion) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async #check(chat, context) {
|
||||
await this.#ownedChat(chat, context);
|
||||
if (!await chat.model.isCurrent()) throw fail("permissionDenied", "Selected model changed");
|
||||
const currentOwner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
if (currentOwner !== chat.owner || this.closed || this.chats.get(chat.handle) !== chat || chat.expiresAt <= this.clock()) {
|
||||
throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
#prune() {
|
||||
for (const chat of [...this.chats.values()]) if (chat.expiresAt <= this.clock()) this.#close(chat);
|
||||
for (const [owner, rate] of this.rates) if (rate.startedAt + 60_000 <= this.clock()) this.rates.delete(owner);
|
||||
}
|
||||
|
||||
async open(context) {
|
||||
const binding = await this.#binding(context);
|
||||
// Host chooses from configured models. Plugin parameters cannot override it.
|
||||
const model = await this.selectModel(context);
|
||||
await context.assertActive();
|
||||
if (!model || typeof model.invoke !== "function" || typeof model.isCurrent !== "function"
|
||||
|| typeof model.label !== "string" || model.label.length > 160 || !await model.isCurrent()) {
|
||||
throw fail("unavailable", "Choose a configured model in the host first");
|
||||
}
|
||||
await context.assertActive();
|
||||
this.#prune();
|
||||
if (this.closed || this.chats.size >= this.limits.chats
|
||||
|| [...this.chats.values()].filter((chat) => chat.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Plugin chat limit reached");
|
||||
}
|
||||
const chat = { ...binding, handle: randomUUID(), model, expiresAt: this.clock() + this.limits.chatMs, turn: null };
|
||||
this.chats.set(chat.handle, chat);
|
||||
return { chatHandle: chat.handle, model: model.label, expiresAt: chat.expiresAt };
|
||||
}
|
||||
|
||||
async start({ chatHandle, prompt, contexts = [] }, context) {
|
||||
assertPluginJsonValue({ chatHandle, prompt, contexts }, { maxBytes: this.limits.inputBytes + 2048 });
|
||||
if (typeof prompt !== "string" || !prompt.trim() || prompt.includes("\0") || !Array.isArray(contexts)
|
||||
|| contexts.length > this.limits.contexts || Buffer.byteLength(prompt) > this.limits.inputBytes) {
|
||||
throw fail("invalidArgument", "Invalid model input");
|
||||
}
|
||||
const references = contexts.map((reference) => {
|
||||
if (!reference || Array.isArray(reference) || Object.keys(reference).some((key) => !["handle", "version"].includes(key))
|
||||
|| typeof reference.handle !== "string" || !reference.handle || reference.handle.length > 128
|
||||
|| typeof reference.version !== "string" || !reference.version || reference.version.length > 128) {
|
||||
throw fail("invalidArgument", "Invalid context reference");
|
||||
}
|
||||
return Object.freeze({ handle: reference.handle, version: reference.version });
|
||||
});
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) throw fail("permissionDenied", "Plugin chat is unavailable");
|
||||
await this.#check(chat, context);
|
||||
if (chat.turn) throw fail("resourceExhausted", "A turn is already active");
|
||||
const controller = new AbortController();
|
||||
const turn = { handle: randomUUID(), chat, controller, reading: false, bytes: 0, sequence: 0, iterator: null, references: Object.freeze(references) };
|
||||
chat.turn = turn;
|
||||
this.turns.set(turn.handle, turn);
|
||||
const abort = () => this.#end(turn, fail("cancelled", "Model turn cancelled"));
|
||||
context.signal?.addEventListener("abort", abort, { once: true });
|
||||
turn.cleanup = () => context.signal?.removeEventListener("abort", abort);
|
||||
turn.timer = setTimeout(() => this.#end(turn, fail("deadlineExceeded", "Model turn timed out")), this.limits.turnMs);
|
||||
try {
|
||||
await this.#guard(turn, context);
|
||||
const selected = [];
|
||||
let bytes = Buffer.byteLength(prompt);
|
||||
for (const reference of references) {
|
||||
const result = await abortable(this.contexts.read(reference, { ...context, signal: controller.signal }), controller.signal);
|
||||
bytes += Buffer.byteLength(result.text);
|
||||
if (bytes > this.limits.inputBytes) throw fail("resourceExhausted", "Model input byte budget exceeded");
|
||||
selected.push(result.text);
|
||||
}
|
||||
await this.#guard(turn, context);
|
||||
this.#prune();
|
||||
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.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.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);
|
||||
await this.#guard(turn, context);
|
||||
if (!iterator || typeof iterator.next !== "function") throw fail("internal", "Model adapter failed");
|
||||
turn.iterator = iterator;
|
||||
return { turnHandle: turn.handle, maxOutputTokens: this.limits.outputTokens, reservedTokens: reserved };
|
||||
} catch (error) {
|
||||
this.#end(turn, error);
|
||||
throw this.#safe(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #guard(turn, context) {
|
||||
turn.controller.signal.throwIfAborted();
|
||||
await abortable(this.#check(turn.chat, context), turn.controller.signal);
|
||||
for (const reference of turn.references) {
|
||||
await abortable(this.contexts.assertCurrent(reference, { ...context, signal: turn.controller.signal }), turn.controller.signal);
|
||||
}
|
||||
turn.controller.signal.throwIfAborted();
|
||||
if (this.turns.get(turn.handle) !== turn || turn.chat.turn !== turn) throw fail("cancelled", "Model turn ended");
|
||||
}
|
||||
|
||||
#safe(error) {
|
||||
// SDK/provider errors may contain headers, URLs or echoed request content.
|
||||
return error instanceof PluginRpcError ? error : fail("unavailable", "Model invocation failed");
|
||||
}
|
||||
|
||||
async read(turnHandle, context) {
|
||||
const turn = this.turns.get(turnHandle);
|
||||
if (!turn) throw fail("permissionDenied", "Model turn is unavailable");
|
||||
// Validate owner before mutating/aborting another caller's turn.
|
||||
await this.#ownedChat(turn.chat, context);
|
||||
if (turn.reading || !turn.iterator) throw fail("resourceExhausted", "A model read is already pending");
|
||||
turn.reading = true;
|
||||
const abort = () => this.#end(turn, fail("cancelled", "Model read cancelled"));
|
||||
context.signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
if (context.signal?.aborted) abort();
|
||||
await this.#guard(turn, context);
|
||||
if (turn.sequence >= this.limits.events) throw fail("resourceExhausted", "Model event budget exceeded");
|
||||
const next = await abortable(turn.iterator.next(), turn.controller.signal);
|
||||
await this.#guard(turn, context);
|
||||
if (next.done) {
|
||||
this.#end(turn);
|
||||
return { kind: "end", sequence: turn.sequence++ };
|
||||
}
|
||||
const event = next.value;
|
||||
if (event?.kind === "text" && typeof event.text === "string") {
|
||||
const bytes = Buffer.byteLength(event.text);
|
||||
if (bytes > this.limits.chunkBytes || turn.bytes + bytes > this.limits.outputBytes) {
|
||||
throw fail("resourceExhausted", "Model output byte budget exceeded");
|
||||
}
|
||||
turn.bytes += bytes;
|
||||
return { kind: "text", sequence: turn.sequence++, text: event.text };
|
||||
}
|
||||
if (event?.kind === "usage") {
|
||||
const usage = {};
|
||||
for (const key of ["inputTokens", "outputTokens", "totalTokens"]) {
|
||||
if (event[key] !== undefined) {
|
||||
if (!Number.isSafeInteger(event[key]) || event[key] < 0) throw fail("internal", "Invalid model usage");
|
||||
usage[key] = event[key];
|
||||
}
|
||||
}
|
||||
if ((usage.outputTokens ?? 0) > this.limits.outputTokens) throw fail("resourceExhausted", "Model output token budget exceeded");
|
||||
return { kind: "usage", sequence: turn.sequence++, ...usage };
|
||||
}
|
||||
// A model's tool request is data, never host authorization. This phase
|
||||
// exposes no execution path, and drops raw tool payloads/reasoning/errors.
|
||||
throw fail("permissionDenied", "Model tools and unsupported events are disabled");
|
||||
} catch (error) {
|
||||
this.#end(turn, error);
|
||||
throw this.#safe(error);
|
||||
} finally { turn.reading = false; context.signal?.removeEventListener("abort", abort); }
|
||||
}
|
||||
|
||||
async stop(turnHandle, context) {
|
||||
const turn = this.turns.get(turnHandle);
|
||||
if (!turn) return false;
|
||||
await this.#ownedChat(turn.chat, context);
|
||||
this.#end(turn, fail("cancelled", "Model turn cancelled"));
|
||||
return true;
|
||||
}
|
||||
|
||||
#end(turn, reason = fail("cancelled", "Model turn ended")) {
|
||||
if (this.turns.get(turn.handle) !== turn) return;
|
||||
this.turns.delete(turn.handle);
|
||||
if (turn.chat.turn === turn) turn.chat.turn = null;
|
||||
clearTimeout(turn.timer); turn.cleanup?.(); turn.controller.abort(reason);
|
||||
// Never wait for a provider which ignores cancellation. Its late next()
|
||||
// result is observed by abortable() and cannot re-enter the output path.
|
||||
try { Promise.resolve(turn.iterator?.return?.()).catch(() => {}); } catch {}
|
||||
turn.iterator = null;
|
||||
}
|
||||
|
||||
#close(chat) {
|
||||
if (chat.turn) this.#end(chat.turn);
|
||||
this.chats.delete(chat.handle);
|
||||
}
|
||||
async close(chatHandle, context) {
|
||||
const chat = this.chats.get(chatHandle);
|
||||
if (!chat) return false;
|
||||
await this.#ownedChat(chat, context); this.#close(chat); return true;
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const chat of [...this.chats.values()]) if (chat.runtimeId === runtimeId) this.#close(chat);
|
||||
this.contexts?.releaseRuntime(runtimeId);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const chat of [...this.chats.values()]) if (chat.owner === owner) this.#close(chat);
|
||||
this.contexts?.releaseOwner(owner);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const chat of [...this.chats.values()]) this.#close(chat); this.rates.clear(); this.contextSubscription?.dispose(); }
|
||||
}
|
||||
|
||||
module.exports = { ModelInvocationBroker, MODEL_INVOCATION_LIMITS: LIMITS };
|
||||
@@ -0,0 +1,211 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { ModelInvocationBroker } = require("./modelInvocationBroker.cjs");
|
||||
const { SelectedContextAuthority } = require("../context/selectedContextAuthority.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 = {}; 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"); } };
|
||||
const calls = [];
|
||||
const model = { label: "Host selected model", isCurrent: () => current,
|
||||
invoke: (input) => { calls.push(input); return (async function* () { yield { kind: "text", text: "Summary" };
|
||||
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, 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; } };
|
||||
}
|
||||
|
||||
test("selected synthetic Note traverses actual broker; text/usage are projected without configuration or secrets", async (t) => {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
const note = await f.contexts.share({ scope: "note", title: "Synthetic note", version: "v1",
|
||||
readText: () => "Synthetic release: add retry button", isCurrent: () => true }, f.context);
|
||||
assert.equal(f.calls.length, 0);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Summarize", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
|
||||
assert.deepEqual(f.calls[0].contexts, ["Synthetic release: add retry button"]);
|
||||
assert.equal(f.calls[0].tools, undefined);
|
||||
const events = [await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context), await f.broker.read(turn.turnHandle, f.context)];
|
||||
assert.equal(events[0].text, "Summary");
|
||||
assert.deepEqual(events[1], { kind: "usage", sequence: 1, inputTokens: 7, outputTokens: 2, totalTokens: 9 });
|
||||
assert.equal(events[2].kind, "end");
|
||||
assert.doesNotMatch(JSON.stringify({ chat, turn, events, note }), /apiKey|config|DO_NOT_SERIALIZE/);
|
||||
});
|
||||
|
||||
test("same pluginId with another source owner, runtime or version cannot use handles or stop victim turns", async (t) => {
|
||||
const f = fixture(); t.after(() => f.broker.shutdown());
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
for (const changed of [{ runtimeId: "r2" }, { pluginVersion: "2" }]) {
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, { ...f.context, ...changed }), denied);
|
||||
await assert.rejects(f.broker.stop(turn.turnHandle, { ...f.context, ...changed }), denied);
|
||||
}
|
||||
f.setOwner({});
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
|
||||
await assert.rejects(f.broker.close(chat.chatHandle, f.context), denied);
|
||||
f.setOwner(f.owner);
|
||||
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "Summary");
|
||||
});
|
||||
|
||||
test("pending read cancels promptly and a provider's late result never escapes", async (t) => {
|
||||
const pending = deferred(); let signal;
|
||||
const f = fixture({ model: { invoke: (input) => { signal = input.signal; return { next: () => pending.promise }; } } });
|
||||
t.after(() => f.broker.shutdown());
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
|
||||
await assert.rejects(reading, (e) => e.code === RPC_ERRORS.cancelled);
|
||||
assert.equal(signal.aborted, true);
|
||||
pending.resolve({ value: { kind: "text", text: "Late output" } });
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), denied);
|
||||
});
|
||||
|
||||
test("runtime teardown, owner revoke and request abort discard pending results", async () => {
|
||||
for (const action of ["runtime", "owner", "abort"]) {
|
||||
const pending = deferred();
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context);
|
||||
await new Promise(setImmediate);
|
||||
if (action === "runtime") f.broker.releaseRuntime("r1");
|
||||
if (action === "owner") f.broker.releaseOwner(f.owner);
|
||||
if (action === "abort") f.request.abort();
|
||||
await assert.rejects(reading);
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline also aborts providers that ignore AbortSignal", async () => {
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => new Promise(() => {}) }) }, limits: { turnMs: 20 } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.deadlineExceeded);
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("model switch and chat expiration reject results", async () => {
|
||||
for (const action of ["model", "expiry"]) {
|
||||
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
if (action === "model") f.setCurrent(false); else f.setTime(300_001);
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
await assert.rejects(reading, denied); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds cover request rate, output bytes/tokens, concurrent read and raw error serialization", async () => {
|
||||
const f = fixture({ limits: { requestsPerMinute: 1 } });
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
await f.broker.stop(turn.turnHandle, f.context);
|
||||
await assert.rejects(f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.releaseRuntime("r1");
|
||||
const again = await f.broker.open(f.context);
|
||||
await assert.rejects(f.broker.start({ chatHandle: again.chatHandle, prompt: "Hi" }, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
f.broker.shutdown();
|
||||
for (const event of [{ kind: "text", text: "X".repeat(4097) }, { kind: "usage", outputTokens: 2049 }, { kind: "tool-call", command: "rm -rf /" }]) {
|
||||
const instance = fixture({ model: { invoke: () => (async function* () { yield event; })() } });
|
||||
const selected = await instance.broker.open(instance.context);
|
||||
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
|
||||
await assert.rejects(instance.broker.read(started.turnHandle, instance.context));
|
||||
assert.equal(instance.broker.turns.size, 0); instance.broker.shutdown();
|
||||
}
|
||||
const broken = fixture({ model: { invoke: () => { throw Error("api-key secret request body"); } } });
|
||||
const selected = await broken.broker.open(broken.context);
|
||||
await assert.rejects(broken.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, broken.context), (e) => !/secret|api-key/.test(e.message));
|
||||
broken.broker.shutdown();
|
||||
});
|
||||
|
||||
test("revoking selected context aborts an in-flight turn; changing its version suppresses late output", async () => {
|
||||
for (const action of ["revoke", "version"]) {
|
||||
let current = true; const pending = deferred();
|
||||
const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const note = await f.contexts.share({ scope: "note", title: "Synthetic", version: "1",
|
||||
readText: () => "Synthetic", isCurrent: () => current }, f.context);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [{ handle: note.handle, version: note.version }] }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
if (action === "revoke") f.contexts.release(note.handle); else current = false;
|
||||
pending.resolve({ value: { kind: "text", text: "Late" } });
|
||||
await assert.rejects(reading); f.broker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("input, aggregate output, reserved token and concurrent read bounds", async () => {
|
||||
const cases = [
|
||||
{ limits: { inputBytes: 2 }, prompt: "too big", start: true },
|
||||
{ limits: { reservedTokensPerMinute: 1 }, prompt: "Hi", start: true },
|
||||
{ limits: { outputBytes: 2 }, prompt: "Hi", start: false },
|
||||
];
|
||||
for (const scenario of cases) {
|
||||
const f = fixture({ limits: scenario.limits }); const chat = await f.broker.open(f.context);
|
||||
const start = () => f.broker.start({ chatHandle: chat.chatHandle, prompt: scenario.prompt }, f.context);
|
||||
if (scenario.start) await assert.rejects(start());
|
||||
else { const turn = await start(); await assert.rejects(f.broker.read(turn.turnHandle, f.context)); }
|
||||
assert.equal(f.broker.turns.size, 0); f.broker.shutdown();
|
||||
}
|
||||
const pending = deferred(); const f = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const chat = await f.broker.open(f.context); const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
const reading = f.broker.read(turn.turnHandle, f.context); await new Promise(setImmediate);
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted);
|
||||
pending.resolve({ value: { kind: "text", text: "OK" } }); assert.equal((await reading).text, "OK"); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("the owner can stop or close after a model switch; read request cancellation aborts the provider", async () => {
|
||||
const f = fixture(); const chat = await f.broker.open(f.context);
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
f.setCurrent(false); assert.equal(await f.broker.stop(turn.turnHandle, f.context), true);
|
||||
assert.equal(await f.broker.close(chat.chatHandle, f.context), true); f.broker.shutdown();
|
||||
const pending = deferred(); const instance = fixture({ model: { invoke: () => ({ next: () => pending.promise }) } });
|
||||
const selected = await instance.broker.open(instance.context);
|
||||
const started = await instance.broker.start({ chatHandle: selected.chatHandle, prompt: "Hi" }, instance.context);
|
||||
const controller = new AbortController();
|
||||
const reading = instance.broker.read(started.turnHandle, { ...instance.context, signal: controller.signal });
|
||||
await new Promise(setImmediate); controller.abort(); await assert.rejects(reading);
|
||||
assert.equal(instance.broker.turns.size, 0); pending.resolve({ done: true }); instance.broker.shutdown();
|
||||
});
|
||||
|
||||
test("event and handle caps prevent cheap empty streams and unbounded allocation", async () => {
|
||||
const f = fixture({ limits: { perRuntime: 1, events: 1 }, model: { invoke: () => (async function* () {
|
||||
yield { kind: "text", text: "" }; yield { kind: "text", text: "" };
|
||||
})() } });
|
||||
const chat = await f.broker.open(f.context); await assert.rejects(f.broker.open(f.context));
|
||||
const turn = await f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi" }, f.context);
|
||||
assert.equal((await f.broker.read(turn.turnHandle, f.context)).text, "");
|
||||
await assert.rejects(f.broker.read(turn.turnHandle, f.context), (e) => e.code === RPC_ERRORS.resourceExhausted); f.broker.shutdown();
|
||||
});
|
||||
|
||||
test("malformed context cannot publish a turn or interrupt two-handle runtime revocation", async (t) => {
|
||||
const entered = deferred(); const resume = deferred(); let checks = 0;
|
||||
const f = fixture({ model: { isCurrent: async () => { if (++checks === 3) { entered.resolve(); await resume.promise; } return true; } } });
|
||||
t.after(() => { resume.resolve(); f.broker.shutdown(); });
|
||||
const selection = { scope: "note", title: "Synthetic", version: "1", readText: () => "Synthetic", isCurrent: () => true };
|
||||
await f.contexts.share(selection, f.context); await f.contexts.share(selection, f.context);
|
||||
const chat = await f.broker.open(f.context);
|
||||
const start = f.broker.start({ chatHandle: chat.chatHandle, prompt: "Hi", contexts: [null] }, f.context);
|
||||
const outcome = start.then(() => "accepted", (error) => error);
|
||||
// On the old implementation, reach the reported post-publication model guard.
|
||||
// On the fixed implementation, validation rejects before that await exists.
|
||||
await Promise.race([entered.promise, outcome]);
|
||||
assert.doesNotThrow(() => f.contexts.releaseRuntime("r1"));
|
||||
assert.equal(f.contexts.handles.size, 0);
|
||||
resume.resolve();
|
||||
const error = await outcome;
|
||||
assert.equal(error.code, RPC_ERRORS.invalidArgument);
|
||||
assert.equal(f.broker.turns.size, 0);
|
||||
assert.equal(f.calls.length, 0);
|
||||
f.broker.shutdown();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const denied = () => new PluginRpcError(RPC_ERRORS.permissionDenied, "Plugin source binding is unavailable");
|
||||
|
||||
// Consume the market task's host-owned API. No source or owner is read from RPC
|
||||
// parameters, and a missing API/retained source fails closed.
|
||||
class PluginAISourceBinding {
|
||||
constructor({ database, getRuntimeIdentity }) {
|
||||
for (const method of ["getActivePlugin", "getRetainedPluginSource", "privateOwner", "publicPluginId"]) {
|
||||
if (typeof database?.[method] !== "function") throw new TypeError(`Source database ${method} is required`);
|
||||
}
|
||||
if (typeof getRuntimeIdentity !== "function") throw new TypeError("Runtime identity resolver required");
|
||||
this.database = database;
|
||||
this.getRuntimeIdentity = getRuntimeIdentity;
|
||||
this.bindings = new Map();
|
||||
}
|
||||
|
||||
#snapshot(context) {
|
||||
const identity = this.getRuntimeIdentity(context.pluginId);
|
||||
if (!identity || identity.pluginId !== context.pluginId || identity.runtimeId !== context.runtimeId
|
||||
|| identity.pluginVersion !== context.pluginVersion || identity.runtimeKind !== "browser") throw denied();
|
||||
const plugin = this.database.getActivePlugin(identity.pluginId);
|
||||
const source = plugin?.source;
|
||||
const retained = this.database.getRetainedPluginSource(identity.pluginId);
|
||||
if (!plugin?.enabled || plugin.activeVersion !== identity.pluginVersion || !source || !retained
|
||||
|| !["starting", "running"].includes(plugin.runtime?.status)
|
||||
|| typeof source.id !== "string" || typeof source.kind !== "string"
|
||||
|| source.id !== retained.id || source.kind !== retained.kind || source.origin !== retained.origin) throw denied();
|
||||
const privateOwner = this.database.privateOwner(identity.pluginId);
|
||||
if (typeof privateOwner !== "string" || !privateOwner || this.database.publicPluginId(privateOwner) !== identity.pluginId) throw denied();
|
||||
return { runtimeId: identity.runtimeId, pluginId: identity.pluginId, pluginVersion: identity.pluginVersion,
|
||||
sourceKey: JSON.stringify([source.id, source.kind, source.origin ?? null]), privateOwner };
|
||||
}
|
||||
|
||||
async resolve(context) {
|
||||
// Authenticate first. Even the database source lookup follows this guard.
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
const snapshot = this.#snapshot(context);
|
||||
const previous = this.bindings.get(snapshot.runtimeId);
|
||||
if (previous && (previous.sourceKey !== snapshot.sourceKey || previous.privateOwner !== snapshot.privateOwner
|
||||
|| previous.pluginId !== snapshot.pluginId || previous.pluginVersion !== snapshot.pluginVersion)) throw denied();
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
const current = this.#snapshot(context);
|
||||
if (current.sourceKey !== snapshot.sourceKey || current.privateOwner !== snapshot.privateOwner) throw denied();
|
||||
if (previous) return previous;
|
||||
const binding = Object.freeze(snapshot);
|
||||
this.bindings.set(binding.runtimeId, binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
|
||||
releasePlugin(pluginId) {
|
||||
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
|
||||
}
|
||||
shutdown() { this.bindings.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { PluginAISourceBinding };
|
||||
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { PluginAISourceBinding } = require("./sourceBinding.cjs");
|
||||
function fixture() {
|
||||
let source = { id: "source-a", kind: "third-party", origin: "https://registry.example" };
|
||||
let owner = "source:opaque-a:com.example.assistant"; let active = true; let runtimeId = "r1"; let reads = 0;
|
||||
const context = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", assertActive: async () => { if (!active) throw Error("Inactive"); } };
|
||||
const database = {
|
||||
getActivePlugin: () => { reads++; return { source, enabled: true, activeVersion: "1", runtime: { status: "running" } }; },
|
||||
getRetainedPluginSource: () => source,
|
||||
privateOwner: () => owner,
|
||||
publicPluginId: (value) => value === owner ? context.pluginId : null,
|
||||
};
|
||||
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => ({ ...context, runtimeId, runtimeKind: "browser" }) });
|
||||
return { binding, context, database, reads: () => reads, activeTo: (v) => { active = v; }, runtimeTo: (v) => { runtimeId = v; },
|
||||
sourceTo: (v) => { source = v; }, ownerTo: (v) => { owner = v; } };
|
||||
}
|
||||
|
||||
test("host authentication precedes database access; forged runtime and missing provenance fail closed", async () => {
|
||||
const f = fixture(); f.activeTo(false); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
|
||||
f.activeTo(true); f.runtimeTo("different"); await assert.rejects(f.binding.resolve(f.context)); assert.equal(f.reads(), 0);
|
||||
f.runtimeTo("r1"); f.database.getRetainedPluginSource = () => null; await assert.rejects(f.binding.resolve(f.context));
|
||||
assert.throws(() => new PluginAISourceBinding({ database: {}, getRuntimeIdentity: () => null }));
|
||||
});
|
||||
|
||||
test("opaque host owner remains stable and source changes cannot resume the old runtime", async () => {
|
||||
const f = fixture(); const owner = await f.binding.resolve({ ...f.context, source: "attacker", privateOwner: "attacker" });
|
||||
assert.equal(owner.privateOwner, "source:opaque-a:com.example.assistant");
|
||||
assert.equal(await f.binding.resolve(f.context), owner);
|
||||
f.sourceTo({ id: "source-b", kind: "third-party", origin: "https://other.example" });
|
||||
f.ownerTo("source:opaque-b:com.example.assistant"); await assert.rejects(f.binding.resolve(f.context));
|
||||
// A fresh authenticated runtime may acquire its own distinct binding.
|
||||
f.binding.releaseRuntime("r1"); const next = await f.binding.resolve(f.context); assert.notEqual(next, owner);
|
||||
});
|
||||
|
||||
test("source/owner changes during asynchronous authentication cannot commit a binding", async () => {
|
||||
const f = fixture(); let checks = 0;
|
||||
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
|
||||
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
|
||||
});
|
||||
@@ -288,9 +288,9 @@ class BrowserPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin browser runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
getProcessId() {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
|
||||
// Host-only transport core. No protocol, credential lookup, or plugin-supplied
|
||||
// Node runs here. The registered service authenticates source-bound runtimes.
|
||||
const net = require("node:net");
|
||||
const tls = require("node:tls");
|
||||
const dns = require("node:dns/promises");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs");
|
||||
|
||||
const LIMITS = Object.freeze(require("./generated/plugin-contract.schema.json").$defs.ByteStreamLimits.const);
|
||||
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamEndpoint.cjs");
|
||||
|
||||
function failure(code, message) { return new PluginRpcError(RPC_ERRORS[code], message); }
|
||||
|
||||
class PluginByteStreamBroker {
|
||||
constructor(options) {
|
||||
this.permissionEngine = options.permissionEngine;
|
||||
this.quotaManager = options.quotaManager;
|
||||
this.audit = options.audit ?? (() => {});
|
||||
this.lookup = options.lookup ?? dns.lookup;
|
||||
this.connectSocket = options.connectSocket ?? ((params) => net.createConnection(params));
|
||||
this.connectTls = options.connectTls ?? ((params) => tls.connect(params));
|
||||
// Only a host test harness can opt a specific loopback endpoint in. The
|
||||
// production default excludes all loopback/link-local control surfaces.
|
||||
this.testLocalEndpoints = new Set(options.testLocalEndpoints ?? []);
|
||||
this.isControlEndpoint = options.isControlEndpoint ?? (() => false);
|
||||
this.connections = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.closed = false;
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => {
|
||||
for (const state of this.connections.values()) {
|
||||
if (state.pluginId === event.pluginId
|
||||
&& (event.permission === undefined || event.permission === "network.stream")
|
||||
&& (event.resource === undefined || event.resource === state.endpoint)) {
|
||||
this.finish(state, failure("permissionDenied", "Byte stream permission was revoked"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
record(state, operation) {
|
||||
// Audit only host-observed generic operations, never bytes or peer messages.
|
||||
try { this.audit(state.pluginId, `network.stream.${operation}`, {
|
||||
runtimeId: state.runtimeId, endpoint: state.endpoint,
|
||||
handleId: state.id, bytesRead: state.bytesRead, bytesWritten: state.bytesWritten,
|
||||
}, state); } catch { /* An audit sink cannot keep a revoked socket alive. */ }
|
||||
}
|
||||
|
||||
finish(state, reason) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
clearTimeout(state.connectTimer);
|
||||
clearTimeout(state.idleTimer);
|
||||
clearTimeout(state.lifetimeTimer);
|
||||
state.error = reason;
|
||||
this.connections.delete(state.id);
|
||||
state.controller.abort(reason ?? failure("cancelled", "Byte stream closed"));
|
||||
state.socket?.destroy();
|
||||
this.record(state, "close");
|
||||
for (const listener of this.closeListeners) {
|
||||
try { listener(state, reason); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
onDidClose(listener) {
|
||||
this.closeListeners.add(listener);
|
||||
return { dispose: () => this.closeListeners.delete(listener) };
|
||||
}
|
||||
|
||||
touch(state) {
|
||||
clearTimeout(state.idleTimer);
|
||||
state.idleTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream idle timeout")), LIMITS.idleMs);
|
||||
state.idleTimer.unref?.();
|
||||
}
|
||||
|
||||
async active(state, context) {
|
||||
this.assertCommit(state, context);
|
||||
await context.assertActive();
|
||||
this.assertCommit(state, context);
|
||||
}
|
||||
|
||||
assertCommit(state, context) {
|
||||
if (this.closed || state.closed || this.connections.get(state.id) !== state) {
|
||||
throw state.error ?? failure("unavailable", "Byte stream is closed");
|
||||
}
|
||||
state.controller.signal.throwIfAborted();
|
||||
context.signal?.throwIfAborted();
|
||||
context.assertCommit?.();
|
||||
}
|
||||
|
||||
async connect(endpoint, context) {
|
||||
let target;
|
||||
try { target = canonicalizeStreamEndpoint(endpoint); }
|
||||
catch { throw failure("invalidArgument", "Byte stream endpoint is invalid"); }
|
||||
if (this.closed) throw failure("unavailable", "Byte stream broker stopped");
|
||||
if (!context.runtimeId || !context.pluginId || typeof context.assertActive !== "function") {
|
||||
throw failure("unauthenticated", "Byte streams require a host runtime identity");
|
||||
}
|
||||
const count = [...this.connections.values()].filter((state) => state.runtimeId === context.runtimeId).length;
|
||||
if (count >= LIMITS.connectionsPerRuntime || this.connections.size >= LIMITS.connectionsTotal) {
|
||||
throw failure("resourceExhausted", "Byte stream connection limit exceeded");
|
||||
}
|
||||
const state = {
|
||||
...target, id: randomUUID(), pluginId: context.pluginId, runtimeId: context.runtimeId,
|
||||
controller: new AbortController(), bytesRead: 0, bytesWritten: 0,
|
||||
sourceOwner: context.sourceOwner, sourceId: context.sourceId,
|
||||
closed: false, reading: false, writing: false, socket: null,
|
||||
};
|
||||
this.connections.set(state.id, state);
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream connection cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
await raceWithAbort(Promise.resolve(this.permissionEngine.authorize({ ...context, signal: state.controller.signal }, {
|
||||
permission: "network.stream", resources: [target.endpoint],
|
||||
reason: `Open byte stream to ${target.endpoint}`,
|
||||
operationId: `network.stream:${target.endpoint}`,
|
||||
validateBeforeGrant: () => this.assertCommit(state, context),
|
||||
})), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
state.connectTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream connect timeout")), LIMITS.connectMs);
|
||||
const addresses = net.isIP(target.host)
|
||||
? [{ address: target.host, family: net.isIP(target.host) }]
|
||||
: await raceWithAbort(Promise.resolve(this.lookup(target.host, { all: true, verbatim: true })), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
if (!Array.isArray(addresses) || addresses.length === 0 || addresses.length > 64) {
|
||||
throw failure("unavailable", "Byte stream hostname could not be resolved");
|
||||
}
|
||||
// Validate the whole DNS answer and pin one literal address. No second
|
||||
// lookup, redirect, proxy discovery, or reconnect can change the peer.
|
||||
for (const peer of addresses) {
|
||||
if (![4, 6].includes(peer.family) || net.isIP(peer.address) !== peer.family) {
|
||||
throw failure("unavailable", "Byte stream resolver returned an invalid address");
|
||||
}
|
||||
const local = isLocalControlAddress(peer.address);
|
||||
const allowedTestPeer = this.testLocalEndpoints.has(target.endpoint)
|
||||
&& !this.isControlEndpoint(peer.address, target.port);
|
||||
if ((local && !allowedTestPeer) || (!local && this.isControlEndpoint(peer.address, target.port))) {
|
||||
throw failure("permissionDenied", "Byte streams cannot reach local or application control endpoints");
|
||||
}
|
||||
}
|
||||
const peer = addresses[0];
|
||||
const params = {
|
||||
host: peer.address, port: target.port, family: peer.family,
|
||||
highWaterMark: LIMITS.bufferBytes,
|
||||
};
|
||||
if (target.transport === "tls") {
|
||||
Object.assign(params, {
|
||||
rejectUnauthorized: true, minVersion: "TLSv1.2",
|
||||
...(net.isIP(target.host) ? {} : { servername: target.host }),
|
||||
checkServerIdentity: (_name, certificate) => tls.checkServerIdentity(target.host, certificate),
|
||||
});
|
||||
}
|
||||
this.assertCommit(state, context);
|
||||
const socket = target.transport === "tls" ? this.connectTls(params) : this.connectSocket(params);
|
||||
state.socket = socket;
|
||||
if (state.closed) { socket.destroy(); throw state.error ?? failure("cancelled", "Byte stream connection cancelled"); }
|
||||
socket.on("error", () => this.finish(state, failure("unavailable", "Byte stream transport failed")));
|
||||
// Preserve one EOF observation for a delayed consumer. This reservation
|
||||
// still counts toward quotas and expires on the existing idle/life timer.
|
||||
socket.on("close", () => {
|
||||
if (!state.eof) this.finish(state, failure("unavailable", "Byte stream transport closed"));
|
||||
});
|
||||
socket.on("end", () => { state.eof = true; });
|
||||
socket.pause();
|
||||
await raceWithAbort(new Promise((resolve) => socket.once(
|
||||
target.transport === "tls" ? "secureConnect" : "connect", resolve,
|
||||
)), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
if (target.transport === "tls" && !socket.authorized) {
|
||||
throw failure("permissionDenied", "Byte stream TLS validation failed");
|
||||
}
|
||||
clearTimeout(state.connectTimer);
|
||||
state.lifetimeTimer = setTimeout(() => this.finish(state,
|
||||
failure("deadlineExceeded", "Byte stream lifetime exceeded")), LIMITS.lifetimeMs);
|
||||
state.lifetimeTimer.unref?.();
|
||||
this.touch(state);
|
||||
this.assertCommit(state, context);
|
||||
this.record(state, "open");
|
||||
return Object.freeze({ handleId: state.id, endpoint: state.endpoint });
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
get(id, context) {
|
||||
const state = this.connections.get(id);
|
||||
if (!state || state.pluginId !== context.pluginId || state.runtimeId !== context.runtimeId
|
||||
|| state.sourceOwner !== context.sourceOwner || state.sourceId !== context.sourceId) {
|
||||
throw failure("permissionDenied", "Byte stream handle does not belong to this runtime");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
charge(state, direction, size) {
|
||||
if (state.bytesRead + state.bytesWritten + size > LIMITS.connectionBytes) {
|
||||
throw failure("resourceExhausted", "Byte stream byte limit exceeded");
|
||||
}
|
||||
this.quotaManager?.chargeBytes(state.runtimeId, "network.stream", size);
|
||||
state[direction] += size;
|
||||
this.touch(state);
|
||||
}
|
||||
|
||||
async read(id, context) {
|
||||
const state = this.get(id, context);
|
||||
if (state.reading) throw failure("failedPrecondition", "Byte stream has a pending read");
|
||||
state.reading = true;
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream read cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
while (true) {
|
||||
this.assertCommit(state, context);
|
||||
const available = state.socket.readableLength;
|
||||
const bytes = state.socket.read(Math.min(available || LIMITS.chunkBytes, LIMITS.chunkBytes));
|
||||
if (bytes !== null) {
|
||||
this.charge(state, "bytesRead", bytes.byteLength);
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
if (state.eof || state.socket.readableEnded) { this.finish(state); return null; }
|
||||
await raceWithAbort(new Promise((resolve) => {
|
||||
const readable = () => {
|
||||
state.socket.off("end", readable);
|
||||
state.socket.off("readable", readable);
|
||||
state.controller.signal.removeEventListener("abort", readable);
|
||||
resolve();
|
||||
};
|
||||
state.socket.once("readable", readable);
|
||||
state.socket.once("end", readable);
|
||||
// Remove listeners even when cancellation wins the race.
|
||||
state.controller.signal.addEventListener("abort", readable, { once: true });
|
||||
}), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
}
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
state.reading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async write(id, bytes, context) {
|
||||
const state = this.get(id, context);
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > LIMITS.chunkBytes) {
|
||||
throw failure("invalidArgument", "Byte stream chunk size is invalid");
|
||||
}
|
||||
if (state.writing) throw failure("resourceExhausted", "Byte stream has a pending write");
|
||||
state.writing = true;
|
||||
const onAbort = () => this.finish(state, failure("cancelled", "Byte stream write cancelled"));
|
||||
context.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
await this.active(state, context);
|
||||
this.assertCommit(state, context);
|
||||
this.charge(state, "bytesWritten", bytes.byteLength);
|
||||
const owned = Buffer.from(bytes);
|
||||
await raceWithAbort(new Promise((resolve, reject) => {
|
||||
state.socket.write(owned, (error) => error ? reject(error) : resolve());
|
||||
}), state.controller.signal);
|
||||
await this.active(state, context);
|
||||
} catch (error) {
|
||||
this.finish(state, error);
|
||||
throw error;
|
||||
} finally {
|
||||
context.signal?.removeEventListener("abort", onAbort);
|
||||
state.writing = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(id, context) { this.finish(this.get(id, context)); }
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const state of this.connections.values()) {
|
||||
if (state.runtimeId === runtimeId) this.finish(state,
|
||||
failure("unavailable", "Byte stream runtime stopped"));
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const state of this.connections.values()) this.finish(state,
|
||||
failure("unavailable", "Byte stream broker stopped"));
|
||||
this.revocation.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress };
|
||||
@@ -0,0 +1,330 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const net = require("node:net");
|
||||
const tls = require("node:tls");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { once } = require("node:events");
|
||||
const { LIMITS, PluginByteStreamBroker, canonicalizeStreamEndpoint, isLocalControlAddress } = require("./byteStreamBroker.cjs");
|
||||
const { RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
|
||||
function context(runtimeId = "runtime-a", controller = new AbortController()) {
|
||||
return { pluginId: "test.stream", runtimeId, signal: controller.signal,
|
||||
assertActive: async () => controller.signal.throwIfAborted() };
|
||||
}
|
||||
|
||||
async function server(t, listener, options) {
|
||||
const sockets = new Set();
|
||||
const instance = options ? tls.createServer(options, listener) : net.createServer(listener);
|
||||
instance.on("connection", (socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
});
|
||||
instance.on("tlsClientError", () => {});
|
||||
instance.listen(0, "127.0.0.1");
|
||||
await once(instance, "listening");
|
||||
t.after(async () => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => instance.close(resolve));
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
function broker(t, endpoint, options = {}) {
|
||||
const audit = [];
|
||||
const requests = [];
|
||||
let revoke;
|
||||
const instance = new PluginByteStreamBroker({
|
||||
permissionEngine: {
|
||||
authorize: async (_ctx, request) => requests.push(request),
|
||||
onDidRevoke(listener) { revoke = listener; return { dispose() {} }; },
|
||||
},
|
||||
audit: (...args) => audit.push(args),
|
||||
testLocalEndpoints: endpoint ? [endpoint] : [],
|
||||
...options,
|
||||
});
|
||||
t.after(() => instance.shutdown());
|
||||
return { instance, requests, audit, revoke: (event) => revoke(event) };
|
||||
}
|
||||
|
||||
test("stream endpoint canonicalization requires one exact host, transport and port", () => {
|
||||
assert.equal(canonicalizeStreamEndpoint("tls://EXAMPLE.test:443").endpoint, "tls://example.test:443");
|
||||
assert.equal(canonicalizeStreamEndpoint("tcp://[0:0:0:0:0:0:0:1]:3306").endpoint, "tcp://[::1]:3306");
|
||||
for (const value of ["*", "tcp://*:3306", "tcp://x:0", "tcp://x:01", "tcp://x:65536", "udp://x:22",
|
||||
"tcp://x", "tcp://x:22/", "tcp://user@x:22", "tcp://127.1:22", "tcp://x:22?x", "tcp://x.:22",
|
||||
"tcp://[::1%25lo]:22", "tcp://x:22#x", "tcp://-x:22"]) {
|
||||
assert.throws(() => canonicalizeStreamEndpoint(value), TypeError, value);
|
||||
}
|
||||
});
|
||||
|
||||
test("local/control address classification handles mapped IPv6 and alternate literals", () => {
|
||||
for (const address of ["127.0.0.1", "127.9.8.7", "0.0.0.0", "169.254.169.254", "224.0.0.1",
|
||||
"::1", "::", "::ffff:127.0.0.1", "::ffff:7f00:1", "fe80::1", "ff02::1", "2002:7f00:1::"]) {
|
||||
assert.equal(isLocalControlAddress(address), true, address);
|
||||
}
|
||||
for (const address of ["192.168.1.2", "10.0.0.8", "203.0.113.1", "2001:db8::1", "fd00::1"]) {
|
||||
assert.equal(isLocalControlAddress(address), false, address);
|
||||
}
|
||||
});
|
||||
|
||||
test("real TCP transport exchanges binary bytes and records no payloads", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.on("data", (bytes) => socket.write(bytes)));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, requests, audit } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const handle = await instance.connect(endpoint, ctx);
|
||||
const bytes = Uint8Array.from([0, 255, 1, 254, 19]);
|
||||
await instance.write(handle.handleId, bytes, ctx);
|
||||
assert.deepEqual(await instance.read(handle.handleId, ctx), bytes);
|
||||
instance.close(handle.handleId, ctx);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
assert.deepEqual(requests[0].resources, [endpoint]);
|
||||
assert.equal(requests[0].permission, "network.stream");
|
||||
assert.deepEqual(audit.map((entry) => entry[1]), ["network.stream.open", "network.stream.close"]);
|
||||
assert.equal(audit[1][2].bytesRead, bytes.byteLength);
|
||||
assert.equal(audit[1][2].bytesWritten, bytes.byteLength);
|
||||
assert.equal(JSON.stringify(audit).includes("data"), false);
|
||||
});
|
||||
|
||||
test("remote EOF returns the final bytes and releases the connection", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.end("synthetic final bytes"));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const chunk = await instance.read(handleId, ctx);
|
||||
if (chunk === null) break;
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
assert.equal(Buffer.concat(chunks).toString(), "synthetic final bytes");
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("a delayed consumer can observe EOF after the real socket has closed", async (t) => {
|
||||
const peer = await server(t, (socket) => socket.end("final"));
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, revoke } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const socket = instance.connections.get(handleId).socket;
|
||||
const closed = once(socket, "close");
|
||||
assert.equal(Buffer.from(await instance.read(handleId, ctx)).toString(), "final");
|
||||
await closed;
|
||||
await assert.rejects(instance.read(handleId, context("other-runtime")), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(await instance.read(handleId, ctx), null);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
const second = await instance.connect(endpoint, ctx);
|
||||
const secondClosed = once(instance.connections.get(second.handleId).socket, "close");
|
||||
await instance.read(second.handleId, ctx);
|
||||
await secondClosed;
|
||||
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
|
||||
await assert.rejects(instance.read(second.handleId, ctx), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("denial happens before DNS or a socket is created", async (t) => {
|
||||
let lookedUp = false;
|
||||
const { instance } = broker(t, null, {
|
||||
permissionEngine: { authorize: async () => { throw new Error("denied"); }, onDidRevoke: () => ({ dispose() {} }) },
|
||||
lookup: () => { lookedUp = true; },
|
||||
});
|
||||
await assert.rejects(instance.connect("tcp://example.test:22", context()), /denied/u);
|
||||
assert.equal(lookedUp, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("production rejects loopback, mixed DNS answers, and app control peers", async (t) => {
|
||||
const endpoint = "tcp://example.test:22";
|
||||
const { instance } = broker(t, null, {
|
||||
lookup: async () => [{ address: "203.0.113.1", family: 4 }, { address: "127.0.0.1", family: 4 }],
|
||||
});
|
||||
await assert.rejects(instance.connect(endpoint, context()), { code: RPC_ERRORS.permissionDenied });
|
||||
await assert.rejects(instance.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
|
||||
const protectedBroker = broker(t, "tcp://127.0.0.1:22", { isControlEndpoint: () => true }).instance;
|
||||
await assert.rejects(protectedBroker.connect("tcp://127.0.0.1:22", context()), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("runtime handles cannot cross runtime or plugin boundaries", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
for (const other of [context("runtime-b"), { ...ctx, pluginId: "test.other" }]) {
|
||||
await assert.rejects(instance.read(handleId, other), { code: RPC_ERRORS.permissionDenied });
|
||||
assert.throws(() => instance.close(handleId, other), { code: RPC_ERRORS.permissionDenied });
|
||||
}
|
||||
instance.close(handleId, ctx);
|
||||
await assert.rejects(instance.write(handleId, Uint8Array.of(1), ctx), { code: RPC_ERRORS.permissionDenied });
|
||||
});
|
||||
|
||||
test("revocation and runtime release cancel pending real reads", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance, revoke } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
let handle = await instance.connect(endpoint, ctx);
|
||||
const reading = instance.read(handle.handleId, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
revoke({ pluginId: ctx.pluginId, permission: "network.stream", resource: endpoint });
|
||||
await assert.rejects(reading, { code: RPC_ERRORS.permissionDenied });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
handle = await instance.connect(endpoint, ctx);
|
||||
const secondRead = instance.read(handle.handleId, ctx);
|
||||
instance.releaseRuntime(ctx.runtimeId);
|
||||
await assert.rejects(secondRead, { code: RPC_ERRORS.unavailable });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("cancellation during DNS rejects promptly and ignores late resolution", async (t) => {
|
||||
let resolveDns;
|
||||
let dialed = false;
|
||||
const { instance } = broker(t, null, {
|
||||
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
|
||||
connectSocket: () => { dialed = true; },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = instance.connect("tcp://example.test:22", context("runtime-a", controller));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
|
||||
resolveDns([{ address: "203.0.113.1", family: 4 }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("cancellation after the last async guard cannot create a late real socket", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
let dialed = false;
|
||||
const { instance } = broker(t, endpoint, { connectSocket(options) {
|
||||
dialed = true;
|
||||
const socket = net.createConnection(options);
|
||||
t.after(() => socket.destroy());
|
||||
return socket;
|
||||
} });
|
||||
const controller = new AbortController();
|
||||
let guards = 0;
|
||||
const ctx = context("runtime-a", controller);
|
||||
ctx.assertActive = async () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (++guards === 3) queueMicrotask(() => queueMicrotask(() => controller.abort()));
|
||||
};
|
||||
await assert.rejects(instance.connect(endpoint, ctx), { code: RPC_ERRORS.cancelled });
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("connect and idle deadlines close resources and reject pending operations", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const reading = instance.read(handleId, ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(LIMITS.idleMs);
|
||||
await assert.rejects(reading, { code: RPC_ERRORS.deadlineExceeded });
|
||||
assert.equal(instance.connections.size, 0);
|
||||
let resolveDns;
|
||||
let dialed = false;
|
||||
const slow = broker(t, null, {
|
||||
lookup: () => new Promise((resolve) => { resolveDns = resolve; }),
|
||||
connectSocket: () => { dialed = true; },
|
||||
}).instance;
|
||||
const pending = slow.connect("tcp://example.test:22", ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(LIMITS.connectMs);
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.deadlineExceeded });
|
||||
resolveDns([{ address: "203.0.113.1", family: 4 }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(dialed, false);
|
||||
assert.equal(slow.connections.size, 0);
|
||||
});
|
||||
|
||||
test("concurrent operations and oversized writes cannot grow an unbounded queue", async (t) => {
|
||||
const peer = await server(t, () => {});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint);
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
const pending = instance.read(handleId, ctx);
|
||||
await assert.rejects(instance.read(handleId, ctx), { code: RPC_ERRORS.failedPrecondition });
|
||||
await assert.rejects(instance.write(handleId, new Uint8Array(LIMITS.chunkBytes + 1), ctx), { code: RPC_ERRORS.invalidArgument });
|
||||
const writing = instance.write(handleId, Uint8Array.of(1), ctx);
|
||||
await assert.rejects(instance.write(handleId, Uint8Array.of(2), ctx), { code: RPC_ERRORS.resourceExhausted });
|
||||
await writing;
|
||||
instance.close(handleId, ctx);
|
||||
await assert.rejects(pending, { code: RPC_ERRORS.cancelled });
|
||||
});
|
||||
|
||||
test("connection reservations bound concurrent DNS operations", async (t) => {
|
||||
const { instance } = broker(t, null, { lookup: () => new Promise(() => {}) });
|
||||
const ctx = context();
|
||||
const pending = Array.from({ length: LIMITS.connectionsPerRuntime }, () => instance.connect("tcp://example.test:22", ctx));
|
||||
const results = Promise.allSettled(pending);
|
||||
await assert.rejects(instance.connect("tcp://example.test:22", ctx), { code: RPC_ERRORS.resourceExhausted });
|
||||
instance.releaseRuntime(ctx.runtimeId);
|
||||
assert.ok((await results).every((result) => result.status === "rejected"));
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("real unread TCP output stays bounded and byte quota closes the socket", async (t) => {
|
||||
const peer = await server(t, (socket) => {
|
||||
const flood = () => { while (!socket.destroyed && socket.write(Buffer.alloc(LIMITS.chunkBytes))) {} };
|
||||
socket.on("drain", flood);
|
||||
flood();
|
||||
});
|
||||
const endpoint = `tcp://127.0.0.1:${peer.address().port}`;
|
||||
const { instance } = broker(t, endpoint, { quotaManager: {
|
||||
chargeBytes() { throw new Error("quota reached"); },
|
||||
} });
|
||||
const ctx = context();
|
||||
const { handleId } = await instance.connect(endpoint, ctx);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const state = instance.connections.get(handleId);
|
||||
assert.ok(state.socket.readableLength <= LIMITS.bufferBytes + LIMITS.chunkBytes);
|
||||
await assert.rejects(instance.read(handleId, ctx), /quota reached/u);
|
||||
assert.equal(instance.connections.size, 0);
|
||||
});
|
||||
|
||||
test("real TLS validates trust and the original hostname against a pinned IP", async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-stream-tls-test-"));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const keyPath = path.join(directory, "test.key");
|
||||
const certPath = path.join(directory, "test.crt");
|
||||
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", keyPath,
|
||||
"-out", certPath, "-days", "1", "-subj", "/CN=stream-fixture.test", "-addext", "subjectAltName=DNS:stream-fixture.test"],
|
||||
{ stdio: "ignore" });
|
||||
const cert = fs.readFileSync(certPath);
|
||||
const peer = await server(t, (socket) => socket.on("data", (data) => socket.write(data)), {
|
||||
key: fs.readFileSync(keyPath), cert,
|
||||
});
|
||||
const endpoint = `tls://stream-fixture.test:${peer.address().port}`;
|
||||
const lookup = async () => [{ address: "127.0.0.1", family: 4 }];
|
||||
const untrusted = broker(t, endpoint, { lookup }).instance;
|
||||
await assert.rejects(untrusted.connect(endpoint, context()), { code: RPC_ERRORS.unavailable });
|
||||
let dialOptions;
|
||||
const trusted = broker(t, endpoint, { lookup,
|
||||
connectTls: (options) => { dialOptions = options; return tls.connect({ ...options, ca: cert }); },
|
||||
}).instance;
|
||||
const ctx = context();
|
||||
const { handleId } = await trusted.connect(endpoint, ctx);
|
||||
assert.equal(dialOptions.host, "127.0.0.1");
|
||||
assert.equal(dialOptions.servername, "stream-fixture.test");
|
||||
assert.equal(dialOptions.rejectUnauthorized, true);
|
||||
await trusted.write(handleId, Uint8Array.of(42), ctx);
|
||||
assert.deepEqual(await trusted.read(handleId, ctx), Uint8Array.of(42));
|
||||
trusted.close(handleId, ctx);
|
||||
const wrongEndpoint = `tls://wrong-fixture.test:${peer.address().port}`;
|
||||
const wrong = broker(t, wrongEndpoint, { lookup, connectTls: (options) => tls.connect({ ...options, ca: cert }) }).instance;
|
||||
await assert.rejects(wrong.connect(wrongEndpoint, context()), { code: RPC_ERRORS.unavailable });
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
|
||||
// Creates only an isolated disposable fixture. Never accepts a user host,
|
||||
// existing database, database URL, or real credentials from the environment.
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { randomUUID, randomBytes } = require("node:crypto");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
|
||||
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
|
||||
|
||||
function docker(args, input) {
|
||||
return execFileSync("docker", args, { input, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 60_000 }).trim();
|
||||
}
|
||||
|
||||
test("minimal browser protocol queries a disposable MariaDB through the real byte broker", {
|
||||
skip: process.env.NETCATTY_BYTE_STREAM_DATABASE_TEST !== "1",
|
||||
timeout: 120_000,
|
||||
}, async (t) => {
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
const network = `netcatty-stream-test-${suffix}`;
|
||||
const container = `netcatty-stream-test-${suffix}`;
|
||||
const sessionPassword = randomBytes(24).toString("hex");
|
||||
docker(["network", "create", "--internal", network]);
|
||||
let created = false;
|
||||
t.after(() => {
|
||||
if (created) docker(["rm", "--force", container]);
|
||||
docker(["network", "rm", network]);
|
||||
});
|
||||
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
|
||||
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
|
||||
created = true;
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 60; attempt++) {
|
||||
try { docker(["exec", container, "mariadb-admin", "--host=127.0.0.1", "ping", "--silent"]); ready = true; break; }
|
||||
catch { await new Promise((resolve) => setTimeout(resolve, 500)); }
|
||||
}
|
||||
assert.equal(ready, true, "isolated MariaDB fixture starts");
|
||||
docker(["exec", "--interactive", container, "mariadb", "--protocol=socket", "--user=root"], `
|
||||
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 '${sessionPassword}';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
`);
|
||||
assert.equal(docker(["port", container]), "", "the isolated fixture publishes no host ports");
|
||||
const address = docker(["inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", container]);
|
||||
assert.match(address, /^172\.[0-9]+\.[0-9]+\.[0-9]+$/u);
|
||||
const endpoint = `tcp://${address}:3306`;
|
||||
const broker = new PluginByteStreamBroker({
|
||||
permissionEngine: {
|
||||
authorize: async (_context, request) => assert.deepEqual(request.resources, [endpoint]),
|
||||
onDidRevoke: () => ({ dispose() {} }),
|
||||
},
|
||||
});
|
||||
t.after(() => broker.shutdown());
|
||||
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
|
||||
const { handleId } = await broker.connect(endpoint, context);
|
||||
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
|
||||
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
|
||||
)).href);
|
||||
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
|
||||
const result = await queryExample({
|
||||
read: () => broker.read(handleId, context),
|
||||
write: (bytes) => broker.write(handleId, bytes, context),
|
||||
close: () => { if (broker.connections.has(handleId)) broker.close(handleId, context); },
|
||||
}, "plugin_demo", sessionPassword);
|
||||
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
|
||||
assert.equal(broker.connections.size, 0);
|
||||
// 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",
|
||||
'read -r MYSQL_PWD; export MYSQL_PWD; exec mariadb --host=127.0.0.1 --user=plugin_demo --execute="INSERT INTO plugin_demo.example_result VALUES (1)"'],
|
||||
{ input: `${sessionPassword}\n`, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
(error) => error.status === 1 && /INSERT command denied/u.test(String(error.stderr)));
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
|
||||
const net = require("node:net");
|
||||
|
||||
function failure(_code, message) { return new TypeError(message); }
|
||||
|
||||
function canonicalizeStreamEndpoint(value) {
|
||||
if (typeof value !== "string" || value.length > 512) {
|
||||
throw failure("invalidArgument", "Byte stream endpoint is invalid");
|
||||
}
|
||||
// URL's permissive non-special scheme parser accepts paths, userinfo, and
|
||||
// alternate IPv4 spellings. Require a complete unambiguous authority instead.
|
||||
const match = /^(tcp|tls):\/\/(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+):([0-9]{1,5})$/u.exec(value);
|
||||
if (!match) throw failure("invalidArgument", "Byte streams require an exact TCP or TLS host and port");
|
||||
let host = match[2].toLowerCase();
|
||||
const port = Number(match[3]);
|
||||
if (port < 1 || port > 65535 || String(port) !== match[3]) {
|
||||
throw failure("invalidArgument", "Byte stream port is invalid");
|
||||
}
|
||||
if (host.startsWith("[")) {
|
||||
host = host.slice(1, -1);
|
||||
if (net.isIP(host) !== 6) throw failure("invalidArgument", "Byte stream IPv6 address is invalid");
|
||||
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
|
||||
} else if (!net.isIP(host)) {
|
||||
if (host.length > 253 || /^[0-9.]+$/u.test(host) || host.split(".").some((label) => (
|
||||
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)
|
||||
))) throw failure("invalidArgument", "Byte stream hostname is invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: `${match[1]}://${net.isIP(host) === 6 ? `[${host}]` : host}:${port}`,
|
||||
transport: match[1], host, port,
|
||||
});
|
||||
}
|
||||
|
||||
function isLocalControlAddress(address) {
|
||||
if (net.isIP(address) === 4) {
|
||||
const [a, b] = address.split(".").map(Number);
|
||||
return a === 0 || a === 127 || a >= 224 || (a === 169 && b === 254);
|
||||
}
|
||||
if (net.isIP(address) !== 6) return true;
|
||||
const canonical = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
||||
if (canonical.startsWith("::ffff:")) {
|
||||
const words = canonical.slice(7).split(":");
|
||||
const n = (parseInt(words[0], 16) * 65536) + parseInt(words[1], 16);
|
||||
return isLocalControlAddress([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."));
|
||||
}
|
||||
// Also exclude compatible/tunnel addresses that can obscure a local IPv4 peer.
|
||||
return canonical.startsWith("::") || canonical.startsWith("ff")
|
||||
|| /^fe[89ab]/u.test(canonical) || canonical.startsWith("2002:")
|
||||
|| canonical.startsWith("2001:0:");
|
||||
}
|
||||
|
||||
module.exports = { canonicalizeStreamEndpoint, isLocalControlAddress };
|
||||
@@ -0,0 +1,126 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { createPluginHostService } = require("./hostService.cjs");
|
||||
const { inProcessBrowserRuntime } = require("./fixtures/inProcessBrowserRuntime.cjs");
|
||||
|
||||
test("installed browser entrypoint uses registered SDK/RPC streams, denial and revoke", { timeout: 15_000 }, async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-byte-entry-"));
|
||||
let service;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {});
|
||||
socket.on("data", (bytes) => socket.write(bytes));
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(async () => {
|
||||
await service?.manager.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const packageRoot = path.join(root, "source");
|
||||
await fs.mkdir(packageRoot);
|
||||
const id = "com.netcatty.byte-entry-test";
|
||||
const manifest = {
|
||||
manifestVersion: 1, id, name: "byte-entry-test", version: "0.1.0", publisher: "netcatty",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal.1 <0.2.0" },
|
||||
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
|
||||
activationEvents: [`onCommand:${id}.run`],
|
||||
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
|
||||
contributes: { commands: ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
|
||||
};
|
||||
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
|
||||
await fs.writeFile(path.join(packageRoot, "browser.js"), `
|
||||
export default { activate(context) {
|
||||
let connection;
|
||||
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
|
||||
if (name === "selected") {
|
||||
const selected = await context.selectedContext.list({});
|
||||
return context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
|
||||
}
|
||||
if (name === "ai") {
|
||||
const chat = await context.ai.openChat({});
|
||||
try {
|
||||
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic prompt" });
|
||||
const events = [];
|
||||
while (true) { const event = await context.ai.readTurn({ turnHandle: turn.turnHandle }); events.push(event); if (event.kind === "end") return events; }
|
||||
} finally { await context.ai.closeChat({ chatHandle: chat.chatHandle }); }
|
||||
}
|
||||
if (name === "cancel") { await connection?.close(); return true; }
|
||||
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 };
|
||||
try {
|
||||
await connection.writable.write(Uint8Array.of(0, 255, 42));
|
||||
return Array.from(await connection.readable.read());
|
||||
} finally { await connection.close(); await connection.close(); }
|
||||
}));
|
||||
} };`);
|
||||
let allow = false;
|
||||
const decisions = [];
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
pluginAIHostAdapter: {
|
||||
assertActive: (context) => context.assertActive(),
|
||||
selectModel: async () => ({ label: "Synthetic fixture", isCurrent: async () => true,
|
||||
invoke: async () => (async function* () { yield { kind: "text", text: "synthetic model result" }; yield { kind: "usage", inputTokens: 2, outputTokens: 3, totalTokens: 5 }; })(),
|
||||
}),
|
||||
listSelected: async () => [{ key: "fixture", scope: "tab", title: "Synthetic tab", version: "v1",
|
||||
readText: async () => "synthetic selected text", isCurrent: async () => true }],
|
||||
reviewText: async () => true,
|
||||
},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
return request.permission === "network.stream" && !allow
|
||||
? { requestId: request.requestId, decision: "deny" }
|
||||
: { requestId: request.requestId, decision: "allow", scope: request.permission === "context.selected.read" ? "once" : "application" };
|
||||
},
|
||||
});
|
||||
// Explicit test-only loopback fixture injection. The production constructor
|
||||
// exposes no option that forwards these overrides to the broker.
|
||||
service.byteStreamService.broker.testLocalEndpoints.add(endpoint);
|
||||
service.byteStreamService.broker.isControlEndpoint = () => false;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
const { buildPluginPackage } = await import("@netcatty/plugin-cli");
|
||||
const archive = path.join(root, "fixture.ncpkg");
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const run = (name) => service.contributionService.executeCommand(`${id}.${name}`);
|
||||
await assert.rejects(run("run"), /denied/u);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
allow = true;
|
||||
assert.deepEqual(await run("run"), [0, 255, 42]);
|
||||
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);
|
||||
assert.deepEqual(await run("ai"), [
|
||||
{ kind: "text", sequence: 0, text: "synthetic model result" },
|
||||
{ kind: "usage", sequence: 1, inputTokens: 2, outputTokens: 3, totalTokens: 5 },
|
||||
{ kind: "end", sequence: 2 },
|
||||
]);
|
||||
assert.equal((await run("selected")).text, "synthetic selected text");
|
||||
await assert.rejects(run("aiSpoof"), /invalid/u);
|
||||
assert.deepEqual(await run("pending"), { connected: true });
|
||||
service.permissionEngine.revokeApplication(id, "network.stream", endpoint);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
await run("cancel");
|
||||
await run("pending");
|
||||
await service.manager.setEnabled(id, false);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
assert.equal(service.byteStreamService.closedHandles.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { PluginByteStreamBroker, LIMITS } = require("./byteStreamBroker.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
|
||||
const FEATURE = require("./generated/plugin-contract.schema.json").$defs.ByteStreamFeature.const;
|
||||
function denied() { return new PluginRpcError(RPC_ERRORS.permissionDenied, "Byte stream runtime or source binding is invalid"); }
|
||||
function validator(name) {
|
||||
const validate = createDefinitionValidator(name);
|
||||
return (params) => {
|
||||
if (!validate(params)) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Byte stream parameters violate the contract");
|
||||
return params;
|
||||
};
|
||||
}
|
||||
|
||||
class PluginByteStreamService {
|
||||
constructor(options) {
|
||||
this.database = options.database;
|
||||
this.runtimeSupervisor = options.runtimeSupervisor;
|
||||
this.records = new Map();
|
||||
this.closedHandles = new Map();
|
||||
this.closed = false;
|
||||
this.broker = new PluginByteStreamBroker({
|
||||
permissionEngine: options.permissionEngine,
|
||||
quotaManager: options.quotaManager,
|
||||
// The public host service never forwards transport fixture overrides.
|
||||
isControlEndpoint: (address) => Object.values(os.networkInterfaces()).flat().some((item) => item?.address === address),
|
||||
audit: (pluginId, operation, fields, state) => {
|
||||
if (this.sourceMatches(state)) this.database.recordSecurityAudit(pluginId, operation, fields);
|
||||
},
|
||||
});
|
||||
this.brokerClose = this.broker.onDidClose((state, reason) => {
|
||||
if (reason) this.closeRecord(this.records.get(state.id), reason);
|
||||
});
|
||||
}
|
||||
|
||||
sourceMatches(identity) {
|
||||
const active = this.database.getActivePlugin(identity.pluginId);
|
||||
const retained = this.database.getRetainedPluginSource(identity.pluginId);
|
||||
return Boolean(active?.enabled && identity.sourceOwner && identity.sourceId
|
||||
&& active.source?.id === identity.sourceId && retained?.id === identity.sourceId
|
||||
&& this.database.privateOwner(identity.pluginId) === identity.sourceOwner
|
||||
&& this.database.publicPluginId(identity.sourceOwner) === identity.pluginId);
|
||||
}
|
||||
|
||||
async authenticate(context) {
|
||||
await context.assertActive();
|
||||
const assertCommit = () => {
|
||||
context.signal?.throwIfAborted();
|
||||
const current = this.runtimeSupervisor.getRuntimeIdentity(context.pluginId);
|
||||
if (this.closed || !current || current.runtimeId !== context.runtimeId
|
||||
|| current.pluginVersion !== context.pluginVersion
|
||||
|| current.securityPrincipal !== context.securityPrincipal
|
||||
|| current.sourceOwner !== context.sourceOwner || current.sourceId !== context.sourceId
|
||||
|| !this.sourceMatches(context)) throw denied();
|
||||
};
|
||||
assertCommit();
|
||||
if (!context.manifest?.features?.required?.includes(FEATURE)) {
|
||||
throw new PluginRpcError(RPC_ERRORS.unsupported, "Byte stream feature was not negotiated");
|
||||
}
|
||||
return Object.freeze({ ...context, assertCommit,
|
||||
assertActive: async () => { await context.assertActive(); assertCommit(); },
|
||||
});
|
||||
}
|
||||
|
||||
register(registry) {
|
||||
// These routes own authorization: connect authenticates before the broker
|
||||
// obtains its exact endpoint grant; attach/close prove handle ownership.
|
||||
// The registry public flag skips only the generic permission middleware.
|
||||
// Runtime authentication and quota middleware still apply to every call.
|
||||
registry.registerRequest("network.stream.connect", async ({ endpoint }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
const handle = await this.broker.connect(endpoint, context);
|
||||
try {
|
||||
context.assertCommit();
|
||||
this.broker.get(handle.handleId, context);
|
||||
const record = {
|
||||
...handle, context,
|
||||
inputStreamId: `byte-in-${randomUUID()}`, outputStreamId: `byte-out-${randomUUID()}`,
|
||||
attached: false, closing: false, input: null, output: null,
|
||||
};
|
||||
this.records.set(handle.handleId, record);
|
||||
record.abort = () => this.closeRecord(record);
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
return { ...handle, inputStreamId: record.inputStreamId,
|
||||
outputStreamId: record.outputStreamId, windowBytes: LIMITS.bufferBytes };
|
||||
} catch (error) {
|
||||
const state = this.broker.connections.get(handle.handleId);
|
||||
if (state) this.broker.finish(state, error);
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamConnectRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
registry.registerRequest("network.stream.attach", async ({ handleId }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
const record = this.get(handleId, context);
|
||||
if (record.attached) throw new PluginRpcError(RPC_ERRORS.failedPrecondition, "Byte stream is already attached");
|
||||
record.attached = true;
|
||||
try {
|
||||
record.output = await this.runtimeSupervisor.openStream(context.pluginId, record.outputStreamId, LIMITS.bufferBytes, {
|
||||
expectedIdentity: context,
|
||||
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
|
||||
});
|
||||
context.assertCommit();
|
||||
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
// A cancelled attachment cannot leave a producer running after its RPC.
|
||||
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
|
||||
return { attached: true };
|
||||
} catch (error) {
|
||||
try { record.output?.cancel(); } catch {}
|
||||
this.closeRecord(record, error);
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
registry.registerRequest("network.stream.close", async ({ handleId }, caller) => {
|
||||
const context = await this.authenticate(caller);
|
||||
if (!this.wasClosed(handleId, context)) this.closeRecord(this.get(handleId, context));
|
||||
return null;
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
this.streamRegistration = registry.registerIncomingStream(async (stream, caller) => {
|
||||
const record = [...this.records.values()].find((item) => item.inputStreamId === stream.streamId);
|
||||
if (!record) return false;
|
||||
const context = await this.authenticate(caller);
|
||||
this.get(record.handleId, context);
|
||||
if (record.input || stream.windowBytes !== LIMITS.bufferBytes) throw denied();
|
||||
record.input = stream;
|
||||
stream.bind({
|
||||
onChunk: async (chunk, release) => {
|
||||
try {
|
||||
if (chunk.encoding !== "binary" || !(chunk.bytes instanceof Uint8Array)) throw denied();
|
||||
await this.broker.write(record.handleId, chunk.bytes, record.context);
|
||||
record.context.assertCommit();
|
||||
release();
|
||||
} catch (error) { this.closeRecord(record, error); }
|
||||
},
|
||||
onClose: () => this.closeRecord(record),
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
get(handleId, context) {
|
||||
const record = this.records.get(handleId);
|
||||
if (!record || record.closing || record.context.runtimeId !== context.runtimeId
|
||||
|| record.context.pluginId !== context.pluginId || record.context.sourceOwner !== context.sourceOwner
|
||||
|| record.context.sourceId !== context.sourceId) throw denied();
|
||||
return record;
|
||||
}
|
||||
|
||||
wasClosed(handleId, context) {
|
||||
const owner = this.closedHandles.get(handleId);
|
||||
return owner && owner.runtimeId === context.runtimeId && owner.pluginId === context.pluginId
|
||||
&& owner.sourceOwner === context.sourceOwner && owner.sourceId === context.sourceId;
|
||||
}
|
||||
|
||||
async pump(record) {
|
||||
try {
|
||||
while (!record.closing) {
|
||||
const bytes = await this.broker.read(record.handleId, record.context);
|
||||
if (record.closing) return;
|
||||
record.context.assertCommit();
|
||||
if (bytes === null) { record.output.end(); break; }
|
||||
await record.output.write(bytes);
|
||||
}
|
||||
this.closeRecord(record);
|
||||
} catch (error) { this.closeRecord(record, error); }
|
||||
}
|
||||
|
||||
closeRecord(record, reason) {
|
||||
if (!record || record.closing) return;
|
||||
record.closing = true;
|
||||
record.context.signal?.removeEventListener("abort", record.abort);
|
||||
this.records.delete(record.handleId);
|
||||
const { pluginId, runtimeId, sourceOwner, sourceId } = record.context;
|
||||
this.closedHandles.set(record.handleId, { pluginId, runtimeId, sourceOwner, sourceId });
|
||||
while (this.closedHandles.size > 256) this.closedHandles.delete(this.closedHandles.keys().next().value);
|
||||
const state = this.broker.connections.get(record.handleId);
|
||||
if (state) this.broker.finish(state, reason);
|
||||
try { record.output?.cancel(); } catch {}
|
||||
try { void Promise.resolve(record.input?.cancel()).catch(() => {}); } catch {}
|
||||
}
|
||||
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const record of this.records.values()) {
|
||||
if (record.context.runtimeId === runtimeId) this.closeRecord(record);
|
||||
}
|
||||
this.broker.releaseRuntime(runtimeId);
|
||||
for (const [id, owner] of this.closedHandles) {
|
||||
if (owner.runtimeId === runtimeId) this.closedHandles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.closed = true;
|
||||
for (const record of this.records.values()) this.closeRecord(record);
|
||||
this.broker.shutdown();
|
||||
this.brokerClose.dispose();
|
||||
this.streamRegistration?.dispose();
|
||||
this.closedHandles.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginByteStreamService, FEATURE };
|
||||
@@ -0,0 +1,165 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
|
||||
const { assertPluginJsonValue } = require("../jsonBoundary.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const SCOPES = new Set(["workspace", "tab", "session", "host", "note"]);
|
||||
const LIMITS = Object.freeze({ handles: 128, perRuntime: 8, contentBytes: 16_384, ttlMs: 300_000, readMs: 5_000 });
|
||||
|
||||
// Internal host authority. Only trusted selection/review UI may call share().
|
||||
// A menu id, plugin supplied entity id or global inventory is never accepted.
|
||||
class SelectedContextAuthority {
|
||||
constructor({ resolveDataOwner, clock = Date.now, limits = {} }) {
|
||||
if (typeof resolveDataOwner !== "function") throw new TypeError("Host data-owner resolver required");
|
||||
this.resolveDataOwner = resolveDataOwner;
|
||||
this.clock = clock;
|
||||
this.limits = Object.freeze(Object.fromEntries(Object.entries(LIMITS).map(([key, max]) => {
|
||||
const value = limits[key] ?? max;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > max) throw new TypeError(`Invalid ${key} limit`);
|
||||
return [key, value];
|
||||
})));
|
||||
this.handles = new Map();
|
||||
this.closed = false;
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
async binding(context) {
|
||||
context.signal?.throwIfAborted();
|
||||
await context.assertActive();
|
||||
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");
|
||||
}
|
||||
context.signal?.throwIfAborted();
|
||||
return { owner, runtimeId: context.runtimeId, pluginVersion: context.pluginVersion };
|
||||
}
|
||||
|
||||
async #check(entry, context) {
|
||||
const binding = await this.binding(context);
|
||||
if (this.closed || this.handles.get(entry.handle) !== entry || entry.expiresAt <= this.clock()
|
||||
|| binding.owner !== entry.owner || binding.runtimeId !== entry.runtimeId
|
||||
|| binding.pluginVersion !== entry.pluginVersion) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
context.signal?.throwIfAborted();
|
||||
// The trusted reader checks the current window/resource generation and owner.
|
||||
// Recheck our map after awaiting it: close/revoke/version changes can race.
|
||||
const current = await entry.isCurrent();
|
||||
const currentOwner = await this.resolveDataOwner(context);
|
||||
await context.assertActive();
|
||||
context.signal?.throwIfAborted();
|
||||
if (!current || currentOwner !== entry.owner || this.handles.get(entry.handle) !== entry || this.closed || entry.expiresAt <= this.clock()) {
|
||||
throw fail("permissionDenied", "Selected context changed");
|
||||
}
|
||||
}
|
||||
|
||||
#prune() {
|
||||
for (const [handle, entry] of this.handles) if (entry.expiresAt <= this.clock()) this.release(handle);
|
||||
}
|
||||
|
||||
async share(selection, context) {
|
||||
if (this.closed) throw fail("unavailable", "Selected context is closed");
|
||||
assertPluginJsonValue({ scope: selection.scope, title: selection.title, version: selection.version }, { maxBytes: 1024 });
|
||||
if (!SCOPES.has(selection.scope) || typeof selection.title !== "string" || selection.title.length > 160
|
||||
|| typeof selection.version !== "string" || !selection.version || selection.version.length > 128
|
||||
|| typeof selection.readText !== "function" || typeof selection.isCurrent !== "function") {
|
||||
throw fail("invalidArgument", "Invalid selected context");
|
||||
}
|
||||
// This API accepts a curated text reader, never a Host/Vault DTO. Passwords,
|
||||
// private keys and arbitrary entity fields have no place in the descriptor.
|
||||
if (Object.keys(selection).some((key) => !["scope", "title", "version", "readText", "isCurrent"].includes(key))) {
|
||||
throw fail("invalidArgument", "Selected context contains unsupported fields");
|
||||
}
|
||||
const binding = await this.binding(context);
|
||||
if (!await selection.isCurrent()) throw fail("permissionDenied", "Selection is no longer current");
|
||||
await context.assertActive();
|
||||
this.#prune();
|
||||
if (this.closed || this.handles.size >= this.limits.handles
|
||||
|| [...this.handles.values()].filter((entry) => entry.runtimeId === context.runtimeId).length >= this.limits.perRuntime) {
|
||||
throw fail("resourceExhausted", "Selected context handle limit reached");
|
||||
}
|
||||
const entry = { ...binding, ...selection, handle: randomUUID(), expiresAt: this.clock() + this.limits.ttlMs };
|
||||
this.handles.set(entry.handle, entry);
|
||||
return this.#summary(entry);
|
||||
}
|
||||
|
||||
#summary(entry) {
|
||||
return { handle: entry.handle, scope: entry.scope, title: entry.title, version: entry.version, expiresAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
async summaries(context) {
|
||||
const binding = await this.binding(context);
|
||||
this.#prune();
|
||||
const summaries = [];
|
||||
for (const entry of [...this.handles.values()]) {
|
||||
if (entry.owner !== binding.owner || entry.runtimeId !== binding.runtimeId || entry.pluginVersion !== binding.pluginVersion) continue;
|
||||
try { await this.#check(entry, context); summaries.push(this.#summary(entry)); }
|
||||
catch (error) { if (context.signal?.aborted) throw error; this.release(entry.handle); }
|
||||
}
|
||||
await context.assertActive();
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async #read({ handle, version }, context) {
|
||||
const entry = this.handles.get(handle);
|
||||
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
await this.#check(entry, context);
|
||||
let text;
|
||||
try { text = await entry.readText({ signal: context.signal, maxBytes: this.limits.contentBytes }); }
|
||||
catch { throw fail("unavailable", "Selected context reader failed"); }
|
||||
await this.#check(entry, context);
|
||||
if (typeof text !== "string" || text.includes("\0") || Buffer.byteLength(text) > this.limits.contentBytes) {
|
||||
throw fail("resourceExhausted", "Selected context exceeds its text limit");
|
||||
}
|
||||
// Do not disclose private key material even if a curated text reader errs.
|
||||
if (/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/.test(text)) {
|
||||
throw fail("permissionDenied", "Private key material cannot be shared");
|
||||
}
|
||||
return { ...this.#summary(entry), text };
|
||||
}
|
||||
|
||||
async read(reference, context) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(fail("deadlineExceeded", "Selected context read timed out")), this.limits.readMs);
|
||||
const signal = context.signal ? AbortSignal.any([context.signal, controller.signal]) : controller.signal;
|
||||
let abort;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
return await new Promise((resolve, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
this.#read(reference, { ...context, signal }).then(resolve, reject);
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (abort) signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
async assertCurrent({ handle, version }, context) {
|
||||
const entry = this.handles.get(handle);
|
||||
if (!entry || entry.version !== version) throw fail("permissionDenied", "Selected context is unavailable");
|
||||
await this.#check(entry, context);
|
||||
}
|
||||
onDidInvalidate(listener) {
|
||||
this.listeners.add(listener);
|
||||
return { dispose: () => this.listeners.delete(listener) };
|
||||
}
|
||||
release(handle) {
|
||||
if (!this.handles.delete(handle)) return;
|
||||
for (const listener of [...this.listeners]) {
|
||||
// Revocation must finish even when an observer fails. Never log selected
|
||||
// text or let an observer exception retain another runtime handle.
|
||||
try { listener(handle); } catch {}
|
||||
}
|
||||
}
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const [handle, entry] of this.handles) if (entry.runtimeId === runtimeId) this.release(handle);
|
||||
}
|
||||
releaseOwner(owner) {
|
||||
for (const [handle, entry] of this.handles) if (entry.owner === owner) this.release(handle);
|
||||
}
|
||||
shutdown() { this.closed = true; for (const handle of [...this.handles.keys()]) this.release(handle); this.listeners.clear(); }
|
||||
}
|
||||
|
||||
module.exports = { SelectedContextAuthority, SELECTED_CONTEXT_LIMITS: LIMITS };
|
||||
@@ -0,0 +1,87 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { SelectedContextAuthority } = require("./selectedContextAuthority.cjs");
|
||||
const deferred = () => { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
|
||||
function fixture() {
|
||||
const owner = {}; let currentOwner = owner; let current = true; let version = "1"; let reads = 0; let now = 0;
|
||||
const context = { pluginId: "same", pluginVersion: "1", runtimeId: "r1", assertActive: async () => {} };
|
||||
const authority = new SelectedContextAuthority({ resolveDataOwner: async () => currentOwner, clock: () => now });
|
||||
const selection = { scope: "note", title: "Synthetic Note", version: "1", isCurrent: () => current && version === "1",
|
||||
readText: () => { reads++; return "synthetic content"; } };
|
||||
return { owner, context, authority, selection, reads: () => reads, ownerTo: (v) => { currentOwner = v; },
|
||||
versionTo: (v) => { version = v; }, currentTo: (v) => { current = v; }, timeTo: (v) => { now = v; } };
|
||||
}
|
||||
|
||||
test("summaries enumerate only explicitly shared selection and never read content", async () => {
|
||||
const f = fixture(); assert.deepEqual(await f.authority.summaries(f.context), []);
|
||||
const note = await f.authority.share(f.selection, f.context);
|
||||
assert.deepEqual(await f.authority.summaries(f.context), [note]); assert.equal(f.reads(), 0);
|
||||
assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
|
||||
assert.equal(f.reads(), 1);
|
||||
assert.deepEqual(await f.authority.summaries({ ...f.context, runtimeId: "r2" }), []);
|
||||
f.ownerTo({}); assert.deepEqual(await f.authority.summaries(f.context), []);
|
||||
await assert.rejects(f.authority.read(note, f.context));
|
||||
f.ownerTo(f.owner); assert.equal((await f.authority.read(note, f.context)).text, "synthetic content");
|
||||
});
|
||||
|
||||
test("scope, owner, expected version and resource lifetime are enforced before read", async () => {
|
||||
for (const action of ["version", "owner", "closed", "expiry", "release", "runtime"]) {
|
||||
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
|
||||
if (action === "version") f.versionTo("2");
|
||||
if (action === "owner") f.currentTo(false);
|
||||
if (action === "closed") f.authority.shutdown();
|
||||
if (action === "expiry") f.timeTo(300_001);
|
||||
if (action === "release") f.authority.release(note.handle);
|
||||
if (action === "runtime") f.authority.releaseRuntime("r1");
|
||||
await assert.rejects(f.authority.read(note, f.context)); assert.equal(f.reads(), 0);
|
||||
}
|
||||
const f = fixture(); const note = await f.authority.share(f.selection, f.context);
|
||||
await assert.rejects(f.authority.read({ ...note, version: "2" }, f.context));
|
||||
await assert.rejects(f.authority.read({ handle: "menu-id", version: "1" }, f.context));
|
||||
await assert.rejects(f.authority.share({ ...f.selection, scope: "all" }, f.context));
|
||||
});
|
||||
|
||||
test("revocation/version/owner replacement while reader awaits suppresses late content", async () => {
|
||||
for (const action of ["release", "version", "owner", "source"]) {
|
||||
const f = fixture(); const pending = deferred(); let started;
|
||||
const entered = new Promise((r) => { started = r; });
|
||||
const note = await f.authority.share({ ...f.selection, readText: () => { started(); return pending.promise; } }, f.context);
|
||||
const reading = f.authority.read(note, f.context); await entered;
|
||||
if (action === "release") f.authority.release(note.handle);
|
||||
if (action === "version") f.versionTo("2");
|
||||
if (action === "owner") f.currentTo(false);
|
||||
if (action === "source") f.ownerTo({});
|
||||
pending.resolve("late secret text"); await assert.rejects(reading);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown Host fields, private keys and oversized content are excluded", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(f.authority.share({ ...f.selection, password: "forbidden" }, f.context));
|
||||
await assert.rejects(f.authority.share({ ...f.selection, privateKey: "forbidden" }, f.context));
|
||||
for (const text of ["-----BEGIN OPENSSH PRIVATE KEY-----\nforbidden", "X".repeat(16_385)]) {
|
||||
const note = await f.authority.share({ ...f.selection, readText: () => text }, f.context);
|
||||
await assert.rejects(f.authority.read(note, f.context));
|
||||
}
|
||||
});
|
||||
|
||||
test("content reader has a deadline and respects request cancellation", async () => {
|
||||
const owner = {}; const context = { runtimeId: "r", pluginVersion: "1", assertActive: async () => {} };
|
||||
const authority = new SelectedContextAuthority({ resolveDataOwner: () => owner, limits: { readMs: 20 } });
|
||||
const note = await authority.share({ scope: "note", title: "Synthetic", version: "1", isCurrent: () => true,
|
||||
readText: () => new Promise(() => {}) }, context);
|
||||
await assert.rejects(authority.read(note, context), /timed out/);
|
||||
const controller = new AbortController(); const reading = authority.read(note, { ...context, signal: controller.signal });
|
||||
controller.abort(); await assert.rejects(reading); authority.shutdown();
|
||||
});
|
||||
|
||||
test("one failing invalidation listener cannot interrupt other listeners or handle cleanup", async () => {
|
||||
const f = fixture(); const first = await f.authority.share(f.selection, f.context); const second = await f.authority.share(f.selection, f.context);
|
||||
const notified = [];
|
||||
f.authority.onDidInvalidate(() => { throw Error("listener failed"); });
|
||||
f.authority.onDidInvalidate((handle) => notified.push(handle));
|
||||
assert.doesNotThrow(() => f.authority.releaseRuntime("r1"));
|
||||
assert.equal(f.authority.handles.size, 0);
|
||||
assert.deepEqual(notified, [first.handle, second.handle]);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
// Test transport harness only. This is Node, not an OS/browser sandbox test.
|
||||
// Production continues to use the sandboxed BrowserPluginRuntime unchanged.
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const { PluginRpcRouter } = require("../rpcRouter.cjs");
|
||||
|
||||
function inProcessBrowserRuntime(options) {
|
||||
let host;
|
||||
let peer;
|
||||
let port;
|
||||
return {
|
||||
async start(config) {
|
||||
const { startPluginRuntime } = await import("../runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
port = port1;
|
||||
host = new PluginRpcRouter({ pluginId: config.pluginId,
|
||||
send: (message, transfer) => port1.postMessage(message, transfer),
|
||||
requestHandlers: options.requestHandlers, notificationHandlers: options.notificationHandlers,
|
||||
onIncomingStream: options.onIncomingStream, onBeforeMessage: options.onBeforeMessage,
|
||||
onProtocolError: options.onProtocolError,
|
||||
});
|
||||
port1.on("message", (message) => { void host.accept(message); });
|
||||
peer = await startPluginRuntime({ port: port2, config,
|
||||
loadPlugin: () => import(pathToFileURL(path.join(options.packageRoot, options.plugin.manifest.main.browser)).href),
|
||||
});
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: config.netcattyVersion, apiVersion: config.apiVersion, supportedFeatures: config.supportedFeatures,
|
||||
});
|
||||
await host.request("plugin.activate", {});
|
||||
return initialized;
|
||||
},
|
||||
request: (...args) => host.request(...args),
|
||||
notify: (...args) => host.notify(...args),
|
||||
openStream: (...args) => host.streams.openOutgoing(...args),
|
||||
async stop() {
|
||||
try { if (host) await host.request("plugin.deactivate", {}); }
|
||||
finally { await peer?.dispose(); host?.close(); port?.close(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { inProcessBrowserRuntime };
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ function normalizeRuntimeIdentity(identity) {
|
||||
runtimeKind: identity.runtimeKind,
|
||||
...(identity.manifest === undefined ? {} : { manifest: identity.manifest }),
|
||||
...(identity.securityPrincipal === undefined ? {} : { securityPrincipal: identity.securityPrincipal }),
|
||||
...(identity.sourceOwner === undefined ? {} : { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId }),
|
||||
...(identity.packageRoot === undefined ? {} : { packageRoot: identity.packageRoot }),
|
||||
...(identity.logger === undefined ? {} : { logger: identity.logger }),
|
||||
});
|
||||
|
||||
@@ -27,7 +27,13 @@ const { PackageStore } = require("./packageStore.cjs");
|
||||
const { createPluginPaths } = require("./paths.cjs");
|
||||
const { PluginManager } = require("./pluginManager.cjs");
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byteStreamService.cjs");
|
||||
const { PluginAIContextService, PLUGIN_AI_CONTEXT_FEATURES } = require("./ai/aiContextService.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { 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");
|
||||
@@ -90,8 +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, 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;
|
||||
try {
|
||||
const packageStore = new PackageStore({
|
||||
allowAdvancedPlugins,
|
||||
@@ -99,7 +108,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures,
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -192,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");
|
||||
@@ -239,7 +253,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["appearance.theme-packs", "metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures,
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -249,12 +263,31 @@ function createPluginHostService(options) {
|
||||
getInitialEnvironment: () => contributionService.getEnvironment(),
|
||||
runtimeResourceMonitor: quotaManager,
|
||||
runtimeCleanup: async (identity) => {
|
||||
byteStreamService?.releaseRuntime(identity.runtimeId);
|
||||
aiContextService?.releaseRuntime(identity.runtimeId);
|
||||
interactionService?.releaseRuntime(identity.runtimeId);
|
||||
leaseStore.revokeRuntime(identity.runtimeId);
|
||||
await companionSupervisor.releaseRuntime(identity.runtimeId);
|
||||
},
|
||||
utilityModuleMappings: options.utilityModuleMappings ?? createUtilityModuleMappings(moduleResources),
|
||||
});
|
||||
byteStreamService = new PluginByteStreamService({ database, permissionEngine, quotaManager, runtimeSupervisor });
|
||||
byteStreamService.register(rpcRegistry);
|
||||
aiContextService = new PluginAIContextService({ database, permissionEngine,
|
||||
getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId),
|
||||
hostAdapter: options.pluginAIHostAdapter ?? {
|
||||
assertActive: (context) => context.assertActive(),
|
||||
selectModel: () => { throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin model adapter is unavailable"); },
|
||||
listSelected: () => [],
|
||||
reviewText: () => false,
|
||||
},
|
||||
});
|
||||
aiContextService.registerRpcCapabilities(rpcRegistry);
|
||||
const originalOnPluginDisabled = contributionService.onPluginDisabled.bind(contributionService);
|
||||
contributionService.onPluginDisabled = (pluginId) => {
|
||||
aiContextService.releasePlugin(pluginId);
|
||||
return originalOnPluginDisabled(pluginId);
|
||||
};
|
||||
runtimeSupervisor.onDidChangeRuntime((event) => contributionService.onRuntimeStateChanged(event));
|
||||
const terminalProviderService = new PluginTerminalProviderService({
|
||||
contributionService,
|
||||
@@ -382,6 +415,8 @@ function createPluginHostService(options) {
|
||||
beforeClose: async () => {
|
||||
themeService.shutdown();
|
||||
interactionService.shutdown();
|
||||
byteStreamService.shutdown();
|
||||
aiContextService.shutdown();
|
||||
terminalDataPipelineService?.shutdown();
|
||||
extensionProviderService.shutdown();
|
||||
credentialResolver.shutdown?.();
|
||||
@@ -395,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,
|
||||
@@ -412,6 +448,8 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
protocol,
|
||||
networkBroker,
|
||||
byteStreamService,
|
||||
aiContextService,
|
||||
permissionEngine,
|
||||
quotaManager,
|
||||
rpcRegistry,
|
||||
@@ -423,6 +461,8 @@ function createPluginHostService(options) {
|
||||
};
|
||||
} catch (error) {
|
||||
try { interactionService?.shutdown(); } catch {}
|
||||
try { byteStreamService?.shutdown(); } catch {}
|
||||
try { aiContextService?.shutdown(); } catch {}
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,4 @@
|
||||
"use strict";
|
||||
// Include the isolated domain suites in npm run test:plugin-runtime.
|
||||
require("./ai/modelInvocationBroker.test.cjs");
|
||||
require("./context/selectedContextAuthority.test.cjs");
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { createHash } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { canonicalizeStreamEndpoint } = require("./byteStreamEndpoint.cjs");
|
||||
|
||||
const contractSchema = require("./generated/plugin-contract.schema.json");
|
||||
|
||||
@@ -62,6 +63,7 @@ function canonicalizeFilesystemResource(value) {
|
||||
|
||||
function canonicalizePermissionResource(permission, resource) {
|
||||
assertPluginPermission(permission);
|
||||
if (permission === "network.stream") return canonicalizeStreamEndpoint(resource).endpoint;
|
||||
if (resource === "*") return resource;
|
||||
if (permission === "network") return canonicalizeNetworkOrigin(resource);
|
||||
if (permission === "companion.execute") return canonicalizeCompanionResource(resource);
|
||||
@@ -86,6 +88,7 @@ function permissionResourceCovers(
|
||||
resourceKind = "exact",
|
||||
requestedResourceKind = "exact",
|
||||
) {
|
||||
if (permission === "network.stream") return grantResource !== "*" && grantResource === requestedResource;
|
||||
if (grantResource === "*") return true;
|
||||
if (permission === "filesystem.read" || permission === "filesystem.write") {
|
||||
if (requestedResourceKind === "directory" && resourceKind !== "directory") return false;
|
||||
@@ -112,7 +115,7 @@ function normalizePermissionDeclarations(manifest) {
|
||||
: [...new Set((value.resources ?? []).map((resource) => (
|
||||
canonicalizePermissionResource(permission, resource)
|
||||
)))].sort();
|
||||
if (required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) {
|
||||
if ((required && RESOURCE_SCOPED_PERMISSIONS.has(permission)) || permission === "network.stream") {
|
||||
if (resources.length === 0) {
|
||||
throw new TypeError(`Required permission ${permission} must declare resources`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PluginError } from "@netcatty/plugin-sdk";
|
||||
import { PLUGIN_BYTE_STREAM_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export async function connectByteStream({ client, streams, subscriptions, enabledFeatures }, request, cancellationToken) {
|
||||
if (!enabledFeatures.includes(PLUGIN_BYTE_STREAM_FEATURE)) throw new PluginError("unsupported", "Byte stream feature is unavailable");
|
||||
const descriptor = await client.request("network.stream.connect", request, { cancellationToken });
|
||||
let readable;
|
||||
let writable;
|
||||
let readablePromise;
|
||||
let closing;
|
||||
let cancellation;
|
||||
let connection;
|
||||
const close = () => {
|
||||
closing ??= client.request("network.stream.close", { handleId: descriptor.handleId })
|
||||
.finally(() => {
|
||||
cancellation?.dispose();
|
||||
streams.rejectReadable(descriptor.outputStreamId);
|
||||
if (connection) subscriptions.delete(connection);
|
||||
});
|
||||
return closing;
|
||||
};
|
||||
const cancel = () => { void close().catch(() => {}); };
|
||||
const checkCancelled = async () => {
|
||||
if (!cancellationToken?.isCancellationRequested) return;
|
||||
await close().catch(() => {});
|
||||
throw new PluginError("cancelled", "Byte stream connection cancelled");
|
||||
};
|
||||
try {
|
||||
await checkCancelled();
|
||||
readablePromise = streams.acceptReadable(descriptor.outputStreamId);
|
||||
void readablePromise.catch(() => {});
|
||||
writable = await streams.openWritable(descriptor.inputStreamId, descriptor.windowBytes);
|
||||
const attaching = client.request("network.stream.attach", { handleId: descriptor.handleId }, { cancellationToken });
|
||||
[readable] = await Promise.all([readablePromise, attaching]);
|
||||
await checkCancelled();
|
||||
connection = Object.freeze({
|
||||
endpoint: descriptor.endpoint,
|
||||
readable: Object.freeze({
|
||||
id: readable.id, read: () => readable.read(), cancel, dispose: cancel,
|
||||
}),
|
||||
writable: Object.freeze({
|
||||
id: writable.id, write: (bytes) => writable.write(bytes), end: () => writable.end(),
|
||||
fail: cancel, cancel, dispose: cancel,
|
||||
}),
|
||||
close, dispose: cancel,
|
||||
});
|
||||
cancellation = cancellationToken?.onCancellationRequested(cancel);
|
||||
subscriptions.add(connection);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
await close().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
import { connectByteStream } from "./byteStreamClient.mjs";
|
||||
|
||||
let terminalInterceptorTransportPromise;
|
||||
|
||||
@@ -164,6 +165,7 @@ function createHostClient(transport) {
|
||||
const request = pending.get(`${typeof message.id}:${String(message.id)}`);
|
||||
if (!request) return false;
|
||||
pending.delete(`${typeof message.id}:${String(message.id)}`);
|
||||
request.cancellation?.dispose();
|
||||
if (message.error) {
|
||||
request.reject(new PluginError(
|
||||
pluginErrorNameFromRpcError(message.error),
|
||||
@@ -174,17 +176,24 @@ function createHostClient(transport) {
|
||||
return true;
|
||||
}
|
||||
function request(method, params, options = {}) {
|
||||
if (options.cancellationToken?.isCancellationRequested) return Promise.reject(new PluginError("cancelled", "Plugin request cancelled"));
|
||||
const id = nextId;
|
||||
nextId = nextId === Number.MAX_SAFE_INTEGER ? 0 : nextId + 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(`${typeof id}:${String(id)}`, { resolve, reject });
|
||||
const cancellationId = options.cancellationToken ? `browser-${id}` : undefined;
|
||||
const pendingRequest = { resolve, reject };
|
||||
pending.set(`${typeof id}:${String(id)}`, pendingRequest);
|
||||
transport.post({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
...(cancellationId === undefined ? {} : { cancellationId }),
|
||||
...(options.deadlineMs === undefined ? {} : { deadlineMs: options.deadlineMs }),
|
||||
});
|
||||
pendingRequest.cancellation = options.cancellationToken?.onCancellationRequested(() => {
|
||||
notify("$/cancelRequest", { cancellationId });
|
||||
});
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
@@ -192,7 +201,7 @@ function createHostClient(transport) {
|
||||
}
|
||||
function close() {
|
||||
const error = new PluginError("unavailable", "Plugin host disconnected");
|
||||
for (const request of pending.values()) request.reject(error);
|
||||
for (const request of pending.values()) { request.cancellation?.dispose(); request.reject(error); }
|
||||
pending.clear();
|
||||
}
|
||||
return { accept, request, notify, close };
|
||||
@@ -371,15 +380,27 @@ 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),
|
||||
};
|
||||
const network = {
|
||||
connect: (request, cancellationToken) => connectByteStream({ client, streams: runtimeApi.streams, subscriptions,
|
||||
enabledFeatures: config.enabledFeatures }, request, cancellationToken),
|
||||
request: (request) => client.request("network.request", request, {
|
||||
deadlineMs: forwardedDeadline(request?.timeoutMs, 300_000),
|
||||
}),
|
||||
};
|
||||
const ai = Object.fromEntries(["openChat", "startTurn", "readTurn", "stopTurn", "closeChat"].map((operation) => [
|
||||
operation, (params) => client.request(`ai.${operation}`, params, operation === "readTurn" ? { deadlineMs: 65_000 } : {}),
|
||||
]));
|
||||
const selectedContext = {
|
||||
list: (params) => client.request("context.selected.list", params),
|
||||
read: (params) => client.request("context.selected.read", params),
|
||||
};
|
||||
const filesystem = {
|
||||
readFile: (filePath, options = {}) => client.request("filesystem.readFile", {
|
||||
path: filePath,
|
||||
@@ -553,7 +574,10 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
secrets,
|
||||
credentials,
|
||||
network,
|
||||
ai,
|
||||
selectedContext,
|
||||
ui,
|
||||
vault,
|
||||
clipboard,
|
||||
filesystem,
|
||||
companions,
|
||||
|
||||
@@ -239,6 +239,10 @@ class RuntimeSupervisor {
|
||||
signal.throwIfAborted();
|
||||
const plugin = this.database.getActivePlugin(pluginId);
|
||||
if (!plugin?.manifest || !plugin.packageRelativePath) throw new Error(`Plugin is not installed: ${pluginId}`);
|
||||
// Capture the host's opaque source owner before async package/placement work.
|
||||
// Test doubles without provenance APIs cannot use source-bound capabilities.
|
||||
const sourceOwner = plugin.source && typeof this.database.privateOwner === "function"
|
||||
? this.database.privateOwner(pluginId) : null;
|
||||
if (!plugin.enabled) throw new Error(`Plugin is disabled: ${pluginId}`);
|
||||
if (plugin.manifest.main.data) throw new Error("Static theme packs have no executable runtime");
|
||||
if (plugin.runtime.quarantinedAt != null) throw new Error(`Plugin is quarantined: ${pluginId}`);
|
||||
@@ -273,6 +277,8 @@ class RuntimeSupervisor {
|
||||
const currentPlugin = this.database.getActivePlugin(pluginId);
|
||||
if (
|
||||
!currentPlugin?.enabled
|
||||
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|
||||
|| (plugin.source && currentPlugin.source?.id !== plugin.source.id)
|
||||
|| currentPlugin.activeVersion !== plugin.activeVersion
|
||||
|| currentPlugin.runtime.quarantinedAt != null
|
||||
) {
|
||||
@@ -286,6 +292,7 @@ class RuntimeSupervisor {
|
||||
runtimeId: randomUUID(),
|
||||
runtimeKind: kind,
|
||||
securityPrincipal,
|
||||
...(sourceOwner === null ? {} : { sourceOwner, sourceId: plugin.source.id }),
|
||||
manifest: freezeJson(structuredClone(plugin.manifest)),
|
||||
packageRoot,
|
||||
logger,
|
||||
@@ -294,6 +301,8 @@ class RuntimeSupervisor {
|
||||
if (
|
||||
this.runtimes.get(pluginId) !== runtime
|
||||
|| !active?.enabled
|
||||
|| (sourceOwner !== null && this.database.privateOwner(pluginId) !== sourceOwner)
|
||||
|| (plugin.source && active.source?.id !== plugin.source.id)
|
||||
|| active.activeVersion !== plugin.activeVersion
|
||||
|| (active.runtime.status !== "starting" && active.runtime.status !== "running")
|
||||
) {
|
||||
@@ -543,6 +552,7 @@ class RuntimeSupervisor {
|
||||
async #cleanupRuntime(identity) {
|
||||
if (!identity || !this.runtimeCleanup) return;
|
||||
await this.runtimeCleanup(Object.freeze({
|
||||
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
|
||||
pluginId: identity.pluginId,
|
||||
pluginVersion: identity.pluginVersion,
|
||||
runtimeId: identity.runtimeId,
|
||||
@@ -575,6 +585,7 @@ class RuntimeSupervisor {
|
||||
runtimeId: identity.runtimeId,
|
||||
runtimeKind: identity.runtimeKind,
|
||||
securityPrincipal: identity.securityPrincipal,
|
||||
...(identity.sourceOwner ? { sourceOwner: identity.sourceOwner, sourceId: identity.sourceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -651,7 +662,7 @@ class RuntimeSupervisor {
|
||||
if (typeof runtime.openStream !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, `Plugin runtime cannot receive streams: ${pluginId}`);
|
||||
}
|
||||
const stream = await runtime.openStream(streamId, windowBytes);
|
||||
const stream = await runtime.openStream(streamId, windowBytes, { onClose: options?.onClose });
|
||||
try {
|
||||
const current = await this.#getRunningRuntime(pluginId);
|
||||
if (current.runtime !== runtime || current.identity.runtimeId !== identity.runtimeId) {
|
||||
|
||||
@@ -158,6 +158,8 @@ class PluginStreamRouter {
|
||||
this.outgoing.delete(frame.streamId);
|
||||
for (const pending of outgoing.queue) pending.reject(new Error(`Plugin stream cancelled: ${frame.streamId}`));
|
||||
outgoing.queue.length = 0;
|
||||
outgoing.queuedBytes = 0;
|
||||
this.#notifyOutgoingClosed(outgoing, "cancel");
|
||||
return;
|
||||
}
|
||||
const state = this.incoming.get(frame.streamId);
|
||||
@@ -233,7 +235,8 @@ class PluginStreamRouter {
|
||||
state.onClose = handlers.onClose;
|
||||
}
|
||||
|
||||
async openOutgoing(streamId, windowBytes) {
|
||||
async openOutgoing(streamId, windowBytes, options = {}) {
|
||||
if (options.onClose !== undefined && typeof options.onClose !== "function") throw new TypeError("Stream onClose must be a function");
|
||||
if (
|
||||
this.closed
|
||||
|| this.outgoing.has(streamId)
|
||||
@@ -268,6 +271,7 @@ class PluginStreamRouter {
|
||||
queuedBytes: 0,
|
||||
closed: false,
|
||||
terminalKind: null,
|
||||
onClose: options.onClose,
|
||||
};
|
||||
this.outgoing.set(streamId, state);
|
||||
try {
|
||||
@@ -347,6 +351,13 @@ class PluginStreamRouter {
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
state.queue.length = 0;
|
||||
state.queuedBytes = 0;
|
||||
this.#notifyOutgoingClosed(state, error);
|
||||
}
|
||||
|
||||
#notifyOutgoingClosed(state, reason) {
|
||||
if (state.closeNotified) return;
|
||||
state.closeNotified = true;
|
||||
try { state.onClose?.(reason); } catch {}
|
||||
}
|
||||
|
||||
#finishOutgoing(state, kind) {
|
||||
@@ -373,6 +384,7 @@ class PluginStreamRouter {
|
||||
) {
|
||||
this.outgoing.delete(state.streamId);
|
||||
}
|
||||
this.#notifyOutgoingClosed(state, kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +425,7 @@ class PluginStreamRouter {
|
||||
for (const state of this.outgoing.values()) {
|
||||
state.closed = true;
|
||||
for (const pending of state.queue) pending.reject(error);
|
||||
this.#notifyOutgoingClosed(state, error);
|
||||
}
|
||||
this.outgoing.clear();
|
||||
for (const state of this.incoming.values()) {
|
||||
|
||||
@@ -208,9 +208,9 @@ class UtilityPluginRuntime {
|
||||
this.router.notify(method, params);
|
||||
}
|
||||
|
||||
openStream(streamId, windowBytes) {
|
||||
openStream(streamId, windowBytes, options) {
|
||||
if (!this.router) return Promise.reject(new Error("Plugin utility runtime is not connected"));
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes);
|
||||
return this.router.streams.openOutgoing(streamId, windowBytes, options);
|
||||
}
|
||||
|
||||
attachTerminalInterceptor(descriptor, port) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Assistant example source
|
||||
|
||||
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 runtime entry and package manifest are pending the parent's shared contract
|
||||
and SDK decision. This directory is not yet an installable plugin.
|
||||
The command names in `panel.js` are example-local command IDs, not host RPC methods.
|
||||
A menu or command ID grants no authority to read a resource.
|
||||
|
||||
The 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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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));
|
||||
import { URL } from 'node:url';
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.assistant",
|
||||
"name": "assistant-example",
|
||||
"displayName": "Selected Note Assistant",
|
||||
"description": "Minimal sandbox assistant using a host-selected model and one explicitly selected reference. No tools or commands are executed.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"main": { "browser": "dist/index.js" },
|
||||
"activationEvents": [ "onView:com.netcatty.assistant.panel" ],
|
||||
"permissions": {
|
||||
"required": [ "commands", "views" ],
|
||||
"optional": [ "ai.invoke", "context.selected.list", "context.selected.read" ]
|
||||
},
|
||||
"features": { "required": [ "ai.model-invocation", "context.selected-read" ] },
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.assistant.open", "title": "Assistant: Open Chat" },
|
||||
{ "id": "com.netcatty.assistant.contexts", "title": "Assistant: Selected References" },
|
||||
{ "id": "com.netcatty.assistant.send", "title": "Assistant: Send" },
|
||||
{ "id": "com.netcatty.assistant.read", "title": "Assistant: Read Stream" },
|
||||
{ "id": "com.netcatty.assistant.stop", "title": "Assistant: Stop" },
|
||||
{ "id": "com.netcatty.assistant.close", "title": "Assistant: Close Chat" }
|
||||
],
|
||||
"views": [ { "id": "com.netcatty.assistant.panel", "title": "Selected Note Assistant", "location": "modal", "entry": "panel.html" } ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@netcatty/example-assistant-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
: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:#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:#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:#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; 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; }
|
||||
#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:#9aabc3; margin-top:8px; }
|
||||
footer { padding-top:8px; }
|
||||
@media(max-width:520px) { main { padding:22px; } h1 { font-size:23px; } }
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>Assistant example</title>
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<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></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>
|
||||
<script src="panel.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
/* global document, window */
|
||||
'use strict';
|
||||
const element = (id) => document.getElementById(id);
|
||||
const invoke = (operation, params = {}) => window.netcattyView.executeCommand(`com.netcatty.assistant.${operation}`, params);
|
||||
let chat;
|
||||
let turn;
|
||||
let generation = 0;
|
||||
const references = new Map();
|
||||
function busy(value) {
|
||||
element('send').disabled = value || !chat;
|
||||
element('stop').disabled = !value;
|
||||
element('context').disabled = value;
|
||||
element('prompt').disabled = value;
|
||||
}
|
||||
async function initialize() {
|
||||
try {
|
||||
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 {
|
||||
element('status').textContent = 'Choose a model and grant access in Netcatty.';
|
||||
}
|
||||
}
|
||||
element('compose').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!chat || element('send').disabled) return;
|
||||
const run = ++generation;
|
||||
busy(true); element('status').textContent = 'Streaming...';
|
||||
element('response').textContent = ''; element('usage').textContent = '';
|
||||
try {
|
||||
const reference = references.get(element('context').value);
|
||||
const started = await invoke('send', { chatHandle: chat.chatHandle, prompt: element('prompt').value,
|
||||
contexts: reference ? [reference] : [] });
|
||||
if (run !== generation) { await invoke('stop', { turnHandle: started.turnHandle }); return; }
|
||||
turn = started.turnHandle;
|
||||
for (;;) {
|
||||
const chunk = await invoke('read', { turnHandle: turn });
|
||||
if (run !== generation) return;
|
||||
if (chunk.kind === 'end') break;
|
||||
if (chunk.kind === 'text') element('response').textContent += chunk.text;
|
||||
if (chunk.kind === 'usage') element('usage').textContent = `${chunk.inputTokens ?? '?'} input tokens · ${chunk.outputTokens ?? '?'} output tokens`;
|
||||
}
|
||||
element('status').textContent = 'Complete';
|
||||
} catch {
|
||||
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 () => {
|
||||
++generation;
|
||||
const stoppedTurn = turn; turn = undefined;
|
||||
element('status').textContent = 'Stopping...';
|
||||
try {
|
||||
if (stoppedTurn) await invoke('stop', { turnHandle: stoppedTurn });
|
||||
else if (chat) {
|
||||
await invoke('close', { chatHandle: chat.chatHandle });
|
||||
chat = await invoke('open');
|
||||
}
|
||||
}
|
||||
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', () => { if (turn) invoke('stop', { turnHandle: turn }).catch(() => {}); });
|
||||
void initialize();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Ordinary browser runtime. The host SDK supplies the only privileged surface.
|
||||
export default {
|
||||
activate(context) {
|
||||
const register = (name, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.assistant.${name}`, handler,
|
||||
));
|
||||
register('open', () => context.ai.openChat({}));
|
||||
register('contexts', async () => (await context.selectedContext.list({})).items);
|
||||
register('send', (params) => context.ai.startTurn(params));
|
||||
register('read', (params) => context.ai.readTurn(params));
|
||||
register('stop', (params) => context.ai.stopTurn(params));
|
||||
register('close', (params) => context.ai.closeChat(params));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CancellationTokenSource } from "@netcatty/plugin-sdk";
|
||||
import { queryExample } from "./protocol.mjs";
|
||||
import { endpoint } from "./fixture-config.mjs";
|
||||
|
||||
export default {
|
||||
activate(context) {
|
||||
let pending;
|
||||
const register = (suffix, handler) => context.subscriptions.add(context.commands.registerCommand(
|
||||
`com.netcatty.database-minimal.${suffix}`, handler,
|
||||
));
|
||||
const cancel = () => { pending?.cancel(); return { cancelled: true }; };
|
||||
register("cancel", cancel);
|
||||
register("endpoint", () => ({ endpoint }));
|
||||
context.subscriptions.add({ dispose: cancel });
|
||||
register("query", async (args) => {
|
||||
if (pending) throw new Error("An example connection is already active");
|
||||
if (!args || Array.isArray(args) || Object.keys(args).some((key) => !["username", "password"].includes(key))
|
||||
|| typeof args.username !== "string" || typeof args.password !== "string") throw new Error("Enter disposable fixture credentials");
|
||||
const cancellation = new CancellationTokenSource();
|
||||
pending = cancellation;
|
||||
let connection;
|
||||
try {
|
||||
connection = await context.network.connect({ endpoint }, cancellation.token);
|
||||
return await queryExample({ read: () => connection.readable.read(), write: (bytes) => connection.writable.write(bytes),
|
||||
close: () => connection.close() }, args.username, args.password);
|
||||
} finally {
|
||||
args.password = "";
|
||||
await connection?.close().catch(() => {});
|
||||
cancellation.dispose();
|
||||
pending = undefined;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const endpoint = "tcp://database-fixture.invalid:3306";
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"name": "database-minimal",
|
||||
"displayName": "Disposable database example",
|
||||
"description": "Connect to one prepared synthetic fixture and read one row.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"features": { "required": ["network.byte-streams"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onCommand:com.netcatty.database-minimal.query", "onView:com.netcatty.database-minimal.panel"],
|
||||
"permissions": {
|
||||
"required": ["commands", "views"],
|
||||
"optional": [{ "permission": "network.stream", "resources": ["tcp://database-fixture.invalid:3306"], "reason": "Connect only to the prepared disposable fixture." }]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.database-minimal.query", "title": "Read one synthetic database row" },
|
||||
{ "id": "com.netcatty.database-minimal.cancel", "title": "Cancel example connection" },
|
||||
{ "id": "com.netcatty.database-minimal.endpoint", "title": "Show example endpoint" }
|
||||
],
|
||||
"views": [{ "id": "com.netcatty.database-minimal.panel", "title": "Disposable database example", "location": "modal", "entry": "panel.html" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark light;font:14px system-ui,sans-serif;color:hsl(var(--foreground,210 20% 90%));background:hsl(var(--background,220 15% 12%))}*{box-sizing:border-box}body{margin:0}main{max-width:640px;padding:24px;margin:auto}h1{font-size:24px}.eyebrow{font-size:11px;letter-spacing:.1em;opacity:.7}p{line-height:1.5}#endpoint,pre{overflow-wrap:anywhere;white-space:pre-wrap}label{display:block;margin:16px 0 6px}input{width:100%;padding:10px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;background:hsl(var(--background,220 15% 12%));color:inherit}.actions{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0}button{padding:10px 16px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;color:inherit;background:hsl(var(--secondary,220 12% 22%));cursor:pointer}button:disabled{opacity:.5;cursor:default}input:focus-visible,button:focus-visible{outline:2px solid hsl(var(--primary,210 90% 60%));outline-offset:2px}pre{padding:14px;border:1px solid hsl(var(--border,220 10% 35%));border-radius:6px;min-height:80px}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Disposable database example</title><link rel="stylesheet" href="panel.css"></head>
|
||||
<body><main><p class="eyebrow">NETCATTY PLUGIN EXAMPLE</p><h1>Read one synthetic row</h1>
|
||||
<p>This example connects only to its prepared disposable test server. Enter the fixture account. Credentials stay in this session.</p>
|
||||
<p id="endpoint">Loading prepared endpoint…</p><form id="form" autocomplete="off">
|
||||
<label for="username">Fixture username</label><input id="username" name="fixture-user" autocomplete="off" maxlength="32" required>
|
||||
<label for="password">Session password</label><input id="password" name="fixture-password" type="password" autocomplete="new-password" maxlength="256" required>
|
||||
<div class="actions"><button id="query" type="submit">Connect and read</button><button id="cancel" type="button" disabled>Cancel</button></div></form>
|
||||
<pre id="result" role="status" aria-live="polite">Ready for a disposable fixture.</pre>
|
||||
</main><script src="panel.js"></script></body></html>
|
||||
@@ -0,0 +1,35 @@
|
||||
/* global window, document */
|
||||
"use strict";
|
||||
const username = document.getElementById("username");
|
||||
const password = document.getElementById("password");
|
||||
const query = document.getElementById("query");
|
||||
const cancel = document.getElementById("cancel");
|
||||
const result = document.getElementById("result");
|
||||
const command = (suffix, args) => window.netcattyView.executeCommand(`com.netcatty.database-minimal.${suffix}`, args);
|
||||
window.netcattyView.onDidChangeEnvironment((environment) => {
|
||||
document.documentElement.style.colorScheme = environment.theme === "light" ? "light" : "dark";
|
||||
for (const name of ["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"]) {
|
||||
const value = environment.themeTokens?.[name];
|
||||
if (typeof value === "string" && value.length <= 96) document.documentElement.style.setProperty(name, value);
|
||||
else document.documentElement.style.removeProperty(name);
|
||||
}
|
||||
});
|
||||
void command("endpoint").then((value) => { document.getElementById("endpoint").textContent = value.endpoint; });
|
||||
document.getElementById("form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
query.disabled = true;
|
||||
cancel.disabled = false;
|
||||
result.textContent = "Connecting to the disposable fixture…";
|
||||
const credentials = { username: username.value, password: password.value };
|
||||
password.value = "";
|
||||
try {
|
||||
const response = await command("query", credentials);
|
||||
result.textContent = JSON.stringify(response, null, 2);
|
||||
} catch { result.textContent = "Connection or read failed. Check the fixture, exact endpoint grant, and session credentials."; }
|
||||
finally { credentials.password = ""; query.disabled = false; cancel.disabled = true; }
|
||||
});
|
||||
cancel.addEventListener("click", async () => {
|
||||
cancel.disabled = true;
|
||||
try { await command("cancel"); result.textContent = "Example connection cancelled."; }
|
||||
catch { result.textContent = "Could not cancel the example connection."; }
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildPluginPackage } from "@netcatty/plugin-cli";
|
||||
import { build } from "esbuild";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { canonicalizeStreamEndpoint, isLocalControlAddress } = require("../../../electron/plugins/byteStreamEndpoint.cjs");
|
||||
const { isIP } = require("node:net");
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function prepareDatabaseExample(targetEndpoint, outputPath) {
|
||||
const { endpoint, transport, host } = canonicalizeStreamEndpoint(targetEndpoint);
|
||||
// This preparation tool only targets a disposable private IPv4 fixture.
|
||||
// The generic host capability has no database restriction or protocol logic.
|
||||
if (transport !== "tcp" || isIP(host) !== 4 || isLocalControlAddress(host)
|
||||
|| !/^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/u.test(host)) throw new Error("Use the isolated fixture's private IPv4 TCP endpoint");
|
||||
const temporary = await fs.mkdtemp(path.join(path.dirname(outputPath), ".database-example-"));
|
||||
try {
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(root, "netcatty.plugin.json"), "utf8"));
|
||||
manifest.permissions.optional[0].resources = [endpoint];
|
||||
await fs.writeFile(path.join(temporary, "netcatty.plugin.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
for (const file of ["browser.js", "protocol.mjs", "panel.html", "panel.css", "panel.js"]) await fs.copyFile(path.join(root, file), path.join(temporary, file));
|
||||
await fs.writeFile(path.join(temporary, "fixture-config.mjs"), `export const endpoint = ${JSON.stringify(endpoint)};\n`);
|
||||
const bundle = await build({ entryPoints: [path.join(temporary, "browser.js")], write: false,
|
||||
bundle: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
|
||||
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"));
|
||||
return await buildPluginPackage(temporary, outputPath);
|
||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const [endpoint, outputPath] = process.argv.slice(2);
|
||||
if (!endpoint || !outputPath) throw new Error("Usage: node prepare.mjs tcp://<private-fixture-ip>:3306 <output.ncpkg>");
|
||||
const result = await prepareDatabaseExample(endpoint, path.resolve(outputPath));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Intentionally limited browser example, not a general database driver. Supports
|
||||
// protocol 4.1, mysql_native_password, and exactly one fixed text result column.
|
||||
// The fixture account's server-side SELECT grant enforces access control.
|
||||
export const READ_QUERY = "SELECT value FROM plugin_demo.example_result LIMIT 1";
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const decoder = new globalThis.TextDecoder("utf-8", { fatal: true });
|
||||
const MAX_PACKET = 64 * 1024 - 1;
|
||||
const MAX_TOTAL = 512 * 1024;
|
||||
const CLIENT_FLAGS = 1 | 4 | 8 | 0x200 | 0x2000 | 0x8000 | 0x80000;
|
||||
|
||||
function reject() { throw new Error("Unsupported or invalid example database response"); }
|
||||
function concat(...chunks) {
|
||||
const value = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) { value.set(chunk, offset); offset += chunk.length; }
|
||||
return value;
|
||||
}
|
||||
function zeroText(text) { return concat(encoder.encode(text), Uint8Array.of(0)); }
|
||||
function uint32(value) {
|
||||
const result = new Uint8Array(4);
|
||||
new DataView(result.buffer).setUint32(0, value, true);
|
||||
return result;
|
||||
}
|
||||
|
||||
class Cursor {
|
||||
constructor(bytes) { this.bytes = bytes; this.offset = 0; }
|
||||
take(length) {
|
||||
if (!Number.isInteger(length) || length < 0 || this.offset + length > this.bytes.length) reject();
|
||||
const result = this.bytes.subarray(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return result;
|
||||
}
|
||||
byte() { return this.take(1)[0]; }
|
||||
short() { const bytes = this.take(2); return bytes[0] | (bytes[1] << 8); }
|
||||
text() {
|
||||
const end = this.bytes.indexOf(0, this.offset);
|
||||
if (end === -1) reject();
|
||||
const result = decoder.decode(this.take(end - this.offset));
|
||||
this.byte();
|
||||
return result;
|
||||
}
|
||||
lengthText() {
|
||||
const marker = this.byte();
|
||||
if (marker === 0xfb) return null;
|
||||
const length = marker < 0xfb ? marker : marker === 0xfc ? this.short() : reject();
|
||||
if (length > 4096) reject();
|
||||
return decoder.decode(this.take(length));
|
||||
}
|
||||
}
|
||||
|
||||
class Packets {
|
||||
constructor(stream) { this.stream = stream; this.pending = new Uint8Array(); this.offset = 0; this.total = 0; this.packets = 0; }
|
||||
async exact(length) {
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
if (this.offset === this.pending.length) {
|
||||
this.pending = await this.stream.read();
|
||||
this.offset = 0;
|
||||
if (!(this.pending instanceof Uint8Array) || !this.pending.length || this.pending.length > 64 * 1024) reject();
|
||||
this.total += this.pending.length;
|
||||
if (this.total > MAX_TOTAL) reject();
|
||||
}
|
||||
const count = Math.min(length - offset, this.pending.length - this.offset);
|
||||
result.set(this.pending.subarray(this.offset, this.offset + count), offset);
|
||||
this.offset += count;
|
||||
offset += count;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async read(sequence) {
|
||||
if (++this.packets > 32) reject();
|
||||
const header = await this.exact(4);
|
||||
const length = header[0] | (header[1] << 8) | (header[2] << 16);
|
||||
if (!length || length > MAX_PACKET || header[3] !== sequence) reject();
|
||||
const payload = await this.exact(length);
|
||||
// Never display peer-provided errors, which may echo user input or secrets.
|
||||
if (payload[0] === 0xff) throw new Error("Example database server rejected the request");
|
||||
return payload;
|
||||
}
|
||||
async write(bytes, sequence) {
|
||||
if (bytes.length > MAX_PACKET) reject();
|
||||
await this.stream.write(concat(Uint8Array.of(bytes.length & 255, (bytes.length >>> 8) & 255, 0, sequence), bytes));
|
||||
}
|
||||
}
|
||||
|
||||
async function authentication(password, nonce) {
|
||||
if (!password.length) return new Uint8Array();
|
||||
const digest = async (bytes) => new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", bytes));
|
||||
const passwordBytes = encoder.encode(password);
|
||||
let first;
|
||||
let second;
|
||||
let third;
|
||||
try {
|
||||
first = await digest(passwordBytes);
|
||||
second = await digest(first);
|
||||
third = await digest(concat(nonce, second));
|
||||
return first.map((byte, index) => byte ^ third[index]);
|
||||
} finally {
|
||||
passwordBytes.fill(0);
|
||||
first?.fill(0);
|
||||
second?.fill(0);
|
||||
third?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function greeting(bytes) {
|
||||
const cursor = new Cursor(bytes);
|
||||
if (cursor.byte() !== 10) reject();
|
||||
cursor.text();
|
||||
cursor.take(4);
|
||||
const nonceFirst = cursor.take(8);
|
||||
if (cursor.byte() !== 0) reject();
|
||||
const lower = cursor.short();
|
||||
cursor.byte();
|
||||
cursor.short();
|
||||
const upper = cursor.short();
|
||||
const capabilities = lower | (upper << 16);
|
||||
const authLength = cursor.byte();
|
||||
// MariaDB clears bit zero to identify its extended dialect. This example
|
||||
// sends CLIENT_MYSQL (bit zero) and uses only the common 4.1 protocol.
|
||||
const required = CLIENT_FLAGS & ~1;
|
||||
if ((capabilities & required) !== required || authLength !== 21) reject();
|
||||
cursor.take(10);
|
||||
const nonceSecond = cursor.take(13);
|
||||
if (nonceSecond[12] !== 0 || cursor.text() !== "mysql_native_password") reject();
|
||||
return concat(nonceFirst, nonceSecond.subarray(0, 12));
|
||||
}
|
||||
|
||||
/** stream is a plugin-owned browser adapter over the generic host byte stream. */
|
||||
export async function queryExample(stream, username, sessionPassword) {
|
||||
if (!/^[A-Za-z0-9_]{1,32}$/u.test(username) || typeof sessionPassword !== "string" || sessionPassword.length > 256) {
|
||||
throw new Error("Enter the disposable fixture username and session password");
|
||||
}
|
||||
const packets = new Packets(stream);
|
||||
try {
|
||||
const nonce = greeting(await packets.read(0));
|
||||
const token = await authentication(sessionPassword, nonce);
|
||||
sessionPassword = "";
|
||||
try {
|
||||
await packets.write(concat(uint32(CLIENT_FLAGS), uint32(MAX_PACKET), Uint8Array.of(45), new Uint8Array(23),
|
||||
zeroText(username), Uint8Array.of(token.length), token, zeroText("plugin_demo"), zeroText("mysql_native_password")), 1);
|
||||
} finally { token.fill(0); nonce.fill(0); }
|
||||
if ((await packets.read(2))[0] !== 0) reject();
|
||||
await packets.write(concat(Uint8Array.of(3), encoder.encode(READ_QUERY)), 0);
|
||||
const count = await packets.read(1);
|
||||
if (count.length !== 1 || count[0] !== 1) reject();
|
||||
const column = new Cursor(await packets.read(2));
|
||||
for (let index = 0; index < 4; index++) column.lengthText();
|
||||
if (column.lengthText() !== "value") reject();
|
||||
const eof = await packets.read(3);
|
||||
if (eof[0] !== 0xfe || eof.length >= 9) reject();
|
||||
const row = new Cursor(await packets.read(4));
|
||||
const value = row.lengthText();
|
||||
if (row.offset !== row.bytes.length) reject();
|
||||
const end = await packets.read(5);
|
||||
if (end[0] !== 0xfe || end.length >= 9) reject();
|
||||
return { columns: ["value"], rows: [[value]] };
|
||||
} finally {
|
||||
sessionPassword = "";
|
||||
await stream.close();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
-1
@@ -459,7 +459,7 @@ declare global {
|
||||
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
|
||||
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
|
||||
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
|
||||
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>;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { once } from 'node:events';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { createPluginModelAdapter } from './modelInvocationAdapter';
|
||||
import { createBridgeFetchForSDK } from '../sdk/providers';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { ModelInvocationBroker } = require('../../../electron/plugins/ai/modelInvocationBroker.cjs');
|
||||
const { SelectedContextAuthority } = require('../../../electron/plugins/context/selectedContextAuthority.cjs');
|
||||
|
||||
|
||||
// Local deterministic OpenAI-compatible fixture. No model inference, credentials,
|
||||
// user's Notes, configured endpoints or paid API requests are used by this test.
|
||||
test('broker -> existing model factory -> existing host HTTP transport -> loopback fixture -> stream and usage', async (t) => {
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-ai-fixture-'));
|
||||
const priorTmp = process.env.TMPDIR;
|
||||
process.env.TMPDIR = fixtureRoot;
|
||||
const aiBridge = require('../../../electron/bridges/aiBridge.cjs');
|
||||
t.after(() => {
|
||||
if (priorTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = priorTmp;
|
||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
assert.equal(request.url, '/v1/chat/completions');
|
||||
assert.equal(request.headers.authorization, 'Bearer __IPC_SECURED__');
|
||||
let body = '';
|
||||
for await (const chunk of request) { body += chunk; assert.ok(Buffer.byteLength(body) < 32_768); }
|
||||
const payload = JSON.parse(body);
|
||||
requests.push(payload);
|
||||
assert.equal(payload.model, 'deterministic-fixture');
|
||||
assert.equal(payload.stream, true);
|
||||
assert.equal(payload.tools, undefined);
|
||||
assert.equal(payload.tool_choice, undefined);
|
||||
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' });
|
||||
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 }],
|
||||
})}\n\n`);
|
||||
send({ role: 'assistant' }); send({ content: 'Synthetic plan: ' });
|
||||
if (payload.messages.at(-1).content === 'Stop fixture') {
|
||||
const timer = setTimeout(() => { send({ content: 'late text' }); response.end('data: [DONE]\n\n'); }, 2000);
|
||||
response.once('close', () => clearTimeout(timer));
|
||||
return;
|
||||
}
|
||||
send({ content: 'add retry button.' }); send({}, 'stop');
|
||||
response.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk', created: 1,
|
||||
model: 'deterministic-fixture', choices: [], usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18 } })}\n\n`);
|
||||
response.end('data: [DONE]\n\n');
|
||||
});
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
t.after(() => { server.closeAllConnections(); server.close(); });
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const baseURL = `http://127.0.0.1:${address.port}/v1`;
|
||||
const data = new Map<string, (value: string) => void>();
|
||||
const end = new Map<string, () => void>();
|
||||
const errors = new Map<string, (value: string) => void>();
|
||||
const originalWindow = globalThis.window;
|
||||
const sentBodies: string[] = [];
|
||||
const bridge = {
|
||||
aiFetch: async () => { throw Error('Nonstreaming transport is not expected'); },
|
||||
onAiStreamData: (id: string, callback: (value: string) => void) => { data.set(id, callback); return () => data.delete(id); },
|
||||
onAiStreamEnd: (id: string, callback: () => void) => { end.set(id, callback); return () => end.delete(id); },
|
||||
onAiStreamError: (id: string, callback: (value: string) => void) => { errors.set(id, callback); return () => errors.delete(id); },
|
||||
aiChatCancel: async () => { aiBridge.cleanup(); return true; },
|
||||
aiChatStream: async (id: string, url: string, headers: Record<string, string>, body: string) => {
|
||||
assert.equal(new URL(url).origin, new URL(baseURL).origin);
|
||||
sentBodies.push(body);
|
||||
const result = await aiBridge._streamRequestForTests(url, { headers, body }, { sender: {
|
||||
isDestroyed: () => false,
|
||||
send: (channel: string, payload: { data?: string; error?: string }) => {
|
||||
if (channel.endsWith(':data')) data.get(id)?.(payload.data!);
|
||||
if (channel.endsWith(':end')) end.get(id)?.();
|
||||
if (channel.endsWith(':error')) errors.get(id)?.(payload.error!);
|
||||
},
|
||||
} }, id, false);
|
||||
return { ok: result.statusCode === 200, ...result };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: { netcatty: bridge } });
|
||||
t.after(() => { Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: originalWindow }); aiBridge.cleanup(); });
|
||||
const config: ProviderConfig = { id: 'isolated-fixture', providerId: 'custom', enabled: true, name: 'Fixture',
|
||||
baseURL, defaultModel: 'deterministic-fixture', apiKey: 'synthetic-not-a-paid-api-key' };
|
||||
const model = createPluginModelAdapter({ config, label: 'Local deterministic fixture', isCurrent: () => true });
|
||||
const owner = {};
|
||||
const resolveDataOwner = () => owner;
|
||||
const context = { pluginId: 'example', pluginVersion: '1', runtimeId: 'qa-isolated', assertActive: async () => {} };
|
||||
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, 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.',
|
||||
contexts: [{ handle: note.handle, version: note.version }] }, context);
|
||||
let text = ''; let usage;
|
||||
for (;;) {
|
||||
const event = await broker.read(turn.turnHandle, context);
|
||||
if (event.kind === 'end') break;
|
||||
if (event.kind === 'text') text += event.text;
|
||||
if (event.kind === 'usage') usage = event;
|
||||
}
|
||||
assert.equal(text, 'Synthetic plan: add retry button.');
|
||||
assert.deepEqual(usage, { kind: 'usage', sequence: 2, inputTokens: 12, outputTokens: 6, totalTokens: 18 });
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
assert.doesNotMatch(JSON.stringify({ chat, turn, text, usage, sentBodies }), /synthetic-not-a-paid-api-key/);
|
||||
// Exercise the actual page handlers through its existing command-only view
|
||||
// surface. This is DOM QA, not installed-plugin or native Electron evidence.
|
||||
const dom = new JSDOM(fs.readFileSync(new URL('../../../examples/plugins/assistant/panel.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' });
|
||||
t.after(() => dom.window.close());
|
||||
const commandHandlers: Record<string, (params: Record<string, unknown>) => unknown> = {
|
||||
open: () => broker.open(context), contexts: () => contexts.summaries(context),
|
||||
send: (params) => broker.start(params, context),
|
||||
read: (params) => broker.read(params.turnHandle, context),
|
||||
stop: (params) => broker.stop(params.turnHandle, context),
|
||||
close: (params) => broker.close(params.chatHandle, context),
|
||||
};
|
||||
Object.defineProperty(dom.window, 'netcattyView', { value: {
|
||||
executeCommand: async (id: string, params: Record<string, unknown>) => {
|
||||
const operation = id.replace('com.netcatty.assistant.', '');
|
||||
assert.ok(Object.hasOwn(commandHandlers, operation));
|
||||
return structuredClone(await commandHandlers[operation](structuredClone(params)));
|
||||
},
|
||||
onDidReceiveMessage: () => () => {},
|
||||
} });
|
||||
dom.window.eval(fs.readFileSync(new URL('../../../examples/plugins/assistant/panel.js', import.meta.url), 'utf8'));
|
||||
const waitFor = async (condition: () => boolean) => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!condition()) {
|
||||
if (Date.now() >= deadline) throw Error('Panel state timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
};
|
||||
const element = (id: string) => dom.window.document.getElementById(id)!;
|
||||
await waitFor(() => element('status').textContent === 'Ready');
|
||||
const selection = element('context') as HTMLSelectElement;
|
||||
assert.equal(selection.options.length, 2);
|
||||
assert.equal(selection.value, '');
|
||||
selection.value = note.handle; // Explicit synthetic Note selection in the UI.
|
||||
element('compose').dispatchEvent(new dom.window.Event('submit', { cancelable: true }));
|
||||
await waitFor(() => element('status').textContent === 'Complete');
|
||||
assert.equal(element('response').textContent, 'Synthetic plan: add retry button.');
|
||||
assert.match(element('usage').textContent!, /12 input tokens/);
|
||||
assert.equal(requests.length, 2);
|
||||
(element('prompt') as HTMLTextAreaElement).value = 'Stop fixture';
|
||||
element('compose').dispatchEvent(new dom.window.Event('submit', { cancelable: true }));
|
||||
await waitFor(() => element('response').textContent === 'Synthetic plan: ');
|
||||
element('stop').dispatchEvent(new dom.window.Event('click'));
|
||||
await waitFor(() => element('status').textContent === 'Stopped');
|
||||
assert.equal(broker.turns.size, 0);
|
||||
assert.equal((element('send') as HTMLButtonElement).disabled, false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(element('response').textContent, 'Synthetic plan: ');
|
||||
assert.equal(aiBridge._getActiveStreamCountForTests(), 0);
|
||||
|
||||
});
|
||||
|
||||
test('plugin provider adapter refuses direct-fetch fallback when the host transport disappears', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: {} });
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: originalWindow }));
|
||||
await assert.rejects(createBridgeFetchForSDK('fixture', { requireBridge: true })('http://127.0.0.1:1/never'), /Host AI transport is unavailable/);
|
||||
});
|
||||
|
||||
test('plugin transport byte cap cancels before unbounded SDK buffering', async (t) => {
|
||||
const originalWindow = globalThis.window;
|
||||
let emit: (value: string) => void; let cancelled = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: { netcatty: {
|
||||
onAiStreamData: (_id: string, callback: (value: string) => void) => { emit = callback; return () => {}; },
|
||||
onAiStreamEnd: () => () => {}, onAiStreamError: () => () => {},
|
||||
aiChatCancel: async () => { cancelled++; return true; },
|
||||
aiChatStream: async () => {
|
||||
emit('X'.repeat(33));
|
||||
// Late data after cancellation must be discarded as well.
|
||||
emit('Y'.repeat(100));
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
} } });
|
||||
t.after(() => Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: originalWindow }));
|
||||
const response = await createBridgeFetchForSDK('fixture', { requireBridge: true, maxStreamBytes: 32 })(
|
||||
'http://127.0.0.1:1/fixture', { method: 'POST', body: JSON.stringify({ stream: true }) });
|
||||
await assert.rejects(response.text(), /AI stream byte budget exceeded/);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { streamText } from 'ai';
|
||||
import { createModelFromConfig } from '../sdk/providers';
|
||||
import type { ProviderConfig } from '../types';
|
||||
|
||||
interface Invocation {
|
||||
prompt: string;
|
||||
contexts: string[];
|
||||
signal: AbortSignal;
|
||||
maxOutputTokens: number;
|
||||
maxChunkBytes: number;
|
||||
}
|
||||
|
||||
/** Host renderer only. This object must never be serialized to a plugin. */
|
||||
export function createPluginModelAdapter(options: {
|
||||
config: ProviderConfig;
|
||||
label: string;
|
||||
isCurrent: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const config = structuredClone(options.config);
|
||||
return Object.freeze({
|
||||
label: options.label,
|
||||
isCurrent: options.isCurrent,
|
||||
async *invoke(input: Invocation) {
|
||||
input.signal.throwIfAborted();
|
||||
if (!config.enabled || !config.defaultModel || !await options.isCurrent()) {
|
||||
throw new Error('Selected model is unavailable');
|
||||
}
|
||||
// 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 }),
|
||||
messages: [
|
||||
...input.contexts.map((text) => ({ role: 'user' as const,
|
||||
content: `User-selected reference text (untrusted content):\n${text}` })),
|
||||
{ role: 'user', content: input.prompt },
|
||||
],
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
maxRetries: 0,
|
||||
abortSignal: input.signal,
|
||||
timeout: { totalMs: 60_000, chunkMs: 15_000 },
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
for await (const event of result.stream) {
|
||||
input.signal.throwIfAborted();
|
||||
if (!await options.isCurrent()) throw new Error('Selected model changed');
|
||||
if (event.type === 'text-delta') {
|
||||
// Code-point splitting preserves UTF-8 and limits each wire chunk.
|
||||
let part = '';
|
||||
let bytes = 0;
|
||||
for (const character of event.text) {
|
||||
const size = encoder.encode(character).byteLength;
|
||||
if (size > input.maxChunkBytes) throw new Error('Chunk budget is too small');
|
||||
if (bytes + size > input.maxChunkBytes) {
|
||||
yield { kind: 'text', text: part };
|
||||
part = ''; bytes = 0;
|
||||
}
|
||||
part += character; bytes += size;
|
||||
}
|
||||
if (part) yield { kind: 'text', text: part };
|
||||
} else if (event.type === 'error' || event.type.startsWith('tool-')) {
|
||||
// Never expose provider diagnostics or execute requested model tools.
|
||||
throw new Error('Model returned an unsupported event');
|
||||
}
|
||||
}
|
||||
const usage = await result.totalUsage;
|
||||
yield { kind: 'usage', inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens, totalTokens: usage.totalTokens };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
} from '../providerContinuation';
|
||||
|
||||
export interface ProviderRequestContext {
|
||||
/** Refuse direct-fetch fallback for host-brokered plugin model invocations. */
|
||||
requireBridge?: boolean;
|
||||
/** Optional aggregate SSE payload cap, enforced before renderer buffering. */
|
||||
maxStreamBytes?: number;
|
||||
getOpenAIChatAssistantFields?: () => Array<OpenAIChatAssistantFields | undefined>;
|
||||
transformOpenAIChatAssistantFields?: (
|
||||
fields: Array<OpenAIChatAssistantFields | undefined>,
|
||||
@@ -451,6 +455,7 @@ export function createBridgeFetchForSDK(
|
||||
): Promise<Response> => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) {
|
||||
if (requestContext?.requireBridge) throw new Error('Host AI transport is unavailable');
|
||||
return globalThis.fetch(input, init);
|
||||
}
|
||||
|
||||
@@ -498,6 +503,7 @@ export function createBridgeFetchForSDK(
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const pendingChunks: Uint8Array[] = [];
|
||||
let receivedBytes = 0;
|
||||
let pendingClose = false;
|
||||
let pendingError: Error | null = null;
|
||||
let cleanedUp = false;
|
||||
@@ -537,6 +543,14 @@ export function createBridgeFetchForSDK(
|
||||
};
|
||||
|
||||
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
|
||||
if (cleanedUp) return;
|
||||
receivedBytes += encoder.encode(data).byteLength;
|
||||
if (requestContext?.maxStreamBytes !== undefined && receivedBytes > requestContext.maxStreamBytes) {
|
||||
errorStream(new Error('AI stream byte budget exceeded'));
|
||||
cleanup();
|
||||
bridge.aiChatCancel(requestId).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const normalizedData = normalizeOpenAIChatCacheUsage(normalizeOpenAIChatToolCalls(data));
|
||||
captureOpenAIChatFields(normalizedData);
|
||||
// Re-wrap as SSE so the SDK can parse it
|
||||
|
||||
Generated
+8
@@ -132,6 +132,10 @@
|
||||
"@vscode/windows-process-tree": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"examples/plugins/assistant": {
|
||||
"name": "@netcatty/example-assistant-plugin",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"examples/plugins/hello-netcatty": {
|
||||
"name": "@netcatty/example-hello-plugin",
|
||||
"version": "0.1.0",
|
||||
@@ -4674,6 +4678,10 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@netcatty/example-assistant-plugin": {
|
||||
"resolved": "examples/plugins/assistant",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@netcatty/example-hello-plugin": {
|
||||
"resolved": "examples/plugins/hello-netcatty",
|
||||
"link": true
|
||||
|
||||
@@ -369,6 +369,9 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
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");
|
||||
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
@@ -333,6 +333,7 @@ test("required resource-scoped permissions declare activation-time bounds", () =
|
||||
const validate = validator("PluginManifest");
|
||||
const resourceScoped = [
|
||||
["network", "https://example.com"],
|
||||
["network.stream", "tls://example.com:443"],
|
||||
["filesystem.read", "/tmp/plugin-read"],
|
||||
["filesystem.write", "/tmp/plugin-write"],
|
||||
["companion.execute", "com.example.contract-test.helper"],
|
||||
@@ -365,6 +366,10 @@ test("required resource-scoped permissions declare activation-time bounds", () =
|
||||
assert.equal(validate({
|
||||
...validManifest,
|
||||
permissions: { optional: [permission] },
|
||||
}), permission !== "network.stream", `${permission}: ${JSON.stringify(validate.errors)}`);
|
||||
assert.equal(validate({
|
||||
...validManifest,
|
||||
permissions: { optional: [{ permission, resources: [resource] }] },
|
||||
}), true, JSON.stringify(validate.errors));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PLUGIN_JSON_MAX_NODES = 100000 as const;
|
||||
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
|
||||
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
|
||||
export const PLUGIN_HOST_INTERACTION_LIMITS = {"textBytes":4096,"requestBytes":65536,"items":100,"pendingPerRuntime":1,"pendingTotal":8,"requestsPerMinute":10,"timeoutMs":60000} as const;
|
||||
export const PLUGIN_BYTE_STREAM_LIMITS = {"connectionsPerRuntime":4,"connectionsTotal":64,"chunkBytes":65536,"bufferBytes":65536,"connectionBytes":8388608,"connectMs":10000,"idleMs":30000,"lifetimeMs":120000} as const;
|
||||
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
|
||||
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
|
||||
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
|
||||
|
||||
@@ -73,6 +73,32 @@ export type AuthenticationResult = ({
|
||||
|
||||
export type BoundedPermissionResource = string;
|
||||
|
||||
export type ByteStreamAttachResult = {
|
||||
attached: true;
|
||||
};
|
||||
|
||||
export type ByteStreamConnectionDescriptor = {
|
||||
handleId: string;
|
||||
endpoint: ByteStreamEndpoint;
|
||||
inputStreamId: StreamId;
|
||||
outputStreamId: StreamId;
|
||||
windowBytes: 65536;
|
||||
};
|
||||
|
||||
export type ByteStreamConnectRequest = {
|
||||
endpoint: ByteStreamEndpoint;
|
||||
};
|
||||
|
||||
export type ByteStreamEndpoint = string;
|
||||
|
||||
export type ByteStreamFeature = "network.byte-streams";
|
||||
|
||||
export type ByteStreamHandleRequest = {
|
||||
handleId: string;
|
||||
};
|
||||
|
||||
export type ByteStreamLimits = {"connectionsPerRuntime":4,"connectionsTotal":64,"chunkBytes":65536,"bufferBytes":65536,"connectionBytes":8388608,"connectMs":10000,"idleMs":30000,"lifetimeMs":120000};
|
||||
|
||||
export type CommandContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
@@ -202,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 = ({
|
||||
@@ -221,7 +256,7 @@ export type HostInteractionResult = ({
|
||||
}) | ({
|
||||
cancelled: false;
|
||||
value: (string) | (true);
|
||||
});
|
||||
}) | (VaultHostSelectedResult) | (VaultHostActionAccepted);
|
||||
|
||||
export type HostQuickPickItem = {
|
||||
id: string;
|
||||
@@ -237,6 +272,8 @@ export type HostQuickPickRequest = {
|
||||
|
||||
export type HostSingleLineInputText = string;
|
||||
|
||||
export type HostVaultFeature = "vault.host-actions";
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
@@ -431,7 +468,7 @@ export type MenuLocation = "commandPalette" | "application" | "host/context" | "
|
||||
|
||||
export type NetworkCredentialLeaseFeature = "network.credential-lease";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type NonResourceScopedPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
@@ -451,7 +488,7 @@ export type PermissionDecision = ({
|
||||
decision: "deny" | "cancel";
|
||||
});
|
||||
|
||||
export type PermissionDeclaration = (PluginPermission) | (PermissionResourceDeclaration);
|
||||
export type PermissionDeclaration = Exclude<PluginPermission, "network.stream"> | PermissionResourceDeclaration;
|
||||
|
||||
export type PermissionGrantScope = "once" | "session" | "application" | "always";
|
||||
|
||||
@@ -487,6 +524,57 @@ export type PermissionSet = {
|
||||
optional?: Array<PermissionDeclaration>;
|
||||
};
|
||||
|
||||
export type PluginAIBooleanResult = {
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
export type PluginAIChatRef = {
|
||||
chatHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAIChatResult = {
|
||||
chatHandle: string;
|
||||
model: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginAIContextLimits = {"contexts":4,"inputBytes":16384,"chunkBytes":4096,"outputBytes":32768,"outputTokens":2048,"turnMs":60000};
|
||||
|
||||
export type PluginAIEmptyParams = Record<string, never>;
|
||||
|
||||
export type PluginAIModelInvocationFeature = "ai.model-invocation";
|
||||
|
||||
export type PluginAIStartTurnParams = {
|
||||
chatHandle: string;
|
||||
prompt: string;
|
||||
contexts?: Array<PluginSelectedContextRef>;
|
||||
};
|
||||
|
||||
export type PluginAIStreamEvent = ({
|
||||
kind: "text";
|
||||
sequence: number;
|
||||
text: string;
|
||||
}) | ({
|
||||
kind: "usage";
|
||||
sequence: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
}) | ({
|
||||
kind: "end";
|
||||
sequence: number;
|
||||
});
|
||||
|
||||
export type PluginAITurnRef = {
|
||||
turnHandle: string;
|
||||
};
|
||||
|
||||
export type PluginAITurnResult = {
|
||||
turnHandle: string;
|
||||
maxOutputTokens: 2048;
|
||||
reservedTokens: number;
|
||||
};
|
||||
|
||||
export type PluginContributions = {
|
||||
themes?: Array<ThemePackContribution>;
|
||||
settings?: Array<SettingContribution>;
|
||||
@@ -587,7 +675,35 @@ export type PluginNetworkResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
export type PluginPermission = "storage" | "ai.invoke" | "context.selected.list" | "context.selected.read" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type PluginSelectedContextContent = {
|
||||
handle: string;
|
||||
scope: "workspace" | "tab" | "session" | "host" | "note";
|
||||
title: string;
|
||||
version: string;
|
||||
expiresAt: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type PluginSelectedContextFeature = "context.selected-read";
|
||||
|
||||
export type PluginSelectedContextListResult = {
|
||||
items: Array<PluginSelectedContextSummary>;
|
||||
};
|
||||
|
||||
export type PluginSelectedContextRef = {
|
||||
handle: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type PluginSelectedContextSummary = {
|
||||
handle: string;
|
||||
scope: "workspace" | "tab" | "session" | "host" | "note";
|
||||
title: string;
|
||||
version: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
@@ -670,7 +786,7 @@ export type RequiredPermissionResourceDeclaration = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ResourceScopedPermission = "network" | "filesystem.read" | "filesystem.write" | "companion.execute";
|
||||
export type ResourceScopedPermission = "network" | "network.stream" | "filesystem.read" | "filesystem.write" | "companion.execute";
|
||||
|
||||
export type RpcCancel = {
|
||||
jsonrpc: "2.0";
|
||||
@@ -1130,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;
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
} from "./jsonValue.js";
|
||||
export {
|
||||
PLUGIN_HOST_INTERACTION_LIMITS,
|
||||
PLUGIN_BYTE_STREAM_LIMITS,
|
||||
PLUGIN_IMPORTER_MAX_INPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_OUTPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_RECORD_BYTES,
|
||||
@@ -52,8 +53,12 @@ export {
|
||||
export { inspectPluginBrandIcon, PLUGIN_BRAND_ICON_FEATURE, PLUGIN_BRAND_ICON_MAX_BYTES, PLUGIN_BRAND_ICON_MAX_EDGE } from './brandIcon.js';
|
||||
|
||||
export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease" as const satisfies import("./generated/plugin-contract.js").NetworkCredentialLeaseFeature;
|
||||
export const PLUGIN_BYTE_STREAM_FEATURE = "network.byte-streams" as const satisfies import("./generated/plugin-contract.js").ByteStreamFeature;
|
||||
export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const satisfies import("./generated/plugin-contract.js").PluginAIModelInvocationFeature;
|
||||
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;
|
||||
|
||||
@@ -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>;
|
||||
@@ -655,6 +664,28 @@ export type { PluginNetworkAuthorization, PluginNetworkRequest, PluginNetworkRes
|
||||
|
||||
export interface PluginNetworkClient {
|
||||
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
|
||||
connect(request: import("@netcatty/plugin-contract").ByteStreamConnectRequest, cancellationToken?: CancellationToken): Promise<PluginByteConnection>;
|
||||
}
|
||||
|
||||
export interface PluginAIClient {
|
||||
openChat(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginAIChatResult>;
|
||||
startTurn(params: import("@netcatty/plugin-contract").PluginAIStartTurnParams): Promise<import("@netcatty/plugin-contract").PluginAITurnResult>;
|
||||
readTurn(params: import("@netcatty/plugin-contract").PluginAITurnRef): Promise<import("@netcatty/plugin-contract").PluginAIStreamEvent>;
|
||||
stopTurn(params: import("@netcatty/plugin-contract").PluginAITurnRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
closeChat(params: import("@netcatty/plugin-contract").PluginAIChatRef): Promise<import("@netcatty/plugin-contract").PluginAIBooleanResult>;
|
||||
}
|
||||
|
||||
export interface PluginSelectedContextClient {
|
||||
list(params: import("@netcatty/plugin-contract").PluginAIEmptyParams): Promise<import("@netcatty/plugin-contract").PluginSelectedContextListResult>;
|
||||
read(params: import("@netcatty/plugin-contract").PluginSelectedContextRef): Promise<import("@netcatty/plugin-contract").PluginSelectedContextContent>;
|
||||
}
|
||||
|
||||
export interface PluginByteConnection extends Disposable {
|
||||
readonly endpoint: string;
|
||||
readonly readable: PluginReadableByteStream;
|
||||
readonly writable: PluginWritableByteStream;
|
||||
/** Closes both directions. Repeated calls on this handle are harmless. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginFilesystemEntry {
|
||||
@@ -737,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;
|
||||
@@ -746,6 +778,8 @@ export interface PluginContext {
|
||||
readonly secrets: PluginSecretStore;
|
||||
readonly credentials: PluginCredentialBroker;
|
||||
readonly network: PluginNetworkClient;
|
||||
readonly ai: PluginAIClient;
|
||||
readonly selectedContext: PluginSelectedContextClient;
|
||||
readonly filesystem: PluginFilesystemClient;
|
||||
readonly companions: PluginCompanionService;
|
||||
readonly streams: PluginStreams;
|
||||
@@ -928,8 +962,11 @@ export function throwIfCancellationRequested(token: CancellationToken): void {
|
||||
}
|
||||
|
||||
export { PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_BYTE_STREAM_FEATURE, PLUGIN_BYTE_STREAM_LIMITS } from "@netcatty/plugin-contract";
|
||||
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";
|
||||
|
||||
@@ -22,6 +22,7 @@ const electronBundlePath = path.join(
|
||||
);
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
const typescriptTypeOverrides = new Map([
|
||||
["PermissionDeclaration", 'Exclude<PluginPermission, "network.stream"> | PermissionResourceDeclaration'],
|
||||
[
|
||||
"ActivationEvent",
|
||||
'"onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`',
|
||||
@@ -306,6 +307,10 @@ function schemaTypeToTs(node, level = 0) {
|
||||
}
|
||||
|
||||
const hostInteractionLimits = schema.$defs.HostInteractionLimits?.const;
|
||||
const byteStreamLimits = schema.$defs.ByteStreamLimits?.const;
|
||||
if (!byteStreamLimits || Object.values(byteStreamLimits).some((value) => !Number.isSafeInteger(value) || value < 1)) {
|
||||
throw new Error("ByteStreamLimits must contain positive bounded integers");
|
||||
}
|
||||
if (!hostInteractionLimits || Object.values(hostInteractionLimits).some((value) => !Number.isSafeInteger(value) || value < 1)) {
|
||||
throw new Error("HostInteractionLimits must contain positive bounded integers");
|
||||
}
|
||||
@@ -333,6 +338,7 @@ const generatedLimits = [
|
||||
`export const PLUGIN_WIRE_MAX_SAFE_INTEGER = ${wireIntegerLimits.maxSafeInteger} as const;`,
|
||||
`export const PLUGIN_RPC_MAX_JSON_BYTES = ${rpcLimits.maxJsonBytes} as const;`,
|
||||
`export const PLUGIN_HOST_INTERACTION_LIMITS = ${JSON.stringify(hostInteractionLimits)} as const;`,
|
||||
`export const PLUGIN_BYTE_STREAM_LIMITS = ${JSON.stringify(byteStreamLimits)} as const;`,
|
||||
`export const PLUGIN_RPC_ERROR_CODES = ${JSON.stringify(rpcErrorCodes)} as const;`,
|
||||
`export const PLUGIN_STREAM_MAX_ID_LENGTH = ${streamLimits.maxStreamIdLength} as const;`,
|
||||
`export const PLUGIN_STREAM_MAX_CHUNK_BYTES = ${streamLimits.maxChunkBytes} as const;`,
|
||||
|
||||
Reference in new issue
Block a user