mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1410f8bb1 |
No files matched your search
@@ -13,7 +13,6 @@ import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
|
||||
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
|
||||
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
|
||||
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -93,7 +92,6 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
<PluginHostActionHost />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1380,20 +1380,5 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||
'plugins.authentication.continue': 'Continue',
|
||||
'plugins.hostActions.select': 'Share selected host metadata',
|
||||
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
|
||||
'plugins.hostActions.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
'plugins.hostActions.fieldDiff': 'Changes to save',
|
||||
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
|
||||
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
|
||||
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
|
||||
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
|
||||
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
|
||||
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
|
||||
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
|
||||
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
|
||||
|
||||
};
|
||||
@@ -1380,20 +1380,5 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
|
||||
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
|
||||
'plugins.hostActions.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
|
||||
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
|
||||
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
|
||||
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
|
||||
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
|
||||
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
|
||||
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
|
||||
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
|
||||
|
||||
};
|
||||
@@ -1275,19 +1275,4 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
|
||||
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
|
||||
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
|
||||
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
|
||||
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
|
||||
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
|
||||
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
|
||||
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
|
||||
};
|
||||
@@ -1295,20 +1295,5 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
'plugins.hostActions.fieldDiff': '即将保存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
|
||||
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
|
||||
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
|
||||
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
|
||||
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
|
||||
|
||||
};
|
||||
@@ -1298,19 +1298,4 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
'plugins.hostActions.fieldDiff': '即將儲存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
|
||||
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
|
||||
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
|
||||
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
|
||||
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
|
||||
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
|
||||
|
||||
function fixture() {
|
||||
const resources = new PluginHostActionResources();
|
||||
let active = true, authorized = 0, connections = 0, writes = 0;
|
||||
let beforeRead: (() => void) | undefined;
|
||||
let beforeCommit: (() => void) | undefined;
|
||||
let duringAuthorize: (() => void) | undefined;
|
||||
let storedSources: ManagedSource[] | undefined;
|
||||
const vault: PluginHostActionVault = {
|
||||
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
|
||||
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
|
||||
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
|
||||
const nextSources = sources(storedSources ?? vault.managedSources);
|
||||
beforeCommit?.();
|
||||
await authorize?.();
|
||||
writes++; vault.hosts = next;
|
||||
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
resources.observe(vault.hosts);
|
||||
const target = resources.metadata(synthetic.id);
|
||||
const options = {
|
||||
getVault: () => vault, resources,
|
||||
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
|
||||
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
|
||||
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
|
||||
};
|
||||
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
|
||||
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
|
||||
storedSources(value: ManagedSource[]) { storedSources = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
|
||||
});
|
||||
|
||||
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].password, synthetic.password);
|
||||
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
|
||||
assert.equal(f.vault.hosts[0].label, 'Reviewed');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
|
||||
const f = fixture(); f[point](() => f.cancel());
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
|
||||
const f = fixture();
|
||||
f.beforeRead(() => {
|
||||
if (change === 'remove') f.vault.hosts = [];
|
||||
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
|
||||
if (change === 'scripts') f.vault.snippets = [];
|
||||
if (change === 'locked') f.cancel();
|
||||
});
|
||||
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
|
||||
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
test('create produces a saved SSH draft without executable or credential injection', async () => {
|
||||
const f = fixture();
|
||||
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
|
||||
assert.equal(await executePluginHostAction(action, f.options), 'saved');
|
||||
const created = f.vault.hosts[1];
|
||||
assert.notEqual(created.id, synthetic.id);
|
||||
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
|
||||
});
|
||||
|
||||
const managedSources: ManagedSource[] = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
|
||||
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
|
||||
const f = fixture();
|
||||
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
|
||||
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
|
||||
f.vault.readPersistedHosts = async () => [structuredClone(host)];
|
||||
f.options.resources.observe(f.vault.hosts);
|
||||
const target = f.options.resources.metadata(host.id);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
|
||||
});
|
||||
|
||||
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
const action: PluginHostAction = kind === 'create'
|
||||
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
|
||||
: { kind, target: f.target, draft: { group: 'B/Child' } };
|
||||
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
|
||||
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
|
||||
assert.equal(f.vault.managedSources, managedSources);
|
||||
});
|
||||
|
||||
test('a managed-source catalog replacement during preparation prevents the write', async () => {
|
||||
const f = fixture();
|
||||
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
|
||||
const f = fixture(); f.storedSources(managedSources);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
|
||||
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
|
||||
import type { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { AppVaultRuntime } from './appRuntimeBridge';
|
||||
|
||||
// Application action model. Public RPC types live only in plugin-contract.
|
||||
export type PluginHostAction =
|
||||
| { kind: 'create'; draft: PluginHostDraftFields }
|
||||
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
|
||||
| { kind: 'connect'; target: { resourceId: string; revision: string } };
|
||||
|
||||
export interface PluginHostActionVault {
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
isInitialized: boolean;
|
||||
readPersistedHosts: () => Promise<Host[]>;
|
||||
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
|
||||
}
|
||||
|
||||
export interface PluginHostActionExecution {
|
||||
getVault: () => PluginHostActionVault;
|
||||
resources: PluginHostActionResources;
|
||||
assertCurrent: () => void;
|
||||
authorizeCommit: () => Promise<void>;
|
||||
connect: (host: Host) => unknown;
|
||||
}
|
||||
|
||||
const sameHost = (left: Host, right: Host) => (
|
||||
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
|
||||
);
|
||||
|
||||
/** Execute only a host-reviewed action. authorizeCommit claims separate,
|
||||
* one-use main-process action authority after the final asynchronous work. */
|
||||
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
|
||||
const current = () => {
|
||||
options.assertCurrent();
|
||||
const vault = options.getVault();
|
||||
if (!vault.isInitialized) throw new Error('Vault is unavailable');
|
||||
options.resources.observe(vault.hosts);
|
||||
return vault;
|
||||
};
|
||||
const initialVault = current();
|
||||
const reviewedGroups = initialVault.groupConfigs;
|
||||
const reviewedSnippets = initialVault.snippets;
|
||||
const reviewedSources = initialVault.managedSources;
|
||||
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
|
||||
const assertSelected = () => {
|
||||
const vault = current();
|
||||
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
|
||||
throw new Error('Selected host changed');
|
||||
}
|
||||
// A connection preview includes inherited and global automation. Any
|
||||
// settings change while the preview is open requires fresh confirmation.
|
||||
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
|
||||
return vault;
|
||||
};
|
||||
const persisted = await initialVault.readPersistedHosts();
|
||||
assertSelected();
|
||||
if (selected) {
|
||||
const persistedHost = persisted.find((host) => host.id === selected.id);
|
||||
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
|
||||
}
|
||||
if (action.kind === 'connect') {
|
||||
await options.authorizeCommit();
|
||||
assertSelected();
|
||||
// The existing host connection handler owns authentication, saved scripts,
|
||||
// and sessions. Its return value never reaches the sandboxed plugin.
|
||||
options.connect(selected!);
|
||||
return 'requested';
|
||||
}
|
||||
const nextHost = action.kind === 'create'
|
||||
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
|
||||
: applyPluginHostEdit(selected!, action.draft);
|
||||
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
|
||||
throw new Error('Managed SSH config changes require the Vault host editor');
|
||||
}
|
||||
const nextHosts = action.kind === 'create'
|
||||
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
|
||||
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
|
||||
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
|
||||
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
|
||||
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
|
||||
const keepSources = (sources: ManagedSource[]) => {
|
||||
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
|
||||
return sources;
|
||||
};
|
||||
const result = await initialVault.commitVaultImportTransaction(
|
||||
nextHosts, updateGroups, keepSources,
|
||||
undefined, persisted, undefined,
|
||||
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
|
||||
);
|
||||
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
|
||||
return 'saved';
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
|
||||
|
||||
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const metadata = resources.metadata(first.id);
|
||||
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
|
||||
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
|
||||
resources.observe([first]);
|
||||
assert.equal(resources.resolve(first.id, metadata.revision), first);
|
||||
resources.observe([{ ...first }]);
|
||||
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
|
||||
const replaced = resources.metadata(first.id);
|
||||
resources.observe([]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
resources.observe([first]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
});
|
||||
|
||||
test('group choices are explicit current members and never expand to later members', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const selection = [resources.metadata(first.id)];
|
||||
resources.observe([first, { ...first, id: 'synthetic-2' }]);
|
||||
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
|
||||
assert.equal(resources.resolve(first.id, selection[0].revision), first);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
/** Renderer-local revisions track replacement as well as visible edits. They
|
||||
* are never authorization handles; only the main-process authority issues one. */
|
||||
export class PluginHostActionResources {
|
||||
private entries = new Map<string, { host: Host; revision: string }>();
|
||||
|
||||
observe(hosts: readonly Host[]) {
|
||||
const next = new Map<string, { host: Host; revision: string }>();
|
||||
for (const host of hosts) {
|
||||
const existing = this.entries.get(host.id);
|
||||
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
resolve(id: string, expectedRevision: string): Host {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
metadata(id: string) {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error('Selected host is unavailable');
|
||||
const host = entry.host;
|
||||
return {
|
||||
resourceId: host.id, revision: entry.revision,
|
||||
label: host.label, hostname: host.hostname, username: host.username,
|
||||
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
|
||||
group: host.group ?? '', tags: [...host.tags],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppHandlers } from '../app/appHandlersBridge';
|
||||
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
|
||||
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
|
||||
|
||||
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
|
||||
type Review = {
|
||||
prompt: VaultHostPrompt;
|
||||
candidates: Host[];
|
||||
candidateRevisions: Map<string, string>;
|
||||
customGroups: string[];
|
||||
host?: Host;
|
||||
groups: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
scriptsRun: boolean;
|
||||
claimed: boolean;
|
||||
completionToken?: string;
|
||||
};
|
||||
|
||||
export function usePluginHostActions() {
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const current = useRef<Review | null>(null);
|
||||
const resources = useRef(new PluginHostActionResources());
|
||||
const responding = useRef(false);
|
||||
|
||||
const getVault = useCallback(() => {
|
||||
const vault = getAppVaultRuntime();
|
||||
const lock = getAppAppLockRuntime();
|
||||
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
|
||||
const actionState = vault.readCurrentHostActionState();
|
||||
resources.current.observe(actionState.hosts);
|
||||
return { ...vault, ...actionState };
|
||||
}, []);
|
||||
|
||||
const assertCurrent = useCallback((entry: Review) => {
|
||||
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
|
||||
const vault = getVault();
|
||||
const target = entry.prompt.vaultTarget;
|
||||
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
|
||||
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
|
||||
}, [getVault]);
|
||||
|
||||
const fail = useCallback(async (entry: Review) => {
|
||||
if (current.current !== entry) return;
|
||||
setError(true);
|
||||
try {
|
||||
await netcattyBridge.get()?.respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
|
||||
});
|
||||
} catch { /* A stopped runtime or closed window already invalidates it. */ }
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (entry: Review) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.respondPluginInteraction) return fail(entry);
|
||||
setBusy(true);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const target = entry.prompt.vaultTarget;
|
||||
let action: PluginHostAction;
|
||||
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
|
||||
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
|
||||
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
|
||||
} else throw new Error('Host action is invalid');
|
||||
await executePluginHostAction(action, {
|
||||
getVault, resources: resources.current,
|
||||
assertCurrent: () => assertCurrent(entry),
|
||||
authorizeCommit: async () => {
|
||||
assertCurrent(entry);
|
||||
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
|
||||
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
|
||||
entry.completionToken = receipt.completionToken;
|
||||
entry.claimed = true;
|
||||
assertCurrent(entry);
|
||||
},
|
||||
connect: (host) => {
|
||||
const connect = getAppHandlers()?.handleConnectToHost;
|
||||
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
|
||||
return connect(host);
|
||||
},
|
||||
});
|
||||
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
|
||||
} catch { await fail(entry); }
|
||||
}, [assertCurrent, fail, getVault]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.prompt.requestId !== event.requestId) return;
|
||||
current.current = null; setReview(null); setBusy(false); responding.current = false;
|
||||
return;
|
||||
}
|
||||
if (!event.request.kind.startsWith('vault.')) return;
|
||||
const prompt = event as VaultHostPrompt;
|
||||
try {
|
||||
if (prompt.phase === 'commit') {
|
||||
const entry = current.current;
|
||||
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
|
||||
entry.prompt = prompt;
|
||||
setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
const vault = getVault();
|
||||
const candidates = [...vault.hosts];
|
||||
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
|
||||
const entry: Review = {
|
||||
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
|
||||
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
|
||||
managedSources: vault.managedSources,
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: false,
|
||||
};
|
||||
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
} catch {
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
const entry = current.current;
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
|
||||
responding.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
|
||||
const revision = entry.candidateRevisions.get(id);
|
||||
if (!revision) throw new Error('Host was not part of the current selection');
|
||||
resources.current.resolve(id, revision);
|
||||
return resources.current.metadata(id);
|
||||
}) : undefined;
|
||||
await netcattyBridge.require().respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
|
||||
...(selected === undefined ? {} : { resources: selected }),
|
||||
...(draft === undefined ? {} : { draft }),
|
||||
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
|
||||
});
|
||||
} catch { await fail(entry); }
|
||||
finally {
|
||||
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
|
||||
}
|
||||
}, [assertCurrent, fail, selectedIds]);
|
||||
|
||||
return { review, selectedIds, setSelectedIds, busy, error, respond };
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
|
||||
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
|
||||
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<UiPrompt | null>(null);
|
||||
const current = useRef<HostInteractionPrompt | null>(null);
|
||||
const pending = useRef(false);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -18,8 +17,7 @@ export function usePluginHostInteractions() {
|
||||
if (current.current?.requestId !== event.requestId) return;
|
||||
current.current = null; setPrompt(null);
|
||||
} else {
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
|
||||
current.current = event; setPrompt(event);
|
||||
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
|
||||
}
|
||||
pending.current = false; setBusy(false); setError(false);
|
||||
|
||||
@@ -776,13 +776,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
// Host-owned action guards need the synchronous mutation snapshot, including
|
||||
// edits that React has not published yet. This accessor never crosses RPC.
|
||||
const readCurrentHostActionState = useCallback(() => ({
|
||||
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
|
||||
managedSources: managedSourcesRef.current,
|
||||
}), []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKeys(cleaned);
|
||||
@@ -1255,7 +1248,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
lock?: VaultLockHandle | null,
|
||||
assertAuthorized?: () => void | Promise<void>,
|
||||
): Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
@@ -1330,10 +1322,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
encryptHosts(cleanedHosts),
|
||||
encryptGroupConfigs(nextGroupConfigs),
|
||||
]);
|
||||
// Optional plugin draft guard: runtime/source/revoke/lock authority must
|
||||
// still hold after encryption, immediately before the existing atomic
|
||||
// persistence boundary. Ordinary imports retain their existing behavior.
|
||||
if (assertAuthorized) await assertAuthorized();
|
||||
if (
|
||||
version !== hostsWriteVersion.current
|
||||
|| groupConfigsVersion !== groupConfigsWriteVersion.current
|
||||
@@ -2531,7 +2519,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
readPersistedHosts,
|
||||
readCurrentHostActionState,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, cp, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLButtonElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
|
||||
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
|
||||
const runtime = await import('../application/state/appRuntimeBridge');
|
||||
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const prompts: HostInteractionPrompt[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
let locked = false;
|
||||
let onLock: ((state: { locked: boolean }) => void) | undefined;
|
||||
const service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
|
||||
getInteractionWindow: () => owner, isAppLocked: () => locked,
|
||||
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
|
||||
});
|
||||
let requestClock = Date.now();
|
||||
service.interactionService.clock = () => requestClock;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
|
||||
} });
|
||||
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
|
||||
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
|
||||
let hosts: Host[] = [first, second];
|
||||
let persisted = hosts.map((host) => structuredClone(host));
|
||||
let beforeCommit: (() => Promise<void>) | undefined;
|
||||
let saves = 0;
|
||||
const connections: Host[] = [];
|
||||
const vault = {
|
||||
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
|
||||
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
|
||||
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
|
||||
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
|
||||
const nextSources = sources(vault.managedSources);
|
||||
await beforeCommit?.();
|
||||
await authorize();
|
||||
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
|
||||
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
const publish = () => {
|
||||
runtime.registerAppVaultRuntime(vault as never);
|
||||
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
|
||||
};
|
||||
publish();
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const waitFor = async (predicate: () => boolean) => {
|
||||
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
|
||||
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
|
||||
};
|
||||
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
|
||||
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
|
||||
const begin = async (name: string) => {
|
||||
requestClock += 60_001;
|
||||
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
|
||||
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
|
||||
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
|
||||
return { promise };
|
||||
};
|
||||
const chooseGroup = async () => {
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
};
|
||||
try {
|
||||
await service.manager.initialize();
|
||||
const packageRoot = path.join(directory, 'source');
|
||||
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(directory, 'fixture.ncpkg');
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
service.interactionService.setReady(sender, true);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
|
||||
|
||||
let selectedTarget: VaultHostRef;
|
||||
await t.test('group selection shares only explicit current members and grants no connection', async () => {
|
||||
const run = await begin('select');
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
|
||||
persisted = hosts.map((host) => structuredClone(host));
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
const result = await run.promise;
|
||||
assert.ok('hosts' in result);
|
||||
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
|
||||
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
|
||||
assert.equal(result.hosts[0].target.scope, 'metadata');
|
||||
selectedTarget = result.hosts[0].target;
|
||||
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
|
||||
assert.equal(connections.length, 0); assert.equal(saves, 0);
|
||||
});
|
||||
|
||||
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
|
||||
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
|
||||
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
|
||||
const transport = { signal: new AbortController().signal };
|
||||
for (const target of [
|
||||
{ handle: 'text-only', version: 'v1', scope: 'host' },
|
||||
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
|
||||
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
|
||||
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('cancel creates no draft or connection', async () => {
|
||||
const run = await begin('create'); await click(button('Cancel'));
|
||||
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
|
||||
const run = await begin('create');
|
||||
assert.match(document.body.textContent ?? '', /Changes to save/);
|
||||
await click(button('Save Host'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
|
||||
assert.equal(saves, 1);
|
||||
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
|
||||
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
|
||||
hosts = change === 'remove' ? [second] : [{ ...first }, second];
|
||||
await click(button('Save Host'));
|
||||
await assert.rejects(run.promise, /changed|cancelled|complete/);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
|
||||
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(connections.length, 1); assert.equal(connections[0], first);
|
||||
});
|
||||
|
||||
await t.test('Vault lock cancels a pending connection', async () => {
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => button('Connect') !== undefined);
|
||||
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
|
||||
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
|
||||
locked = false; publish();
|
||||
});
|
||||
|
||||
await t.test('revoke during asynchronous preparation prevents the write', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
let release!: () => void; let preparing = false;
|
||||
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
|
||||
await waitFor(() => preparing);
|
||||
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
|
||||
});
|
||||
|
||||
await t.test('source replacement during review cannot use the old action authority', async () => {
|
||||
const run = await begin('create');
|
||||
const original = service.database.getRetainedPluginSource;
|
||||
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
|
||||
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
|
||||
finally { service.database.getRetainedPluginSource = original; }
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
|
||||
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
|
||||
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
vault.managedSources = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined);
|
||||
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
|
||||
assert.ok(group);
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Save Host')); assert.equal(saves, 1);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
|
||||
vault.managedSources = [];
|
||||
});
|
||||
|
||||
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
|
||||
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
|
||||
const run = await begin('create');
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(saves, 1); vault.managedSources = [];
|
||||
});
|
||||
|
||||
for (const transport of [
|
||||
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
|
||||
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
|
||||
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
|
||||
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
|
||||
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const dispatched: Host[] = [];
|
||||
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
|
||||
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
|
||||
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
|
||||
}), host) });
|
||||
const run = await begin('connect');
|
||||
try {
|
||||
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
|
||||
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
|
||||
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
|
||||
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
|
||||
assert.equal(dispatched.length, 0);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(dispatched.length, 1);
|
||||
assert.equal(logs[0].protocol, transport.expected);
|
||||
} finally {
|
||||
if (button('Cancel')) await click(button('Cancel'));
|
||||
await run.promise.catch(() => {});
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('runtime removal rejects old signed replies', async () => {
|
||||
const run = await begin('create');
|
||||
const old = prompts.at(-1)!;
|
||||
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
|
||||
await assert.rejects(run.promise);
|
||||
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
await service.manager.shutdown();
|
||||
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
|
||||
dom.window.close(); await rm(directory, { recursive: true, force: true });
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
|
||||
import type { Host } from '../domain/models';
|
||||
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
|
||||
|
||||
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
|
||||
const submitted: PluginHostDraftFields[] = [];
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
|
||||
assert.equal(dom.document.querySelectorAll('input').length, 3);
|
||||
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('invalid create draft cannot be confirmed', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
|
||||
assert.ok(dom.document.querySelector('[role="alert"]'));
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save?.disabled);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -50,7 +50,6 @@ export interface SelectHostPanelContentProps {
|
||||
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onConfirm: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
@@ -80,7 +79,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onConfirm,
|
||||
confirmDisabled = false,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
@@ -645,7 +643,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={confirmDisabled || selectedHostIds.length === 0}
|
||||
disabled={selectedHostIds.length === 0}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{multiSelect
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { SelectHostPanelContent } from '../SelectHostPanelContent';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
|
||||
|
||||
export function PluginHostActionHost() {
|
||||
const { t } = useI18n();
|
||||
const state = usePluginHostActions();
|
||||
const entry = state.review;
|
||||
if (!entry) return null;
|
||||
const request = entry.prompt.request;
|
||||
const selection = request.kind === 'vault.selectHosts';
|
||||
const connection = request.kind === 'vault.requestConnect';
|
||||
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
|
||||
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
|
||||
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
|
||||
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
|
||||
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
|
||||
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selection ? <>
|
||||
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
|
||||
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
|
||||
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
|
||||
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
|
||||
</> : connection && effective ? <>
|
||||
<p className="break-all text-sm font-medium">{effective.label}</p>
|
||||
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
|
||||
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
|
||||
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
|
||||
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
|
||||
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
|
||||
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
|
||||
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
/** Host-owned editable draft and exact normalized field diff. The containing
|
||||
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
|
||||
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
|
||||
before?: Host;
|
||||
draft: PluginHostDraftFields;
|
||||
busy: boolean;
|
||||
onConfirm: (draft: PluginHostDraftFields) => void;
|
||||
managedSources?: ManagedSource[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [fields, setFields] = useState<Record<string, string>>(() => ({
|
||||
label: draft.label ?? before?.label ?? '',
|
||||
hostname: draft.hostname ?? '', username: draft.username ?? '',
|
||||
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
|
||||
tags: (draft.tags ?? before?.tags ?? []).join(', '),
|
||||
}));
|
||||
const preview = useMemo(() => {
|
||||
const value = {
|
||||
label: fields.label, group: fields.group,
|
||||
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
|
||||
};
|
||||
try {
|
||||
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
|
||||
return { value: {
|
||||
label: after.label, group: after.group ?? '', tags: after.tags,
|
||||
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
|
||||
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
|
||||
} catch { return null; }
|
||||
}, [before, fields, managedSources]);
|
||||
return <div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
|
||||
<span>{t(`hostForm.field.${field}`)}</span>
|
||||
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
|
||||
</label>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
|
||||
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
|
||||
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
|
||||
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
{preview.diff.map((change) => <div key={change.field} className="break-all">
|
||||
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
|
||||
<dd>{JSON.stringify(change.before)} → {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,86 @@
|
||||
# Byte stream acceptance evidence
|
||||
|
||||
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
|
||||
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
|
||||
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
The transport delta does not edit the source database or sync implementation.
|
||||
Draft PR #3988 records the published review head. No merge or release is authorized.
|
||||
|
||||
## Completed local checks
|
||||
|
||||
The delegated environment uses Node 24.19.0 on Linux.
|
||||
|
||||
- `npm run test:plugin-contract`: 90 passed, zero skipped.
|
||||
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
|
||||
Database-specific testing is now entirely in the example directory.
|
||||
- Final focused transport, source-isolation, installed entrypoint and disposable
|
||||
database run before the test-file move: 32 passed, zero skipped. After the
|
||||
move, the same installed database test passed again at its example path.
|
||||
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
|
||||
real MariaDB result and server-side INSERT denial passed through both the
|
||||
core and the ordinary installed example entrypoint. No host ports, fixture
|
||||
override, real hosts, or real credentials were used by this database test.
|
||||
Its container, network, package and profile were removed.
|
||||
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
|
||||
clearing before dispatch and safe text rendering passed.
|
||||
- Production `npm run build`, generated contract drift, changed-source lint,
|
||||
and whitespace checks passed.
|
||||
|
||||
Real broker TLS tests validate trust and the original requested hostname using
|
||||
a disposable fixture CA. Database traffic is plain TCP inside an internal
|
||||
Docker network. Neither check proves native BrowserWindow sandbox behavior,
|
||||
database TLS, device testing, or real model inference. The registered AI/context
|
||||
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
|
||||
wiring owned by the AI task.
|
||||
|
||||
The EOF and last-await cancellation regressions each failed before their fix
|
||||
and passed after it with real loopback sockets. Source changes before grant
|
||||
publication, source changes during a pending read, and a stream returned after
|
||||
runtime release have separate passing regressions.
|
||||
|
||||
## Reproducible native sample
|
||||
|
||||
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
|
||||
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
|
||||
Two separate staging directories produced the same five-file package:
|
||||
|
||||
- Archive bytes: 11,621.
|
||||
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
|
||||
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
|
||||
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
|
||||
|
||||
The artifact contains no credentials or server setup. Changing its approved
|
||||
fixture endpoint requires a newly prepared package and a recorded new hash.
|
||||
|
||||
## Checks that did not pass locally
|
||||
|
||||
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
|
||||
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
|
||||
Electron build caches under `/workspace` passed that step, then failed while
|
||||
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
|
||||
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
|
||||
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
|
||||
directory packaging remains failed locally and needs the normal CI or native
|
||||
QA build environment.
|
||||
|
||||
The broad `npm test` run reported failures in existing transfer-resume,
|
||||
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
|
||||
tests, then stalled without a final aggregate. Its owned process tree was
|
||||
terminated. It is not reported as a passing full test run.
|
||||
|
||||
An independent source-baseline worktree at `4213d8e` ran five affected files in
|
||||
the same environment with shared dependencies: 145 tests, 118 passed,
|
||||
24 failed, three skipped. It reproduced failures in transfer-resume,
|
||||
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
|
||||
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
|
||||
This diagnoses that tested subset only. It does not classify every broad-suite
|
||||
failure, replace exact-head CI, or make any failed result successful.
|
||||
|
||||
## Pending acceptance
|
||||
|
||||
Exact published-head CI and independent review remain pending. The parent owns
|
||||
sequential native GUI acceptance for streams and the database example, then AI,
|
||||
then logging integration. The native recipe covers ordinary install/source and
|
||||
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
|
||||
password, audit inspection, screenshots, and a short recording. No native GUI
|
||||
run or screenshot/video result is claimed by this delegated executor.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Byte stream integration checkpoint
|
||||
|
||||
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
|
||||
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
|
||||
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
|
||||
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
|
||||
No source database or sync implementation is changed by the transport delta.
|
||||
|
||||
@@ -106,6 +106,6 @@ connection-management subsystem. See the example README for native setup.
|
||||
removes the container and network. Database transport is plain TCP inside
|
||||
that disposable network, not database TLS.
|
||||
|
||||
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.
|
||||
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
|
||||
sample hash, and remaining native/exact-head review work. Do not disable the
|
||||
Chromium sandbox to obtain that evidence.
|
||||
@@ -1,125 +0,0 @@
|
||||
# Host and Vault actions
|
||||
|
||||
Feature: `vault.host-actions`. The SDK registers three methods under
|
||||
`context.vault`. Netcatty owns the picker, field-diff review, and connection
|
||||
confirmation. Plugin business logic stays in the offline browser sandbox.
|
||||
|
||||
Base: source integration #3981 at
|
||||
`4213d8e6726c44e7a479418ece3adf16ea494782`.
|
||||
The published stream/AI branch point `c4433d496` is preserved through an ordinary
|
||||
merge. Its owner was still completing integration checks when it was published.
|
||||
|
||||
## Reusable boundaries
|
||||
|
||||
| Concern | Existing contract | Reuse |
|
||||
| --- | --- | --- |
|
||||
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
|
||||
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
|
||||
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
|
||||
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
|
||||
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
|
||||
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
|
||||
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
|
||||
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
|
||||
|
||||
## Public methods
|
||||
|
||||
| Method | Parameters | Result |
|
||||
| --- | --- | --- |
|
||||
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
|
||||
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
|
||||
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
|
||||
|
||||
Selected metadata contains label, hostname, username, optional port, protocol,
|
||||
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
|
||||
remain behind the typed target reference; its revision is the expected revision.
|
||||
Group metadata describes the explicitly selected current member handles. The
|
||||
grant never expands when another host joins a group.
|
||||
|
||||
Selection grants metadata access only. Each draft save and each connection
|
||||
requires its own host-owned confirmation. A connection confirmation displays the
|
||||
saved target and whether saved scripts can run. It does not grant terminal input,
|
||||
terminal output, or session access.
|
||||
|
||||
All requests are bounded and cancel on runtime stop, source replacement, revoke,
|
||||
Vault lock, owner window replacement, expiry, or user cancellation. The host
|
||||
revalidates authority and expected revision after asynchronous work and before
|
||||
the Vault write or connection dispatch.
|
||||
|
||||
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
|
||||
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
|
||||
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
|
||||
existing 128-character single-line limit. Draft field lengths use
|
||||
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
|
||||
|
||||
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
|
||||
preserve connection endpoints and credentials. Changing a group can change
|
||||
inherited authentication, proxies, and scripts; the draft review states this.
|
||||
Drafts involving managed SSH-config hosts or SSH destinations inside managed
|
||||
groups are explicitly unavailable in this small API. The review disables saving
|
||||
and directs the user to the Vault host editor. The execution adapter enforces the
|
||||
same restriction before claiming action authority, using the existing
|
||||
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
|
||||
managed-source catalog for changes before writing. This API does not initiate
|
||||
managed-file sync or expose plugin file access.
|
||||
|
||||
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
|
||||
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
|
||||
execution. Host selection and action UI live in Netcatty. Plugin business logic
|
||||
stays in the sandboxed plugin.
|
||||
|
||||
## Typed target and action authority
|
||||
|
||||
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
|
||||
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
|
||||
It does not authorize editing, connecting, terminal input, or terminal output.
|
||||
|
||||
References bind source, private owner, plugin version, runtime, window owner and
|
||||
generation, exact host ID, revision, and expiry. They live for five minutes at
|
||||
most, with at most 500 retained references. Another selection replaces that
|
||||
runtime's previous references. Lock, revoke, window replacement, and runtime stop
|
||||
invalidate the affected references. Removal or replacement of a host makes its
|
||||
renderer revision stale, even if its visible fields are identical.
|
||||
|
||||
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
|
||||
It revalidates after asynchronous authentication. AI selected-context handles are
|
||||
text-disclosure handles and cannot target these actions.
|
||||
|
||||
The permission engine gates selection with `vault.metadata`, drafts with
|
||||
`vault.write`, and connection requests with `ui.interact`. Every save or connection
|
||||
also requires a separate host-owned confirmation and a signed one-use action
|
||||
claim. A stored permission grant cannot bypass this review.
|
||||
|
||||
After review, the renderer prepares the existing transaction or connection
|
||||
request. It claims action authority after the final asynchronous preparation,
|
||||
immediately before persistence or dispatch. This claim is the action's commit
|
||||
point. Completion only acknowledges the committed action. Automation changes
|
||||
during connection review require fresh confirmation.
|
||||
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
|
||||
the launcher, including Mosh/ET flags and implicit localhost shells. Local
|
||||
execution displays an explicit on-device shell notice and omits the remote SSH
|
||||
username and port.
|
||||
|
||||
## Sample and validation
|
||||
|
||||
`examples/plugins/host-actions` adds four commands through the existing command
|
||||
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
|
||||
filesystem, terminal-content, or credential permissions.
|
||||
|
||||
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
|
||||
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
|
||||
production React dialog, and renderer execution adapter. It covers explicit
|
||||
selection, cancel, stale revisions, locked Vault, host removal and replacement,
|
||||
revoke, source replacement, runtime removal, and connection confirmation. Registry
|
||||
checks reject text handles, tampered references, caller owners, and mismatched
|
||||
expected revisions. Only synthetic resources are used. The Node transport fixture
|
||||
does not certify the operating-system sandbox.
|
||||
Focused action tests compare Mosh, ET, and localhost confirmations with the real
|
||||
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
|
||||
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
|
||||
unmanaged saves, and catalog changes use synthetic source records; no SSH config
|
||||
files are read or written.
|
||||
|
||||
Native screenshots and video run on the parent's existing cloud GUI. This branch
|
||||
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
|
||||
and credentials. No real host connection or credential access is authorized.
|
||||
@@ -1,40 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostFieldDiff, pluginHostRunsSavedScripts } from './pluginHostActions';
|
||||
import type { Host, Snippet } from './models';
|
||||
|
||||
const host: Host = { id: 'synthetic-host', label: 'Synthetic', hostname: 'host.invalid', username: 'test', os: 'linux', tags: [], password: 'synthetic-secret', startupCommand: 'synthetic-script' };
|
||||
|
||||
test('host action drafts reuse importer normalization and discard credential/executable fields', () => {
|
||||
const created = normalizePluginHostDraft({ ...host, password: 'untrusted', protocol: 'local' } as never, 'host-owned-id');
|
||||
assert.equal(created.id, 'host-owned-id');
|
||||
assert.equal(created.protocol, 'ssh');
|
||||
assert.equal(created.password, undefined);
|
||||
assert.equal(created.startupCommand, undefined);
|
||||
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid', port: 0 }, 'id'));
|
||||
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid\ninvalid' }, 'id'));
|
||||
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid other.invalid' }, 'id'));
|
||||
});
|
||||
|
||||
test('edit draft preserves saved connection and credentials and diff includes only approved metadata', () => {
|
||||
const edited = applyPluginHostEdit(host, { label: ' Renamed ', tags: ['new'], hostname: 'attacker.invalid', password: 'untrusted' } as never);
|
||||
assert.equal(edited.password, host.password);
|
||||
assert.equal(edited.hostname, host.hostname);
|
||||
assert.equal(edited.startupCommand, host.startupCommand);
|
||||
assert.throws(() => applyPluginHostEdit(host, { label: ' ' }), /required/);
|
||||
assert.deepEqual(pluginHostFieldDiff(host, edited), [
|
||||
{ field: 'label', before: 'Synthetic', after: 'Renamed' },
|
||||
{ field: 'tags', before: [], after: ['new'] },
|
||||
]);
|
||||
assert.equal(JSON.stringify(pluginHostFieldDiff(undefined, normalizePluginHostDraft(host, 'id'))).includes('synthetic-secret'), false);
|
||||
});
|
||||
|
||||
test('connection warning covers host, inherited, global and output automation without returning script content', () => {
|
||||
const clean = { ...host, startupCommand: undefined };
|
||||
assert.equal(pluginHostRunsSavedScripts(clean, [], []), false);
|
||||
assert.equal(pluginHostRunsSavedScripts(host, [], []), true);
|
||||
assert.equal(pluginHostRunsSavedScripts({ ...clean, group: 'Synthetic' }, [{ path: 'Synthetic', startupCommand: 'synthetic-group-script' }], []), true);
|
||||
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'global', label: 'Global', command: 'synthetic', kind: 'script', trigger: 'onConnect', targetsAllHosts: true } as Snippet]), true);
|
||||
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'output', label: 'Output', command: 'synthetic', kind: 'script', trigger: 'onOutput', triggerPattern: 'ready' } as Snippet]), true);
|
||||
assert.equal(pluginHostRunsSavedScripts({ ...clean, outputTriggers: [{ id: 't', pattern: 'ready', scriptId: 'synthetic' }] }, [], []), true);
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { ImporterHostDraft } from '@netcatty/plugin-contract';
|
||||
import type { GroupConfig, Host, ManagedSource, Snippet } from './models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
|
||||
import { hasHostConnectAutomation } from './hostConnectScripts';
|
||||
import { normalizePluginImporterRecords } from './pluginImporter';
|
||||
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
|
||||
import { isScriptSnippet } from './snippetScript';
|
||||
import { snippetAppliesToOutputTrigger } from './snippetTargets';
|
||||
|
||||
// Keep the public host draft a subset of the existing typed importer. In
|
||||
// particular, references to credentials, plugin configuration and executable
|
||||
// settings are never accepted by this surface.
|
||||
export type PluginHostDraftFields = Pick<ImporterHostDraft,
|
||||
'label' | 'hostname' | 'username' | 'port' | 'group' | 'tags'>;
|
||||
export type PluginHostEditFields = Pick<PluginHostDraftFields, 'label' | 'group' | 'tags'>;
|
||||
|
||||
export const PLUGIN_HOST_CREATE_FIELDS = ['label', 'hostname', 'username', 'port', 'group', 'tags'] as const;
|
||||
export const PLUGIN_HOST_EDIT_FIELDS = ['label', 'group', 'tags'] as const;
|
||||
|
||||
export function normalizePluginHostDraft(draft: PluginHostDraftFields, id: string): Host {
|
||||
const fields = Object.fromEntries(PLUGIN_HOST_CREATE_FIELDS.flatMap((field) => (
|
||||
draft[field] === undefined ? [] : [[field, draft[field]]]
|
||||
)));
|
||||
// Validate before sanitization as well: an invalid address must not become
|
||||
// a different, valid endpoint when the importer removes control characters.
|
||||
const raw = { ...fields, hostname: draft.hostname ?? '', protocol: 'ssh' as const };
|
||||
if (!buildVaultHostFromDraft(raw).ok) throw new Error('Host draft is invalid');
|
||||
const normalized = normalizePluginImporterRecords([{
|
||||
type: 'draft', draft: { kind: 'host', value: raw },
|
||||
}]);
|
||||
if (normalized.errors.length || normalized.hosts.length !== 1) {
|
||||
throw new Error('Host draft is invalid');
|
||||
}
|
||||
const validated = buildVaultHostFromDraft(normalized.hosts[0]);
|
||||
if (!validated.ok) throw new Error('Host draft is invalid');
|
||||
return { ...validated.host, id };
|
||||
}
|
||||
|
||||
export function applyPluginHostEdit(host: Host, draft: PluginHostEditFields): Host {
|
||||
if (draft.label !== undefined && !draft.label.trim()) throw new Error('Host label is required');
|
||||
const normalized = normalizePluginHostDraft({
|
||||
label: draft.label ?? host.label,
|
||||
hostname: 'draft.invalid', username: '',
|
||||
group: draft.group ?? host.group, tags: draft.tags ?? host.tags,
|
||||
}, host.id);
|
||||
return { ...host, label: normalized.label, group: normalized.group, tags: normalized.tags };
|
||||
}
|
||||
|
||||
export function pluginHostFieldDiff(before: Host | undefined, after: Host) {
|
||||
const fields = before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS;
|
||||
return fields.flatMap((field) => {
|
||||
const previous = before?.[field];
|
||||
const value = after[field];
|
||||
return JSON.stringify(previous) === JSON.stringify(value) ? [] : [{
|
||||
field, before: previous ?? null, after: value ?? null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** Managed SSH-config writes stay in the full Vault editor. Reuse its group
|
||||
* ownership rules without opening or syncing any managed file here. */
|
||||
export function pluginHostDraftTouchesManagedSource(host: Host, managedSources: ManagedSource[]): boolean {
|
||||
if (host.managedSourceId) return true;
|
||||
if (managedSources.length === 0) return false;
|
||||
const reconciled = applyVaultHostUpdate([host], [], host.id, { group: host.group ?? '' }, { managedSources });
|
||||
if (!reconciled.ok) throw new Error('Host draft is invalid');
|
||||
return Boolean(reconciled.updatedHost.managedSourceId);
|
||||
}
|
||||
|
||||
/** Only the host UI consumes this disclosure; script text never crosses RPC. */
|
||||
export function pluginHostRunsSavedScripts(host: Host, groups: GroupConfig[], snippets: Snippet[]): boolean {
|
||||
const effective = applyGroupDefaults(host, host.group ? resolveGroupDefaults(host.group, groups) : {});
|
||||
return Boolean(effective.startupCommand?.trim())
|
||||
|| hasHostConnectAutomation(effective, snippets)
|
||||
|| snippets.some((snippet) => isScriptSnippet(snippet) && snippetAppliesToOutputTrigger(snippet, effective))
|
||||
|| Boolean(effective.outputTriggers?.length);
|
||||
}
|
||||
@@ -11,9 +11,6 @@ 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.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
|
||||
@@ -29,7 +26,10 @@ 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 与已批准的端点收发数据。数据可能离开此设备。"],
|
||||
"network.stream": [true, "Outgoing byte connections", "Exchange bytes with explicitly approved TCP or TLS endpoints. Sent data leaves this device.", "传出字节连接", "与明确批准的 TCP 或 TLS 端点交换字节。发送的数据会离开此设备。"],
|
||||
"ai.invoke": [true, "Use the selected AI model", "Send plugin prompts to the model selected in Netcatty. Model usage may incur charges.", "使用选定的 AI 模型", "向 Netcatty 中选定的模型发送插件提示词。模型用量可能产生费用。"],
|
||||
"context.selected.list": [true, "Selected item summaries", "Read titles and summaries of items currently selected in the host.", "选中项目摘要", "读取宿主当前选中项目的标题和摘要。"],
|
||||
"context.selected.read": [true, "Read selected text", "Request selected text for review before sharing it with the plugin.", "读取选中文本", "请求选中文本,在分享给插件前由你确认。"],
|
||||
"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.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
|
||||
|
||||
@@ -38,13 +38,13 @@ test("installed browser entrypoint uses registered SDK/RPC streams, denial and r
|
||||
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
|
||||
activationEvents: [`onCommand:${id}.run`],
|
||||
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
|
||||
contributes: { commands: ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
|
||||
contributes: { commands: ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
|
||||
};
|
||||
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
|
||||
await fs.writeFile(path.join(packageRoot, "browser.js"), `
|
||||
export default { activate(context) {
|
||||
let connection;
|
||||
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
for (const name of ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
|
||||
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
|
||||
if (name === "selected") {
|
||||
const selected = await context.selectedContext.list({});
|
||||
@@ -62,6 +62,11 @@ export default { activate(context) {
|
||||
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
|
||||
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
|
||||
if (name === "pending") return { connected: true };
|
||||
if (name === "cancelRead") {
|
||||
const reading = connection.readable.read();
|
||||
connection.readable.cancel();
|
||||
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
|
||||
}
|
||||
try {
|
||||
await connection.writable.write(Uint8Array.of(0, 255, 42));
|
||||
return Array.from(await connection.readable.read());
|
||||
@@ -104,6 +109,7 @@ export default { activate(context) {
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
allow = true;
|
||||
assert.deepEqual(await run("run"), [0, 255, 42]);
|
||||
assert.equal(await run("cancelRead"), true);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
|
||||
await assert.rejects(run("spoof"), /contract/u);
|
||||
|
||||
@@ -104,14 +104,15 @@ class PluginByteStreamService {
|
||||
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
|
||||
});
|
||||
context.assertCommit();
|
||||
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
|
||||
if (record.closing) throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled");
|
||||
context.signal?.addEventListener("abort", record.abort, { once: true });
|
||||
// A cancelled attachment cannot leave a producer running after its RPC.
|
||||
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
|
||||
return { attached: true };
|
||||
} catch (error) {
|
||||
try { record.output?.cancel(); } catch {}
|
||||
const alreadyClosing = record.closing;
|
||||
this.closeRecord(record, error);
|
||||
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
|
||||
throw error;
|
||||
}
|
||||
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { PluginByteStreamService } = require("./byteStreamService.cjs");
|
||||
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
let peer;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
let service;
|
||||
t.after(async () => {
|
||||
service?.shutdown();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
let source = "first";
|
||||
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
|
||||
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
|
||||
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
|
||||
manifest: { features: { required: ["network.byte-streams"] } },
|
||||
};
|
||||
const audit = [];
|
||||
const writes = [];
|
||||
const registry = new PluginHostRpcRegistry();
|
||||
service = new PluginByteStreamService({
|
||||
database: {
|
||||
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
|
||||
getRetainedPluginSource: () => ({ id: source }),
|
||||
privateOwner: () => `opaque-${source}`,
|
||||
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
|
||||
recordSecurityAudit: (...args) => audit.push(args),
|
||||
},
|
||||
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
|
||||
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
|
||||
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
|
||||
})) },
|
||||
});
|
||||
service.broker.testLocalEndpoints.add(endpoint);
|
||||
service.broker.isControlEndpoint = () => false;
|
||||
service.register(registry);
|
||||
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
|
||||
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
|
||||
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
|
||||
}
|
||||
|
||||
test("source changes before grant publication cannot create a socket or grant", async (t) => {
|
||||
let approve;
|
||||
let grants = 0;
|
||||
const f = await fixture(t, { authorize: async (_context, descriptor) => {
|
||||
await new Promise((resolve) => { approve = resolve; });
|
||||
descriptor.validateBeforeGrant();
|
||||
grants++;
|
||||
} });
|
||||
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
while (!approve) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.swap(); approve();
|
||||
await assert.rejects(pending, /source binding/u);
|
||||
assert.equal(grants, 0);
|
||||
assert.equal(f.peer(), undefined);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.equal(f.audit.length, 0);
|
||||
});
|
||||
|
||||
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
|
||||
const f = await fixture(t);
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
await f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
f.swap();
|
||||
f.peer().write("synthetic old-source bytes");
|
||||
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(f.writes.length, 0);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
|
||||
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
|
||||
});
|
||||
|
||||
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
|
||||
let opened;
|
||||
let cancelled = 0;
|
||||
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
|
||||
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
|
||||
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
|
||||
while (!opened) await new Promise((resolve) => setImmediate(resolve));
|
||||
f.service.releaseRuntime("runtime-one");
|
||||
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
|
||||
await assert.rejects(attaching, /cancelled/u);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(f.service.records.size, 0);
|
||||
assert.equal(f.service.broker.connections.size, 0);
|
||||
});
|
||||
@@ -231,565 +231,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostVaultFeature": {
|
||||
"const": "vault.host-actions"
|
||||
},
|
||||
"VaultHostRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"scope",
|
||||
"handle",
|
||||
"revision",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "host.selection"
|
||||
},
|
||||
"scope": {
|
||||
"const": "metadata"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Host-signed source/runtime/window-bound metadata selection. Identifies an exact saved host and revision; edit and connect require separate host confirmation and one-use action authority. AI text handles are not Host targets."
|
||||
},
|
||||
"VaultHostMetadata": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostCreateFields": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"hostname"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostEditFields": {
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"VaultSelectHostsParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostDraftParams": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostConnectParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.selectHosts"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestConnect"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostSelectedResources": {
|
||||
"description": "Trusted host-renderer reply only. Never accepted from a plugin request.",
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision",
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"VaultHostSelectedResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"hosts",
|
||||
"groups"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"metadata"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/$defs/VaultHostMetadata"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"members"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostSelectionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostActionAccepted": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"saved",
|
||||
"requested"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostLocalTarget": {
|
||||
"description": "Trusted host renderer target only; resource IDs never cross the plugin API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionReceipt": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"completionToken"
|
||||
],
|
||||
"properties": {
|
||||
"completionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
@@ -990,12 +431,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1021,32 +456,13 @@
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit",
|
||||
"commit",
|
||||
"complete",
|
||||
"fail"
|
||||
"submit"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/$defs/VaultHostSelectedResources"
|
||||
},
|
||||
"draft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scriptsRun": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1110,37 +526,16 @@
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview",
|
||||
"commit"
|
||||
"preview"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionRequest"
|
||||
}
|
||||
]
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"vaultTarget": {
|
||||
"$ref": "#/$defs/VaultHostLocalTarget"
|
||||
},
|
||||
"reviewedDraft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -61,7 +61,6 @@ class PluginHostInteractionService {
|
||||
this.admissionRevision = 0;
|
||||
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
|
||||
this.owner = null;
|
||||
this.ownerGeneration = 0;
|
||||
this.ownerCleanup = null;
|
||||
this.closed = false;
|
||||
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
|
||||
@@ -73,19 +72,15 @@ class PluginHostInteractionService {
|
||||
metadata: { capability: "ui", mutating: true },
|
||||
validateParams: assertInteractionRequest,
|
||||
authorization: (params, context) => {
|
||||
this.admitRequest(params, context);
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
return interactionAuthorization(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
admitRequest(params, context) {
|
||||
const sender = this.#admit(params, context);
|
||||
let guard = this.admissionGuards.get(context.runtimeId);
|
||||
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
|
||||
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
|
||||
}
|
||||
|
||||
setReady(sender, ready) {
|
||||
const window = this.getWindow();
|
||||
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
|
||||
@@ -97,7 +92,6 @@ class PluginHostInteractionService {
|
||||
if (this.owner === sender) return true;
|
||||
if (this.owner) this.releaseWindow(this.owner);
|
||||
this.owner = sender;
|
||||
this.ownerGeneration++;
|
||||
const close = () => this.releaseWindow(sender);
|
||||
// A native permission dialog can temporarily blur its parent. Already
|
||||
// displayed interactions still cancel immediately; pre-prompt admission
|
||||
@@ -150,8 +144,6 @@ class PluginHostInteractionService {
|
||||
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
|
||||
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
|
||||
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
|
||||
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
|
||||
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,25 +258,6 @@ class PluginHostInteractionService {
|
||||
this.#audit(entry, "cancelled");
|
||||
return this.#finish(entry, { cancelled: true });
|
||||
}
|
||||
if (this.vaultService?.isRequest(entry.params)) {
|
||||
const outcome = await this.vaultService.respond(entry, response);
|
||||
await entry.context.assertActive();
|
||||
this.#assertWindow(sender);
|
||||
if (this.pending.get(entry.id) !== entry) return false;
|
||||
if (outcome.result) {
|
||||
this.#audit(entry, "completed");
|
||||
return this.#finish(entry, outcome.result);
|
||||
}
|
||||
this.#audit(entry, outcome.phase === "commit" ? "action-confirmed" : "action-claimed");
|
||||
entry.phase = outcome.phase;
|
||||
if (outcome.reviewedDraft !== undefined) entry.reviewedDraft = outcome.reviewedDraft;
|
||||
if (entry.phase === "executing") {
|
||||
entry.actionToken = this.#token(entry);
|
||||
return { completionToken: entry.actionToken };
|
||||
}
|
||||
this.#present(entry);
|
||||
return true;
|
||||
}
|
||||
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
|
||||
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
|
||||
this.#audit(entry, "preview-confirmed");
|
||||
@@ -325,8 +298,6 @@ class PluginHostInteractionService {
|
||||
releaseWindow(sender) {
|
||||
if (this.owner !== sender) return;
|
||||
this.admissionRevision++;
|
||||
this.ownerGeneration++;
|
||||
this.vaultService?.clearSelections();
|
||||
this.owner = null;
|
||||
this.ownerCleanup?.(); this.ownerCleanup = null;
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
@@ -335,7 +306,6 @@ class PluginHostInteractionService {
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
this.admissionRevision++;
|
||||
this.vaultService?.releasePlugin(pluginId);
|
||||
for (const entry of [...this.pending.values()]) {
|
||||
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
|
||||
}
|
||||
@@ -343,7 +313,6 @@ class PluginHostInteractionService {
|
||||
releaseRuntime(runtimeId) {
|
||||
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
|
||||
this.rates.delete(runtimeId);
|
||||
this.vaultService?.releaseRuntime(runtimeId);
|
||||
}
|
||||
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
|
||||
const guard = this.admissionGuards.get(runtimeId);
|
||||
@@ -355,7 +324,6 @@ class PluginHostInteractionService {
|
||||
}
|
||||
cancelAll(invalidateAdmission = true) {
|
||||
if (invalidateAdmission) this.admissionRevision++;
|
||||
if (invalidateAdmission) this.vaultService?.clearSelections();
|
||||
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
|
||||
}
|
||||
shutdown() {
|
||||
@@ -365,7 +333,6 @@ class PluginHostInteractionService {
|
||||
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
|
||||
this.revocation.dispose(); this.unsubscribeLock?.();
|
||||
this.key.fill(0);
|
||||
this.vaultService?.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ const { PluginByteStreamService, FEATURE: BYTE_STREAM_FEATURE } = require("./byt
|
||||
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");
|
||||
@@ -96,7 +93,7 @@ function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
let byteStreamService;
|
||||
@@ -201,11 +198,6 @@ function createPluginHostService(options) {
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
});
|
||||
interactionService.registerRpcCapabilities(rpcRegistry);
|
||||
const hostVaultService = new PluginHostVaultService({
|
||||
interactionService,
|
||||
sourceBinding: new PluginAISourceBinding({ database, getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId) }),
|
||||
});
|
||||
hostVaultService.registerRpcCapabilities(rpcRegistry);
|
||||
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
|
||||
if (configuredRegistry && typeof configuredRegistry.then === "function") {
|
||||
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
|
||||
@@ -430,7 +422,6 @@ function createPluginHostService(options) {
|
||||
return {
|
||||
themeService,
|
||||
interactionService,
|
||||
hostVaultService,
|
||||
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
|
||||
companionSupervisor,
|
||||
contributionIconService,
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const { createHmac, randomBytes } = require("node:crypto");
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
|
||||
const METHODS = ["vault.selectHosts", "vault.requestHostDraft", "vault.requestConnect"];
|
||||
const MAX_SELECTED = 50;
|
||||
const MAX_REFERENCES = 500;
|
||||
const TARGET_LIFETIME_MS = 5 * 60_000;
|
||||
|
||||
/** Typed host selection only. Selection references identify a reviewed target;
|
||||
* the interaction service owns separate operation-specific commit authority. */
|
||||
class PluginHostVaultService {
|
||||
constructor({ sourceBinding, interactionService, clock = Date.now }) {
|
||||
this.sourceBinding = sourceBinding;
|
||||
this.interactionService = interactionService;
|
||||
this.clock = clock;
|
||||
this.key = randomBytes(32);
|
||||
this.targets = new Map();
|
||||
this.admissions = new WeakMap();
|
||||
this.validators = Object.fromEntries([
|
||||
"VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams",
|
||||
"VaultHostSelectedResources", "VaultHostCreateFields", "VaultHostEditFields",
|
||||
].map((name) => [name, createDefinitionValidator(name)]));
|
||||
interactionService.vaultService = this;
|
||||
}
|
||||
|
||||
isRequest(params) { return METHODS.includes(params?.kind); }
|
||||
|
||||
#validate(name, value) {
|
||||
if (!this.validators[name](value)) throw fail("invalidArgument", "Host action parameters are invalid");
|
||||
if (Buffer.byteLength(JSON.stringify(value)) > 65_536) throw fail("resourceExhausted", "Host action exceeds its size limit");
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
registerRpcCapabilities(registry) {
|
||||
const definitions = ["VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams"];
|
||||
for (const [index, method] of METHODS.entries()) {
|
||||
registry.registerRequest(method, (params, context) => this.request(params, context), {
|
||||
metadata: { capability: "vault", mutating: method !== "vault.selectHosts" },
|
||||
validateParams: (params) => Object.freeze({ ...this.#validate(definitions[index], params), kind: method }),
|
||||
authorization: async (params, context) => {
|
||||
const binding = await this.sourceBinding.resolve(context);
|
||||
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
|
||||
this.interactionService.admitRequest(params, context);
|
||||
this.admissions.set(params, binding);
|
||||
return {
|
||||
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
|
||||
resources: ["*"], allowedScopes: ["once"],
|
||||
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
|
||||
operationId: method,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async request(params, originalContext) {
|
||||
const binding = await this.sourceBinding.resolve(originalContext);
|
||||
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
|
||||
this.admissions.delete(params);
|
||||
const assertActive = async () => {
|
||||
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
|
||||
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
|
||||
};
|
||||
await assertActive();
|
||||
return this.interactionService.request(params, Object.freeze({ ...originalContext, assertActive, vaultBinding: binding }));
|
||||
}
|
||||
|
||||
#resolveTarget(ref, expectedRevision, binding) {
|
||||
const entry = this.targets.get(ref?.handle);
|
||||
if (!entry || ref.kind !== "host.selection" || ref.scope !== "metadata" || entry.binding !== binding
|
||||
|| ref.revision !== entry.revision || expectedRevision !== entry.revision || ref.expiresAt !== entry.expiresAt
|
||||
|| this.clock() >= entry.expiresAt || entry.owner !== this.interactionService.owner
|
||||
|| entry.generation !== this.interactionService.ownerGeneration) {
|
||||
throw fail("permissionDenied", "Host selection expired or changed; select the host again");
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
#issueTarget(resource, context) {
|
||||
const entry = {
|
||||
binding: context.vaultBinding, resourceId: resource.resourceId, revision: resource.revision,
|
||||
owner: this.interactionService.owner, generation: this.interactionService.ownerGeneration,
|
||||
expiresAt: this.clock() + TARGET_LIFETIME_MS,
|
||||
};
|
||||
const nonce = randomBytes(24).toString("base64url");
|
||||
const signature = createHmac("sha256", this.key).update(JSON.stringify([
|
||||
nonce, entry.binding, entry.owner.id, entry.generation, entry.resourceId,
|
||||
entry.revision, entry.expiresAt, "host.selection", "metadata",
|
||||
])).digest("base64url");
|
||||
const handle = `${nonce}.${signature}`;
|
||||
this.targets.set(handle, entry);
|
||||
return { kind: "host.selection", scope: "metadata", handle, revision: entry.revision, expiresAt: entry.expiresAt };
|
||||
}
|
||||
|
||||
describePrompt(entry) {
|
||||
if (!entry.params.target) return {};
|
||||
const target = this.#resolveTarget(entry.params.target, entry.params.expectedRevision, entry.context.vaultBinding);
|
||||
return { vaultTarget: { resourceId: target.resourceId, revision: target.revision } };
|
||||
}
|
||||
|
||||
async respond(entry, response) {
|
||||
await entry.context.assertActive();
|
||||
if (response.action === "fail") throw fail("cancelled", "Host resource changed or the action could not complete");
|
||||
if (entry.phase === "request" && response.action === "submit") {
|
||||
if (entry.params.kind === "vault.selectHosts") {
|
||||
const resources = this.#validate("VaultHostSelectedResources", response.resources);
|
||||
if (resources.length > MAX_SELECTED || new Set(resources.map((resource) => resource.resourceId)).size !== resources.length) {
|
||||
throw fail("invalidArgument", "Host selection is invalid");
|
||||
}
|
||||
this.releaseSelections(entry.context.vaultBinding);
|
||||
this.pruneExpired();
|
||||
if (this.targets.size + resources.length > MAX_REFERENCES) throw fail("resourceExhausted", "Too many host selections");
|
||||
const hosts = resources.map(({ resourceId, revision, ...metadata }) => ({
|
||||
target: this.#issueTarget({ resourceId, revision }, entry.context), metadata,
|
||||
}));
|
||||
const groups = [...new Set(hosts.map((host) => host.metadata.group).filter(Boolean))].map((path) => ({
|
||||
path, members: hosts.filter((host) => host.metadata.group === path).map((host) => host.target),
|
||||
}));
|
||||
return { result: { cancelled: false, hosts, groups } };
|
||||
}
|
||||
if (entry.params.kind === "vault.requestHostDraft") {
|
||||
const draft = this.#validate(entry.params.target ? "VaultHostEditFields" : "VaultHostCreateFields", response.draft);
|
||||
return { phase: "commit", reviewedDraft: draft };
|
||||
}
|
||||
if (typeof response.scriptsRun !== "boolean") throw fail("invalidArgument", "Connection script disclosure is required");
|
||||
return { phase: "commit" };
|
||||
}
|
||||
if (entry.phase === "commit" && response.action === "commit") return { phase: "executing" };
|
||||
if (entry.phase === "executing" && response.action === "complete") {
|
||||
return { result: { cancelled: false, status: entry.params.kind === "vault.requestConnect" ? "requested" : "saved" } };
|
||||
}
|
||||
throw fail("invalidArgument", "Host action phase is invalid");
|
||||
}
|
||||
|
||||
pruneExpired() { for (const [handle, entry] of this.targets) if (this.clock() >= entry.expiresAt) this.targets.delete(handle); }
|
||||
releaseSelections(binding) { for (const [handle, entry] of this.targets) if (entry.binding === binding) this.targets.delete(handle); }
|
||||
releaseRuntime(runtimeId) {
|
||||
for (const [handle, entry] of this.targets) if (entry.binding.runtimeId === runtimeId) this.targets.delete(handle);
|
||||
this.sourceBinding.releaseRuntime(runtimeId);
|
||||
}
|
||||
releasePlugin(pluginId) {
|
||||
for (const [handle, entry] of this.targets) if (entry.binding.pluginId === pluginId) this.targets.delete(handle);
|
||||
// Keep the source helper's pinned runtime binding until runtime stop. A
|
||||
// revoked old runtime must never rebind itself to a replacement source.
|
||||
}
|
||||
clearSelections() { this.targets.clear(); }
|
||||
shutdown() { this.targets.clear(); this.sourceBinding.shutdown(); this.key.fill(0); }
|
||||
}
|
||||
|
||||
module.exports = { PluginHostVaultService, METHODS, MAX_SELECTED, TARGET_LIFETIME_MS };
|
||||
@@ -380,9 +380,6 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
quickPick: (title, items) => interact({ kind: "quickPick", title, items }),
|
||||
input: (title, initialValue) => interact({ kind: "input", title, ...(initialValue === undefined ? {} : { initialValue }) }),
|
||||
};
|
||||
const vault = Object.fromEntries(["selectHosts", "requestHostDraft", "requestConnect"].map((method) => [
|
||||
method, (params) => client.request(`vault.${method}`, params, { deadlineMs: PLUGIN_HOST_INTERACTION_LIMITS.timeoutMs }),
|
||||
]));
|
||||
const clipboard = {
|
||||
readText: (title) => interact({ kind: "clipboard.read", title }),
|
||||
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
|
||||
@@ -577,7 +574,6 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
ai,
|
||||
selectedContext,
|
||||
ui,
|
||||
vault,
|
||||
clipboard,
|
||||
filesystem,
|
||||
companions,
|
||||
|
||||
@@ -5,6 +5,17 @@ const test = require("node:test");
|
||||
|
||||
const { PluginStreamRouter } = require("./streamRouter.cjs");
|
||||
|
||||
test("outgoing close hook observes idle peer cancellation exactly once", async () => {
|
||||
const closed = [];
|
||||
const router = new PluginStreamRouter({ send() {} });
|
||||
const stream = await router.openOutgoing("idle-close-hook", 1024, { onClose: (reason) => closed.push(reason) });
|
||||
await router.accept({ frame: { streamId: "idle-close-hook", sequence: 1, kind: "cancel" } });
|
||||
stream.cancel();
|
||||
router.close();
|
||||
assert.deepEqual(closed, ["cancel"]);
|
||||
assert.equal(router.outgoing.size, 0);
|
||||
});
|
||||
|
||||
test("incoming stream credit is returned only after the consumer releases a chunk", async () => {
|
||||
const sent = [];
|
||||
const received = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, copyFile } from 'node:fs/promises';
|
||||
import { URL } from 'node:url';
|
||||
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,111 @@
|
||||
# Run the disposable database example
|
||||
|
||||
Use the Linux cloud GUI machine assigned by the parent. Keep the existing
|
||||
Chromium sandbox settings. Use a fresh, isolated Netcatty QA profile. This
|
||||
example does not use saved SSH or AI credentials.
|
||||
|
||||
## Prepare the fixture and package
|
||||
|
||||
1. Check out the exact transport PR head and run `npm ci` followed by
|
||||
`npm run build:plugin-runtime-deps`.
|
||||
2. Create a dedicated internal Docker network. Do not alter an existing
|
||||
network if this subnet is already in use.
|
||||
|
||||
```sh
|
||||
docker network create --internal --subnet 172.30.247.0/24 netcatty-plugin-db-qa
|
||||
docker run --detach --rm --name netcatty-plugin-db-qa \
|
||||
--network netcatty-plugin-db-qa --ip 172.30.247.2 \
|
||||
--env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 \
|
||||
mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873
|
||||
```
|
||||
|
||||
3. Wait for `docker exec netcatty-plugin-db-qa mariadb-admin --host=127.0.0.1 ping --silent`
|
||||
to succeed. The container publishes no host ports and has no external route.
|
||||
The empty administrative password exists only in this disposable container.
|
||||
4. Open its local client with
|
||||
`docker exec -it netcatty-plugin-db-qa mariadb --protocol=socket --user=root`.
|
||||
Create the fixture below. Replace the password placeholder with a new
|
||||
synthetic session password. Do not put a real password in this example.
|
||||
|
||||
```sql
|
||||
CREATE DATABASE plugin_demo;
|
||||
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
|
||||
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
|
||||
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<new-synthetic-session-password>';
|
||||
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
```
|
||||
|
||||
5. Build the normal browser package for this exact endpoint:
|
||||
|
||||
```sh
|
||||
node examples/plugins/database-minimal/prepare.mjs \
|
||||
tcp://172.30.247.2:3306 /tmp/database-minimal.ncpkg
|
||||
```
|
||||
|
||||
The tool prints the package hash, not credentials. Its temporary staging
|
||||
directory is removed. Only the manifest, browser bundle, and three view
|
||||
assets enter the five-file package. No Node preparation tool, server setup,
|
||||
dependency tree, or credential file enters the package.
|
||||
|
||||
If a different private subnet is needed, prepare a new package for its exact
|
||||
fixture IP. Record the new package hash and review its endpoint permission.
|
||||
The example page does not accept arbitrary hosts or SQL.
|
||||
|
||||
## Run automated entrypoint acceptance
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
|
||||
```
|
||||
|
||||
The test creates its own isolated server, random session password, and temporary
|
||||
package. It exercises both the transport core and the installed example entry
|
||||
through the registered SDK/RPC path. It removes the container, network, package
|
||||
and profile afterward. The in-process runtime harness does not prove native
|
||||
browser sandbox behavior.
|
||||
|
||||
## Record native acceptance
|
||||
|
||||
1. Start the exact build with the existing sandbox settings and isolated
|
||||
profile. Install the `.ncpkg` through the ordinary plugin manager. Its source
|
||||
is a local import, not a claim of official maintenance.
|
||||
2. Enable the plugin and open **Disposable database example** from its view
|
||||
action. Record the plugin detail screen, source, version, and exact endpoint
|
||||
resource. The plugin has a browser entry only.
|
||||
3. Enter `plugin_demo` and the synthetic session password. The password input
|
||||
is masked and clears before dispatch. Click **Connect and read** and deny
|
||||
the endpoint prompt. The page must show a bounded failure and remain usable.
|
||||
4. Retry and allow only `tcp://172.30.247.2:3306`. Record the permission prompt
|
||||
and the result `{"columns":["value"],"rows":[["synthetic stream result"]]}`.
|
||||
Do not record the password or administrative client session.
|
||||
5. Revoke the stream grant in the manager, retry, and deny. Check that no
|
||||
connection remains. Test **Cancel**, close/reopen the view, and disable the
|
||||
plugin during a pending request. Restart with the same QA profile: the
|
||||
plugin must require the session password again.
|
||||
6. Inspect generic connection audit records. Record endpoint, open/close and
|
||||
byte counts; verify that neither the password, SQL nor result body appears.
|
||||
Verify the account's server-side write denial in the disposable server.
|
||||
|
||||
Save screenshots of install/details, denial, endpoint approval and the result,
|
||||
plus a short recording of denial/retry/query/revoke/disable. This recipe does
|
||||
not claim that native QA has run. The automated Node entrypoint harness is not
|
||||
a BrowserWindow sandbox test.
|
||||
|
||||
The example database connection is plaintext only inside the disposable
|
||||
network. It does not test MySQL STARTTLS. Generic TLS transport has separate
|
||||
real-socket certificate and hostname validation tests. Do not use this example
|
||||
with real data or an existing database.
|
||||
|
||||
## Remove the fixture
|
||||
|
||||
Disable and uninstall the example, then remove the temporary container and
|
||||
network:
|
||||
|
||||
```sh
|
||||
docker rm --force netcatty-plugin-db-qa
|
||||
docker network rm netcatty-plugin-db-qa
|
||||
```
|
||||
|
||||
The container, its synthetic credential hashes, and data disappear. Remove the
|
||||
prepared package and isolated QA profile after retaining the required evidence.
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
/* global __dirname */
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const test = require("node:test");
|
||||
|
||||
test("the example clears session passwords before dispatch and renders result text safely", async () => {
|
||||
const elements = new Map();
|
||||
for (const id of ["username", "password", "query", "cancel", "result", "form", "endpoint"]) {
|
||||
const element = { value: "", textContent: "", disabled: false, listeners: {},
|
||||
addEventListener(event, listener) { this.listeners[event] = listener; },
|
||||
};
|
||||
Object.defineProperty(element, "innerHTML", { set() { throw new Error("HTML output is forbidden"); } });
|
||||
elements.set(id, element);
|
||||
}
|
||||
elements.get("username").value = "plugin_demo";
|
||||
elements.get("password").value = "synthetic-session-only";
|
||||
let settle;
|
||||
let submitted;
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "panel.js"), "utf8"), {
|
||||
document: { getElementById: (id) => elements.get(id), documentElement: { style: { setProperty() {}, removeProperty() {} } } },
|
||||
window: { netcattyView: {
|
||||
onDidChangeEnvironment() {},
|
||||
executeCommand: async (name, params) => {
|
||||
if (name.endsWith(".endpoint")) return { endpoint: "tcp://172.30.247.2:3306" };
|
||||
assert.ok(name.endsWith(".query"));
|
||||
assert.equal(elements.get("password").value, "");
|
||||
submitted = params;
|
||||
return new Promise((resolve) => { settle = resolve; });
|
||||
},
|
||||
} },
|
||||
});
|
||||
const pending = elements.get("form").listeners.submit({ preventDefault() {} });
|
||||
assert.equal(submitted.password, "synthetic-session-only");
|
||||
assert.equal(elements.get("query").disabled, true);
|
||||
settle({ columns: ["value"], rows: [["<img src=x onerror=alert(1)>"]] });
|
||||
await pending;
|
||||
assert.equal(submitted.password, "");
|
||||
assert.equal(elements.get("password").value, "");
|
||||
assert.equal(elements.get("query").disabled, false);
|
||||
assert.match(elements.get("result").textContent, /<img/u);
|
||||
const html = fs.readFileSync(path.join(__dirname, "panel.html"), "utf8");
|
||||
assert.match(html, /id="password"[^>]*type="password"/u);
|
||||
});
|
||||
@@ -25,7 +25,8 @@ export async function prepareDatabaseExample(targetEndpoint, outputPath) {
|
||||
for (const file of ["browser.js", "protocol.mjs", "panel.html", "panel.css", "panel.js"]) await fs.copyFile(path.join(root, file), path.join(temporary, file));
|
||||
await fs.writeFile(path.join(temporary, "fixture-config.mjs"), `export const endpoint = ${JSON.stringify(endpoint)};\n`);
|
||||
const bundle = await build({ entryPoints: [path.join(temporary, "browser.js")], write: false,
|
||||
bundle: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
|
||||
bundle: true, platform: "browser", format: "esm", minify: true,
|
||||
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"));
|
||||
|
||||
+42
-3
@@ -1,4 +1,5 @@
|
||||
"use strict";
|
||||
/* global __dirname */
|
||||
|
||||
// Creates only an isolated disposable fixture. Never accepts a user host,
|
||||
// existing database, database URL, or real credentials from the environment.
|
||||
@@ -8,7 +9,13 @@ const { execFileSync } = require("node:child_process");
|
||||
const { randomUUID, randomBytes } = require("node:crypto");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const path = require("node:path");
|
||||
const { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
|
||||
const process = require("node:process");
|
||||
const { setTimeout } = require("node:timers");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const { PluginByteStreamBroker } = require("../../../electron/plugins/byteStreamBroker.cjs");
|
||||
const { createPluginHostService } = require("../../../electron/plugins/hostService.cjs");
|
||||
const { inProcessBrowserRuntime } = require("../../../electron/plugins/fixtures/inProcessBrowserRuntime.cjs");
|
||||
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
|
||||
|
||||
function docker(args, input) {
|
||||
@@ -23,11 +30,15 @@ test("minimal browser protocol queries a disposable MariaDB through the real byt
|
||||
const network = `netcatty-stream-test-${suffix}`;
|
||||
const container = `netcatty-stream-test-${suffix}`;
|
||||
const sessionPassword = randomBytes(24).toString("hex");
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-db-entry-"));
|
||||
let service;
|
||||
docker(["network", "create", "--internal", network]);
|
||||
let created = false;
|
||||
t.after(() => {
|
||||
t.after(async () => {
|
||||
await service?.manager.shutdown();
|
||||
if (created) docker(["rm", "--force", container]);
|
||||
docker(["network", "rm", network]);
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
|
||||
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
|
||||
@@ -59,7 +70,7 @@ GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
|
||||
const { handleId } = await broker.connect(endpoint, context);
|
||||
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
|
||||
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
|
||||
__dirname, "./protocol.mjs",
|
||||
)).href);
|
||||
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
|
||||
const result = await queryExample({
|
||||
@@ -69,6 +80,34 @@ GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
|
||||
}, "plugin_demo", sessionPassword);
|
||||
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
|
||||
assert.equal(broker.connections.size, 0);
|
||||
const { prepareDatabaseExample } = await import("./prepare.mjs");
|
||||
const archive = path.join(root, "database-minimal.ncpkg");
|
||||
const packed = await prepareDatabaseExample(endpoint, archive);
|
||||
assert.equal(packed.fileCount, 5);
|
||||
const decisions = [];
|
||||
service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
|
||||
electron: {}, env: {},
|
||||
requestPermissionDecision: async (request) => {
|
||||
decisions.push(request);
|
||||
return { requestId: request.requestId, decision: "allow", scope: "application" };
|
||||
},
|
||||
});
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
await service.manager.initialize();
|
||||
await service.manager.install(archive, { enable: true });
|
||||
const installedResult = await service.contributionService.executeCommand("com.netcatty.database-minimal.query", {
|
||||
username: "plugin_demo", password: sessionPassword,
|
||||
});
|
||||
assert.deepEqual(installedResult, result);
|
||||
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
|
||||
assert.equal(service.byteStreamService.records.size, 0);
|
||||
assert.equal(service.byteStreamService.broker.connections.size, 0);
|
||||
const audits = JSON.stringify(service.database.listSecurityAudit("com.netcatty.database-minimal", 100));
|
||||
assert.equal(audits.includes(sessionPassword), false);
|
||||
assert.equal(audits.includes(READ_QUERY), false);
|
||||
assert.equal(audits.includes("synthetic stream result"), false);
|
||||
assert.equal(audits.includes("network.stream.open"), true);
|
||||
// Server-side grants, not a SQL-prefix check, enforce the read-only fixture.
|
||||
// MYSQL_PWD exists only inside this disposable test exec and is never logged.
|
||||
assert.throws(() => execFileSync("docker", ["exec", "--interactive", container, "sh", "-c",
|
||||
@@ -1,19 +0,0 @@
|
||||
# Host action sample
|
||||
|
||||
The sample adds four commands to the command palette. It runs as an offline,
|
||||
sandboxed browser plugin and requests no network, filesystem, or credential access.
|
||||
|
||||
`select` asks Netcatty to share explicitly chosen current hosts. The result contains
|
||||
saved labels, addresses, usernames, groups, tags, and metadata-only target references.
|
||||
Group results contain the chosen current members and never include future members.
|
||||
|
||||
`create` opens a host-owned SSH draft for `synthetic.invalid`. `edit` proposes a
|
||||
label change for the first chosen host. Netcatty displays editable fields and the
|
||||
normalized field diff before either action saves.
|
||||
|
||||
`connect` asks Netcatty to confirm a saved-host connection. The confirmation shows
|
||||
whether saved scripts can run. The acknowledgement contains no session identifier
|
||||
or terminal data. Selecting metadata never authorizes a connection by itself.
|
||||
|
||||
All sample test resources use synthetic hostnames. Native acceptance must use
|
||||
synthetic hosts and must not establish a real host connection.
|
||||
@@ -1,31 +0,0 @@
|
||||
// This offline BrowserWindow plugin has no filesystem, network or credential
|
||||
// permissions. Netcatty owns every picker, field diff and connection prompt.
|
||||
export default {
|
||||
activate(context) {
|
||||
const select = () => context.vault.selectHosts({ title: 'Share saved host names, addresses, usernames, groups and tags' });
|
||||
const commands = {
|
||||
select,
|
||||
create: () => context.vault.requestHostDraft({ draft: {
|
||||
label: 'Synthetic SSH host', hostname: 'synthetic.invalid', username: 'example', group: 'Samples', tags: ['synthetic'],
|
||||
} }),
|
||||
edit: async () => {
|
||||
const selection = await select();
|
||||
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
|
||||
const host = selection.hosts[0];
|
||||
return context.vault.requestHostDraft({
|
||||
target: host.target, expectedRevision: host.target.revision,
|
||||
draft: { label: `${host.metadata.label} (reviewed)` },
|
||||
});
|
||||
},
|
||||
connect: async () => {
|
||||
const selection = await select();
|
||||
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
|
||||
const target = selection.hosts[0].target;
|
||||
return context.vault.requestConnect({ target, expectedRevision: target.revision });
|
||||
},
|
||||
};
|
||||
for (const [name, execute] of Object.entries(commands)) {
|
||||
context.subscriptions.add(context.commands.registerCommand(`com.netcatty.host-actions.${name}`, execute));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.host-actions",
|
||||
"name": "host-actions",
|
||||
"displayName": "Host action sample",
|
||||
"description": "Explicit host metadata sharing, reviewed SSH drafts, and confirmed saved-host connections.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
|
||||
"features": { "required": ["vault.host-actions", "commands.interactive"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": [
|
||||
"onCommand:com.netcatty.host-actions.select",
|
||||
"onCommand:com.netcatty.host-actions.create",
|
||||
"onCommand:com.netcatty.host-actions.edit",
|
||||
"onCommand:com.netcatty.host-actions.connect"
|
||||
],
|
||||
"permissions": {
|
||||
"required": ["commands", "menus"],
|
||||
"optional": ["vault.metadata", "vault.write", "ui.interact"]
|
||||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{ "id": "com.netcatty.host-actions.select", "title": "Host sample: share selected metadata", "interactive": true },
|
||||
{ "id": "com.netcatty.host-actions.create", "title": "Host sample: review a synthetic SSH draft", "interactive": true },
|
||||
{ "id": "com.netcatty.host-actions.edit", "title": "Host sample: review a host label change", "interactive": true },
|
||||
{ "id": "com.netcatty.host-actions.connect", "title": "Host sample: confirm a saved-host connection", "interactive": true }
|
||||
],
|
||||
"menus": [
|
||||
{ "command": "com.netcatty.host-actions.select", "location": "commandPalette" },
|
||||
{ "command": "com.netcatty.host-actions.create", "location": "commandPalette" },
|
||||
{ "command": "com.netcatty.host-actions.edit", "location": "commandPalette" },
|
||||
{ "command": "com.netcatty.host-actions.connect", "location": "commandPalette" }
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+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<import("@netcatty/plugin-contract").HostInteractionReceipt>;
|
||||
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
|
||||
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
|
||||
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
|
||||
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { validateManifestValue } from "./manifest.js";
|
||||
import { checkPluginCompatibility } from "./compatibility.js";
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL("../../../examples/plugins/database-minimal/netcatty.plugin.json", import.meta.url), "utf8"));
|
||||
|
||||
test("byte stream packages require negotiated features and exact resources in optional grants", () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
for (const resources of [["*"], ["tcp://*:3306"], ["tcp://host:3306/"], ["udp://host:3306"], ["https://host"]]) {
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: [{ permission: "network.stream", resources }] } }).valid, false);
|
||||
}
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: ["network.stream"] } }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: [] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: ["network.byte-streams"] }).compatible, true);
|
||||
});
|
||||
@@ -372,6 +372,10 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
const hasByteStreams = [...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])]
|
||||
.some((declaration) => permissionName(declaration) === "network.stream");
|
||||
if (hasByteStreams && !requiredFeatures.has("network.byte-streams")) errors.push("Byte streams require network.byte-streams");
|
||||
const declaredCapabilityNames = new Set([...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])].map(permissionName));
|
||||
if (declaredCapabilityNames.has("ai.invoke") && !requiredFeatures.has("ai.model-invocation")) errors.push("AI invocation requires ai.model-invocation");
|
||||
if ((declaredCapabilityNames.has("context.selected.list") || declaredCapabilityNames.has("context.selected.read"))
|
||||
&& !requiredFeatures.has("context.selected-read")) errors.push("Selected context requires context.selected-read");
|
||||
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
|
||||
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
|
||||
@@ -178,565 +178,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostVaultFeature": {
|
||||
"const": "vault.host-actions"
|
||||
},
|
||||
"VaultHostRef": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"scope",
|
||||
"handle",
|
||||
"revision",
|
||||
"expiresAt"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "host.selection"
|
||||
},
|
||||
"scope": {
|
||||
"const": "metadata"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"expiresAt": {
|
||||
"$ref": "#/$defs/SafePositiveInteger"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Host-signed source/runtime/window-bound metadata selection. Identifies an exact saved host and revision; edit and connect require separate host confirmation and one-use action authority. AI text handles are not Host targets."
|
||||
},
|
||||
"VaultHostMetadata": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostCreateFields": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"hostname"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostEditFields": {
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
},
|
||||
"VaultSelectHostsParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostDraftParams": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostConnectParams": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionRequest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.selectHosts"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision",
|
||||
"draft"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestHostDraft"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"draft": {
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"target",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "vault.requestConnect"
|
||||
},
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"expectedRevision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostSelectedResources": {
|
||||
"description": "Trusted host-renderer reply only. Never accepted from a plugin request.",
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision",
|
||||
"label",
|
||||
"hostname",
|
||||
"username",
|
||||
"group",
|
||||
"tags",
|
||||
"protocol"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"hostname": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"group": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 512
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"maxLength": 199,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"VaultHostSelectedResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"hosts",
|
||||
"groups"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"target",
|
||||
"metadata"
|
||||
],
|
||||
"properties": {
|
||||
"target": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/$defs/VaultHostMetadata"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"members"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"$ref": "#/$defs/VaultHostRef"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostSelectionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostActionAccepted": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": false
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"saved",
|
||||
"requested"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"VaultHostActionResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"cancelled"
|
||||
],
|
||||
"properties": {
|
||||
"cancelled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"VaultHostLocalTarget": {
|
||||
"description": "Trusted host renderer target only; resource IDs never cross the plugin API.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"resourceId",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"revision": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[^\\u0000-\\u001f\\u007f]*$",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HostInteractionReceipt": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"completionToken"
|
||||
],
|
||||
"properties": {
|
||||
"completionToken": {
|
||||
"type": "string",
|
||||
"minLength": 32,
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"HostInteractionLimits": {
|
||||
"const": {
|
||||
"textBytes": 4096,
|
||||
@@ -933,12 +374,6 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostSelectedResult"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionAccepted"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -964,32 +399,13 @@
|
||||
"enum": [
|
||||
"cancel",
|
||||
"preview",
|
||||
"submit",
|
||||
"commit",
|
||||
"complete",
|
||||
"fail"
|
||||
"submit"
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"resources": {
|
||||
"$ref": "#/$defs/VaultHostSelectedResources"
|
||||
},
|
||||
"draft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scriptsRun": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1053,37 +469,16 @@
|
||||
"phase": {
|
||||
"enum": [
|
||||
"request",
|
||||
"preview",
|
||||
"commit"
|
||||
"preview"
|
||||
]
|
||||
},
|
||||
"request": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostActionRequest"
|
||||
}
|
||||
]
|
||||
"$ref": "#/$defs/HostInteractionRequest"
|
||||
},
|
||||
"previewText": {
|
||||
"type": "string",
|
||||
"maxLength": 4096,
|
||||
"pattern": "^[^\\u0000]*$"
|
||||
},
|
||||
"vaultTarget": {
|
||||
"$ref": "#/$defs/VaultHostLocalTarget"
|
||||
},
|
||||
"reviewedDraft": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostCreateFields"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/VaultHostEditFields"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -366,11 +366,7 @@ 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));
|
||||
}), permission !== "network.stream", JSON.stringify(validate.errors));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -228,27 +228,18 @@ export type HostInteractionPrompt = {
|
||||
pluginId: PluginId;
|
||||
pluginVersion: SemanticVersion;
|
||||
expiresAt: SafePositiveInteger;
|
||||
phase: "request" | "preview" | "commit";
|
||||
request: (HostInteractionRequest) | (VaultHostActionRequest);
|
||||
phase: "request" | "preview";
|
||||
request: HostInteractionRequest;
|
||||
previewText?: string;
|
||||
vaultTarget?: VaultHostLocalTarget;
|
||||
reviewedDraft?: (VaultHostCreateFields) | (VaultHostEditFields);
|
||||
};
|
||||
|
||||
export type HostInteractionReceipt = (boolean) | ({
|
||||
completionToken: string;
|
||||
});
|
||||
|
||||
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
|
||||
|
||||
export type HostInteractionResponse = {
|
||||
requestId: string;
|
||||
actionToken: string;
|
||||
action: "cancel" | "preview" | "submit" | "commit" | "complete" | "fail";
|
||||
action: "cancel" | "preview" | "submit";
|
||||
value?: string;
|
||||
resources?: VaultHostSelectedResources;
|
||||
draft?: (VaultHostCreateFields) | (VaultHostEditFields);
|
||||
scriptsRun?: boolean;
|
||||
};
|
||||
|
||||
export type HostInteractionResult = ({
|
||||
@@ -256,7 +247,7 @@ export type HostInteractionResult = ({
|
||||
}) | ({
|
||||
cancelled: false;
|
||||
value: (string) | (true);
|
||||
}) | (VaultHostSelectedResult) | (VaultHostActionAccepted);
|
||||
});
|
||||
|
||||
export type HostQuickPickItem = {
|
||||
id: string;
|
||||
@@ -272,8 +263,6 @@ export type HostQuickPickRequest = {
|
||||
|
||||
export type HostSingleLineInputText = string;
|
||||
|
||||
export type HostVaultFeature = "vault.host-actions";
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
@@ -1246,117 +1235,8 @@ export type ThemePackSelection = {
|
||||
|
||||
export type ThemePackVariant = "light" | "dark";
|
||||
|
||||
export type VaultHostActionAccepted = {
|
||||
cancelled: false;
|
||||
status: "saved" | "requested";
|
||||
};
|
||||
|
||||
export type VaultHostActionRequest = ({
|
||||
kind: "vault.selectHosts";
|
||||
title: string;
|
||||
}) | ({
|
||||
kind: "vault.requestHostDraft";
|
||||
draft: VaultHostCreateFields;
|
||||
}) | ({
|
||||
kind: "vault.requestHostDraft";
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
draft: VaultHostEditFields;
|
||||
}) | ({
|
||||
kind: "vault.requestConnect";
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
});
|
||||
|
||||
export type VaultHostActionResult = ({
|
||||
cancelled: true;
|
||||
}) | (VaultHostActionAccepted);
|
||||
|
||||
export type VaultHostConnectParams = {
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
};
|
||||
|
||||
export type VaultHostCreateFields = {
|
||||
label?: string;
|
||||
hostname: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
group?: string;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
|
||||
export type VaultHostDraftParams = ({
|
||||
draft: VaultHostCreateFields;
|
||||
}) | ({
|
||||
target: VaultHostRef;
|
||||
expectedRevision: string;
|
||||
draft: VaultHostEditFields;
|
||||
});
|
||||
|
||||
export type VaultHostEditFields = {
|
||||
label?: string;
|
||||
group?: string;
|
||||
tags?: Array<string>;
|
||||
};
|
||||
|
||||
export type VaultHostLocalTarget = {
|
||||
resourceId: string;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type VaultHostMetadata = {
|
||||
label: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
group: string;
|
||||
tags: Array<string>;
|
||||
protocol: string;
|
||||
};
|
||||
|
||||
export type VaultHostRef = {
|
||||
kind: "host.selection";
|
||||
scope: "metadata";
|
||||
handle: string;
|
||||
revision: string;
|
||||
expiresAt: SafePositiveInteger;
|
||||
};
|
||||
|
||||
export type VaultHostSelectedResources = Array<{
|
||||
resourceId: string;
|
||||
revision: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
username: string;
|
||||
port?: number;
|
||||
group: string;
|
||||
tags: Array<string>;
|
||||
protocol: string;
|
||||
}>;
|
||||
|
||||
export type VaultHostSelectedResult = {
|
||||
cancelled: false;
|
||||
hosts: Array<{
|
||||
target: VaultHostRef;
|
||||
metadata: VaultHostMetadata;
|
||||
}>;
|
||||
groups: Array<{
|
||||
path: string;
|
||||
members: Array<VaultHostRef>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type VaultHostSelectionResult = ({
|
||||
cancelled: true;
|
||||
}) | (VaultHostSelectedResult);
|
||||
|
||||
export type VaultPagesFeature = "views.vault-pages";
|
||||
|
||||
export type VaultSelectHostsParams = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type ViewContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
|
||||
@@ -58,7 +58,6 @@ export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const
|
||||
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
export const PLUGIN_HOST_VAULT_FEATURE = "vault.host-actions" as const satisfies import("./generated/plugin-contract.js").HostVaultFeature;
|
||||
|
||||
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
|
||||
|
||||
@@ -24,6 +24,33 @@ const testSecretRef: SecretRef = {
|
||||
key: "token",
|
||||
};
|
||||
|
||||
test("byte streams and AI/context clients expose only canonical requests", () => {
|
||||
assertSdkTypeChecks(`
|
||||
import type { PluginContext } from "./index.ts";
|
||||
declare const context: PluginContext;
|
||||
async function useCapabilities() {
|
||||
const connection = await context.network.connect({ endpoint: "tls://example.test:443" });
|
||||
await connection.writable.write(new Uint8Array([1]));
|
||||
await connection.readable.read();
|
||||
await connection.close();
|
||||
const chat = await context.ai.openChat({});
|
||||
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic" });
|
||||
await context.ai.readTurn({ turnHandle: turn.turnHandle });
|
||||
const selected = await context.selectedContext.list({});
|
||||
await context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
|
||||
// @ts-expect-error source identity is host-owned
|
||||
await context.network.connect({ endpoint: "tcp://example.test:22", sourceOwner: "spoofed" });
|
||||
// @ts-expect-error TLS validation cannot be disabled
|
||||
await context.network.connect({ endpoint: "tls://example.test:443", rejectUnauthorized: false });
|
||||
// @ts-expect-error model selection belongs to the host
|
||||
await context.ai.openChat({ model: "plugin-chosen" });
|
||||
// @ts-expect-error selected context requires a version
|
||||
await context.selectedContext.read({ handle: "opaque" });
|
||||
}
|
||||
void useCapabilities;
|
||||
`);
|
||||
});
|
||||
|
||||
const testSecretStore: PluginSecretStore = {
|
||||
async get() {
|
||||
return testSecretRef;
|
||||
|
||||
@@ -115,15 +115,6 @@ export interface PluginHostUI {
|
||||
input(title: string, initialValue?: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/** Saved-host selection does not grant editing, connecting or session access.
|
||||
* Each action uses the host's separate review and confirmation UI. */
|
||||
export interface PluginVault {
|
||||
selectHosts(params: import("@netcatty/plugin-contract").VaultSelectHostsParams): Promise<import("@netcatty/plugin-contract").VaultHostSelectionResult>;
|
||||
/** Managed SSH-config drafts require the full Vault host editor. */
|
||||
requestHostDraft(params: import("@netcatty/plugin-contract").VaultHostDraftParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
|
||||
requestConnect(params: import("@netcatty/plugin-contract").VaultHostConnectParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
|
||||
}
|
||||
|
||||
export interface PluginClipboard {
|
||||
/** The host previews text only after the user asks, then separately confirms sharing it. */
|
||||
readText(title: string): Promise<string | undefined>;
|
||||
@@ -768,7 +759,6 @@ export interface PluginContext {
|
||||
readonly settings: PluginSettings;
|
||||
readonly commands: PluginCommands;
|
||||
readonly ui: PluginHostUI;
|
||||
readonly vault: PluginVault;
|
||||
readonly clipboard: PluginClipboard;
|
||||
readonly contextKeys: PluginContextKeys;
|
||||
readonly views: PluginViews;
|
||||
@@ -966,7 +956,6 @@ export { PLUGIN_BYTE_STREAM_FEATURE, PLUGIN_BYTE_STREAM_LIMITS } from "@netcatty
|
||||
export { PLUGIN_AI_MODEL_INVOCATION_FEATURE, PLUGIN_SELECTED_CONTEXT_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_HOST_VAULT_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
Reference in new issue
Block a user