Compare commits

...
47 changed files with 3150 additions and 62 deletions

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -92,6 +93,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
<PluginHostActionHost />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
+18
View File
@@ -1298,6 +1298,9 @@ Highlight the focused split pane:
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
@@ -1377,5 +1380,20 @@ Highlight the focused split pane:
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
'plugins.authentication.continue': 'Continue',
'plugins.hostActions.select': 'Share selected host metadata',
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
'plugins.hostActions.create': 'Review host draft',
'plugins.hostActions.edit': 'Review host changes',
'plugins.hostActions.connect': 'Confirm connection',
'plugins.hostActions.fieldDiff': 'Changes to save',
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
};
+18
View File
@@ -1298,6 +1298,9 @@ Resalta el panel dividido enfocado:
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
@@ -1377,5 +1380,20 @@ Resalta el panel dividido enfocado:
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
'plugins.authentication.continue': 'Continuar',
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
'plugins.hostActions.create': 'Revisar borrador de equipo',
'plugins.hostActions.edit': 'Revisar cambios del equipo',
'plugins.hostActions.connect': 'Confirmar conexión',
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
};
+18
View File
@@ -1193,6 +1193,9 @@ export const ruCoreMessages: Messages = {
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
@@ -1272,4 +1275,19 @@ export const ruCoreMessages: Messages = {
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
'plugins.authentication.continue': 'Продолжить',
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
'plugins.hostActions.create': 'Проверить черновик хоста',
'plugins.hostActions.edit': 'Проверить изменения хоста',
'plugins.hostActions.connect': 'Подтвердить подключение',
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
};
+18
View File
@@ -1213,6 +1213,9 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
@@ -1292,5 +1295,20 @@ Vaults 标签保持扁平,不使用这块背景。
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
'plugins.authentication.continue': '继续',
'plugins.hostActions.select': '分享所选主机的元数据',
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
'plugins.hostActions.create': '预览主机草稿',
'plugins.hostActions.edit': '预览主机修改',
'plugins.hostActions.connect': '确认连接',
'plugins.hostActions.fieldDiff': '即将保存的修改',
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
};
+18
View File
@@ -1241,6 +1241,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
@@ -1295,4 +1298,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
'plugins.authentication.continue': '繼續',
'plugins.hostActions.select': '分享所選主機的中繼資料',
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
'plugins.hostActions.create': '預覽主機草稿',
'plugins.hostActions.edit': '預覽主機修改',
'plugins.hostActions.connect': '確認連線',
'plugins.hostActions.fieldDiff': '即將儲存的修改',
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
};
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host, ManagedSource } from '../../domain/models';
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
function fixture() {
const resources = new PluginHostActionResources();
let active = true, authorized = 0, connections = 0, writes = 0;
let beforeRead: (() => void) | undefined;
let beforeCommit: (() => void) | undefined;
let duringAuthorize: (() => void) | undefined;
let storedSources: ManagedSource[] | undefined;
const vault: PluginHostActionVault = {
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
const nextSources = sources(storedSources ?? vault.managedSources);
beforeCommit?.();
await authorize?.();
writes++; vault.hosts = next;
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
},
};
resources.observe(vault.hosts);
const target = resources.metadata(synthetic.id);
const options = {
getVault: () => vault, resources,
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
};
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
storedSources(value: ManagedSource[]) { storedSources = value; },
};
}
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
});
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
const f = fixture();
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].password, synthetic.password);
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
assert.equal(f.vault.hosts[0].label, 'Reviewed');
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
});
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
const f = fixture(); f[point](() => f.cancel());
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
});
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
const f = fixture();
f.beforeRead(() => {
if (change === 'remove') f.vault.hosts = [];
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
if (change === 'scripts') f.vault.snippets = [];
if (change === 'locked') f.cancel();
});
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
});
test('create produces a saved SSH draft without executable or credential injection', async () => {
const f = fixture();
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
assert.equal(await executePluginHostAction(action, f.options), 'saved');
const created = f.vault.hosts[1];
assert.notEqual(created.id, synthetic.id);
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
});
const managedSources: ManagedSource[] = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
const f = fixture();
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
f.vault.readPersistedHosts = async () => [structuredClone(host)];
f.options.resources.observe(f.vault.hosts);
const target = f.options.resources.metadata(host.id);
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
});
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
const f = fixture(); f.vault.managedSources = managedSources;
const action: PluginHostAction = kind === 'create'
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
: { kind, target: f.target, draft: { group: 'B/Child' } };
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
const f = fixture(); f.vault.managedSources = managedSources;
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
assert.equal(f.vault.managedSources, managedSources);
});
test('a managed-source catalog replacement during preparation prevents the write', async () => {
const f = fixture();
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
const f = fixture(); f.storedSources(managedSources);
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
});
@@ -0,0 +1,102 @@
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
import type { PluginHostActionResources } from './pluginHostActionResources';
import type { AppVaultRuntime } from './appRuntimeBridge';
// Application action model. Public RPC types live only in plugin-contract.
export type PluginHostAction =
| { kind: 'create'; draft: PluginHostDraftFields }
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
| { kind: 'connect'; target: { resourceId: string; revision: string } };
export interface PluginHostActionVault {
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
isInitialized: boolean;
readPersistedHosts: () => Promise<Host[]>;
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
}
export interface PluginHostActionExecution {
getVault: () => PluginHostActionVault;
resources: PluginHostActionResources;
assertCurrent: () => void;
authorizeCommit: () => Promise<void>;
connect: (host: Host) => unknown;
}
const sameHost = (left: Host, right: Host) => (
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
);
/** Execute only a host-reviewed action. authorizeCommit claims separate,
* one-use main-process action authority after the final asynchronous work. */
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
const current = () => {
options.assertCurrent();
const vault = options.getVault();
if (!vault.isInitialized) throw new Error('Vault is unavailable');
options.resources.observe(vault.hosts);
return vault;
};
const initialVault = current();
const reviewedGroups = initialVault.groupConfigs;
const reviewedSnippets = initialVault.snippets;
const reviewedSources = initialVault.managedSources;
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
const assertSelected = () => {
const vault = current();
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
throw new Error('Selected host changed');
}
// A connection preview includes inherited and global automation. Any
// settings change while the preview is open requires fresh confirmation.
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
throw new Error('Saved connection settings changed');
}
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
return vault;
};
const persisted = await initialVault.readPersistedHosts();
assertSelected();
if (selected) {
const persistedHost = persisted.find((host) => host.id === selected.id);
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
}
if (action.kind === 'connect') {
await options.authorizeCommit();
assertSelected();
// The existing host connection handler owns authentication, saved scripts,
// and sessions. Its return value never reaches the sandboxed plugin.
options.connect(selected!);
return 'requested';
}
const nextHost = action.kind === 'create'
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
: applyPluginHostEdit(selected!, action.draft);
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
throw new Error('Managed SSH config changes require the Vault host editor');
}
const nextHosts = action.kind === 'create'
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
const keepSources = (sources: ManagedSource[]) => {
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
return sources;
};
const result = await initialVault.commitVaultImportTransaction(
nextHosts, updateGroups, keepSources,
undefined, persisted, undefined,
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
);
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
return 'saved';
}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PluginHostActionResources } from './pluginHostActionResources';
import type { Host } from '../../domain/models';
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const metadata = resources.metadata(first.id);
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
resources.observe([first]);
assert.equal(resources.resolve(first.id, metadata.revision), first);
resources.observe([{ ...first }]);
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
const replaced = resources.metadata(first.id);
resources.observe([]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
resources.observe([first]);
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
});
test('group choices are explicit current members and never expand to later members', () => {
const resources = new PluginHostActionResources();
resources.observe([first]);
const selection = [resources.metadata(first.id)];
resources.observe([first, { ...first, id: 'synthetic-2' }]);
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
assert.equal(resources.resolve(first.id, selection[0].revision), first);
});
@@ -0,0 +1,34 @@
import type { Host } from '../../domain/models';
/** Renderer-local revisions track replacement as well as visible edits. They
* are never authorization handles; only the main-process authority issues one. */
export class PluginHostActionResources {
private entries = new Map<string, { host: Host; revision: string }>();
observe(hosts: readonly Host[]) {
const next = new Map<string, { host: Host; revision: string }>();
for (const host of hosts) {
const existing = this.entries.get(host.id);
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
}
this.entries = next;
}
resolve(id: string, expectedRevision: string): Host {
const entry = this.entries.get(id);
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
return entry.host;
}
metadata(id: string) {
const entry = this.entries.get(id);
if (!entry) throw new Error('Selected host is unavailable');
const host = entry.host;
return {
resourceId: host.id, revision: entry.revision,
label: host.label, hostname: host.hostname, username: host.username,
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
group: host.group ?? '', tags: [...host.tags],
};
}
}
+168
View File
@@ -0,0 +1,168 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppHandlers } from '../app/appHandlersBridge';
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
import { PluginHostActionResources } from './pluginHostActionResources';
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
type Review = {
prompt: VaultHostPrompt;
candidates: Host[];
candidateRevisions: Map<string, string>;
customGroups: string[];
host?: Host;
groups: GroupConfig[];
snippets: Snippet[];
managedSources: ManagedSource[];
scriptsRun: boolean;
claimed: boolean;
completionToken?: string;
};
export function usePluginHostActions() {
const [review, setReview] = useState<Review | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
const current = useRef<Review | null>(null);
const resources = useRef(new PluginHostActionResources());
const responding = useRef(false);
const getVault = useCallback(() => {
const vault = getAppVaultRuntime();
const lock = getAppAppLockRuntime();
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
const actionState = vault.readCurrentHostActionState();
resources.current.observe(actionState.hosts);
return { ...vault, ...actionState };
}, []);
const assertCurrent = useCallback((entry: Review) => {
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
const vault = getVault();
const target = entry.prompt.vaultTarget;
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
throw new Error('Saved connection settings changed');
}
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
}, [getVault]);
const fail = useCallback(async (entry: Review) => {
if (current.current !== entry) return;
setError(true);
try {
await netcattyBridge.get()?.respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
});
} catch { /* A stopped runtime or closed window already invalidates it. */ }
}, []);
const execute = useCallback(async (entry: Review) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondPluginInteraction) return fail(entry);
setBusy(true);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const target = entry.prompt.vaultTarget;
let action: PluginHostAction;
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
} else throw new Error('Host action is invalid');
await executePluginHostAction(action, {
getVault, resources: resources.current,
assertCurrent: () => assertCurrent(entry),
authorizeCommit: async () => {
assertCurrent(entry);
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
entry.completionToken = receipt.completionToken;
entry.claimed = true;
assertCurrent(entry);
},
connect: (host) => {
const connect = getAppHandlers()?.handleConnectToHost;
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
return connect(host);
},
});
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
} catch { await fail(entry); }
}, [assertCurrent, fail, getVault]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.prompt.requestId !== event.requestId) return;
current.current = null; setReview(null); setBusy(false); responding.current = false;
return;
}
if (!event.request.kind.startsWith('vault.')) return;
const prompt = event as VaultHostPrompt;
try {
if (prompt.phase === 'commit') {
const entry = current.current;
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
entry.prompt = prompt;
setReview({ ...entry });
void execute(entry);
return;
}
const vault = getVault();
const candidates = [...vault.hosts];
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
const entry: Review = {
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
managedSources: vault.managedSources,
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
claimed: false,
};
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
} catch {
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
}
});
return () => {
const entry = current.current;
current.current = null;
unsubscribe();
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
};
}, [execute, getVault]);
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
const entry = current.current;
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
responding.current = true; setBusy(true); setError(false);
try {
assertCurrent(entry);
const request = entry.prompt.request;
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
const revision = entry.candidateRevisions.get(id);
if (!revision) throw new Error('Host was not part of the current selection');
resources.current.resolve(id, revision);
return resources.current.metadata(id);
}) : undefined;
await netcattyBridge.require().respondPluginInteraction?.({
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
...(selected === undefined ? {} : { resources: selected }),
...(draft === undefined ? {} : { draft }),
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
});
} catch { await fail(entry); }
finally {
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
}
}, [assertCurrent, fail, selectedIds]);
return { review, selectedIds, setSelectedIds, busy, error, respond };
}
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<HostInteractionPrompt | null>(null);
const current = useRef<UiPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -17,7 +18,8 @@ export function usePluginHostInteractions() {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
if (event.request.kind.startsWith('vault.')) return;
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
+13
View File
@@ -776,6 +776,13 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
}
}, [waitForPendingVaultWrites]);
// Host-owned action guards need the synchronous mutation snapshot, including
// edits that React has not published yet. This accessor never crosses RPC.
const readCurrentHostActionState = useCallback(() => ({
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
managedSources: managedSourcesRef.current,
}), []);
const updateKeys = useCallback((data: SSHKey[]) => {
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
@@ -1248,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
lock?: VaultLockHandle | null,
assertAuthorized?: () => void | Promise<void>,
): Promise<
| {
status: "persisted";
@@ -1322,6 +1330,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
encryptHosts(cleanedHosts),
encryptGroupConfigs(nextGroupConfigs),
]);
// Optional plugin draft guard: runtime/source/revoke/lock authority must
// still hold after encryption, immediately before the existing atomic
// persistence boundary. Ordinary imports retain their existing behavior.
if (assertAuthorized) await assertAuthorized();
if (
version !== hostsWriteVersion.current
|| groupConfigsVersion !== groupConfigsWriteVersion.current
@@ -2519,6 +2531,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
groupConfigs,
updateHosts,
readPersistedHosts,
readCurrentHostActionState,
updateKeys,
importOrReuseKey,
updateIdentities,
@@ -0,0 +1,290 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { createRequire } from 'node:module';
import { mkdtemp, cp, rm } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { JSDOM } from 'jsdom';
import { sanitizeHost } from '../domain/host';
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
const require = createRequire(import.meta.url);
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLButtonElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
install('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
const runtime = await import('../application/state/appRuntimeBridge');
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
const listeners = new Set<(event: unknown) => void>();
const prompts: HostInteractionPrompt[] = [];
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
let locked = false;
let onLock: ((state: { locked: boolean }) => void) | undefined;
const service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
getInteractionWindow: () => owner, isAppLocked: () => locked,
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
});
let requestClock = Date.now();
service.interactionService.clock = () => requestClock;
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
} });
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
let hosts: Host[] = [first, second];
let persisted = hosts.map((host) => structuredClone(host));
let beforeCommit: (() => Promise<void>) | undefined;
let saves = 0;
const connections: Host[] = [];
const vault = {
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
const nextSources = sources(vault.managedSources);
await beforeCommit?.();
await authorize();
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
},
};
const publish = () => {
runtime.registerAppVaultRuntime(vault as never);
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
};
publish();
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const waitFor = async (predicate: () => boolean) => {
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
};
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
const begin = async (name: string) => {
requestClock += 60_001;
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
return { promise };
};
const chooseGroup = async () => {
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
};
try {
await service.manager.initialize();
const packageRoot = path.join(directory, 'source');
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(directory, 'fixture.ncpkg');
await buildPluginPackage(packageRoot, archive);
await service.manager.install(archive, { enable: true });
service.interactionService.setReady(sender, true);
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
let selectedTarget: VaultHostRef;
await t.test('group selection shares only explicit current members and grants no connection', async () => {
const run = await begin('select');
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
persisted = hosts.map((host) => structuredClone(host));
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
const result = await run.promise;
assert.ok('hosts' in result);
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
assert.equal(result.hosts[0].target.scope, 'metadata');
selectedTarget = result.hosts[0].target;
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
assert.equal(connections.length, 0); assert.equal(saves, 0);
});
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
const transport = { signal: new AbortController().signal };
for (const target of [
{ handle: 'text-only', version: 'v1', scope: 'host' },
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
});
await t.test('cancel creates no draft or connection', async () => {
const run = await begin('create'); await click(button('Cancel'));
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
});
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
const run = await begin('create');
assert.match(document.body.textContent ?? '', /Changes to save/);
await click(button('Save Host'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
assert.equal(saves, 1);
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
});
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('edit'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
hosts = change === 'remove' ? [second] : [{ ...first }, second];
await click(button('Save Host'));
await assert.rejects(run.promise, /changed|cancelled|complete/);
assert.equal(saves, 1); assert.equal(connections.length, 0);
});
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
const run = await begin('connect'); await chooseGroup();
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(connections.length, 1); assert.equal(connections[0], first);
});
await t.test('Vault lock cancels a pending connection', async () => {
const run = await begin('connect'); await chooseGroup();
await waitFor(() => button('Connect') !== undefined);
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
locked = false; publish();
});
await t.test('revoke during asynchronous preparation prevents the write', async () => {
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
let release!: () => void; let preparing = false;
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
await waitFor(() => preparing);
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
});
await t.test('source replacement during review cannot use the old action authority', async () => {
const run = await begin('create');
const original = service.database.getRetainedPluginSource;
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
finally { service.database.getRetainedPluginSource = original; }
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
vault.managedSources = [
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
];
const run = await begin('edit'); await chooseGroup();
await waitFor(() => button('Save Host') !== undefined);
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
assert.ok(group);
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Save Host')); assert.equal(saves, 1);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
vault.managedSources = [];
});
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
const run = await begin('create');
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
assert.equal(button('Save Host').disabled, true);
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
assert.equal(saves, 1); vault.managedSources = [];
});
for (const transport of [
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
const dispatched: Host[] = [];
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
}), host) });
const run = await begin('connect');
try {
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
assert.equal(dispatched.length, 0);
await click(button('Connect'));
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
assert.equal(dispatched.length, 1);
assert.equal(logs[0].protocol, transport.expected);
} finally {
if (button('Cancel')) await click(button('Cancel'));
await run.promise.catch(() => {});
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
}
});
await t.test('runtime removal rejects old signed replies', async () => {
const run = await begin('create');
const old = prompts.at(-1)!;
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
await assert.rejects(run.promise);
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
assert.equal(saves, 1); assert.equal(connections.length, 1);
});
} finally {
await act(async () => { root.unmount(); await settle(); });
await service.manager.shutdown();
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
dom.window.close(); await rm(directory, { recursive: true, force: true });
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
import type { Host } from '../domain/models';
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
const submitted: PluginHostDraftFields[] = [];
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
assert.equal(dom.document.querySelectorAll('input').length, 3);
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
const save = dom.document.querySelector('button');
assert.ok(save);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
} finally { await renderer.unmount(); dom.cleanup(); }
});
test('invalid create draft cannot be confirmed', async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
try {
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
assert.ok(dom.document.querySelector('[role="alert"]'));
const save = dom.document.querySelector('button');
assert.ok(save?.disabled);
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
} finally { await renderer.unmount(); dom.cleanup(); }
});
+3 -1
View File
@@ -50,6 +50,7 @@ export interface SelectHostPanelContentProps {
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
onSelectionChange?: (selectedHostIds: string[]) => void;
onConfirm: () => void;
confirmDisabled?: boolean;
onNewHost?: () => void;
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
@@ -79,6 +80,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
onSelect,
onSelectionChange,
onConfirm,
confirmDisabled = false,
onNewHost,
availableKeys = [],
identities = [],
@@ -643,7 +645,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
disabled={confirmDisabled || selectedHostIds.length === 0}
onClick={onConfirm}
>
{multiSelect
@@ -0,0 +1,45 @@
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { SelectHostPanelContent } from '../SelectHostPanelContent';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
export function PluginHostActionHost() {
const { t } = useI18n();
const state = usePluginHostActions();
const entry = state.review;
if (!entry) return null;
const request = entry.prompt.request;
const selection = request.kind === 'vault.selectHosts';
const connection = request.kind === 'vault.requestConnect';
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
</DialogHeader>
{selection ? <>
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
</> : connection && effective ? <>
<p className="break-all text-sm font-medium">{effective.label}</p>
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,58 @@
import { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
import type { Host, ManagedSource } from '../../domain/models';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
/** Host-owned editable draft and exact normalized field diff. The containing
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
before?: Host;
draft: PluginHostDraftFields;
busy: boolean;
onConfirm: (draft: PluginHostDraftFields) => void;
managedSources?: ManagedSource[];
}) {
const { t } = useI18n();
const [fields, setFields] = useState<Record<string, string>>(() => ({
label: draft.label ?? before?.label ?? '',
hostname: draft.hostname ?? '', username: draft.username ?? '',
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
tags: (draft.tags ?? before?.tags ?? []).join(', '),
}));
const preview = useMemo(() => {
const value = {
label: fields.label, group: fields.group,
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
};
try {
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
return { value: {
label: after.label, group: after.group ?? '', tags: after.tags,
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
} catch { return null; }
}, [before, fields, managedSources]);
return <div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
<span>{t(`hostForm.field.${field}`)}</span>
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
</label>)}
</div>
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
<dl className="space-y-2 text-xs">
{preview.diff.map((change) => <div key={change.field} className="break-all">
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
<dd>{JSON.stringify(change.before)} &rarr; {JSON.stringify(change.after)}</dd>
</div>)}
</dl>
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
</div>;
}
@@ -104,8 +104,28 @@ test('information previews on hover/focus, pins on click and Escape returns focu
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
await act(async () => { thirdStatus.focus(); await settle(); });
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
assert.match(thirdPreview, /provided by the selected third party/);
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
assert.equal(document.querySelector('[role="tooltip"] button'), null);
await act(async () => { thirdStatus.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
await act(async () => { sourceInfo.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} finally {
await act(async () => root.unmount());
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
@@ -67,7 +67,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
{toolbarContainer ? createPortal(<><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
@@ -98,7 +98,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t('settings.plugins.market.sourceOnly')}</PluginInfo></div>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
+3 -3
View File
@@ -3,12 +3,12 @@ import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry }: { noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t('settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t('settings.plugins.market.note')}</p></>;
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
@@ -0,0 +1,125 @@
# Host and Vault actions
Feature: `vault.host-actions`. The SDK registers three methods under
`context.vault`. Netcatty owns the picker, field-diff review, and connection
confirmation. Plugin business logic stays in the offline browser sandbox.
Base: source integration #3981 at
`4213d8e6726c44e7a479418ece3adf16ea494782`.
The published stream/AI branch point `c4433d496` is preserved through an ordinary
merge. Its owner was still completing integration checks when it was published.
## Reusable boundaries
| Concern | Existing contract | Reuse |
| --- | --- | --- |
| Host picker | `components/SelectHostPanelContent.tsx` | Controlled `selectedHostIds` and `onSelectionChange`; group toggles resolve to explicit host IDs. |
| Typed drafts | `ImporterHostDraft` in `packages/plugin-contract/schema/plugin-contract.schema.json` | Create allowlist: label, hostname, username, port, group, tags. Edit allowlist: label, group, tags. |
| Validation and preview | `normalizePluginImporterRecords`, `buildVaultHostFromDraft` | Validate the raw address, normalize the importer draft, validate the normalized result, and show the normalized field diff. |
| Vault writes | `useVaultState.commitVaultImportTransaction` | Existing shared Vault lock, expected host snapshot, encryption, stale-write checks, and journaled persistence. Optional authorization guard runs after encryption. |
| Connection | `AppSideEffects.handleConnectToHost` | Existing saved-settings, group defaults, authentication, logging, and session creation flow. |
| Script disclosure | `applyGroupDefaults`, `hasHostConnectAutomation`, `snippetAppliesToOutputTrigger` | Boolean disclosure covers inherited startup commands, global/group/host connect scripts, unresolved bindings, host output triggers, and applicable global output scripts. Script contents remain host-owned. |
| Lifecycle | `PluginHostInteractionService`, `PluginPermissionEngine`, runtime route `assertActive` | Existing window ownership, focus, lock, signed replies, cancellation, and revocation patterns. |
| Source identity | `PluginDatabase` | `getActivePlugin`, `getRetainedPluginSource`, `privateOwner`, and `publicPluginId`; no caller-supplied owner or bare-ID fallback. |
## Public methods
| Method | Parameters | Result |
| --- | --- | --- |
| `vault.selectHosts` | `{ title: string }` | `{ cancelled: true }`, or `{ cancelled: false, hosts, groups }`. |
| `vault.requestHostDraft` | Create: `{ draft: { label?, hostname, username?, port?, group?, tags? } }`. Edit: `{ target, expectedRevision, draft: { label?, group?, tags? } }`. | Cancelled, or `{ cancelled: false, status: "saved" }`. |
| `vault.requestConnect` | `{ target, expectedRevision }` | Cancelled, or `{ cancelled: false, status: "requested" }`; no session identifier or content. |
Selected metadata contains label, hostname, username, optional port, protocol,
group path, and tags. Each host result contains `{ target, metadata }`. Host IDs
remain behind the typed target reference; its revision is the expected revision.
Group metadata describes the explicitly selected current member handles. The
grant never expands when another host joins a group.
Selection grants metadata access only. Each draft save and each connection
requires its own host-owned confirmation. A connection confirmation displays the
saved target and whether saved scripts can run. It does not grant terminal input,
terminal output, or session access.
All requests are bounded and cancel on runtime stop, source replacement, revoke,
Vault lock, owner window replacement, expiry, or user cancellation. The host
revalidates authority and expected revision after asynchronous work and before
the Vault write or connection dispatch.
Bounds reuse `HostInteractionLimits`: one pending request per runtime,
eight total, ten per minute, a 60-second confirmation deadline, and a 64-KiB
request ceiling. Selection adds a maximum of 50 current hosts. Titles use the
existing 128-character single-line limit. Draft field lengths use
`ImporterHostDraft`. Unknown properties are rejected by the shared schema.
Creates produce saved SSH drafts. An empty group clears the saved group. Edits
preserve connection endpoints and credentials. Changing a group can change
inherited authentication, proxies, and scripts; the draft review states this.
Drafts involving managed SSH-config hosts or SSH destinations inside managed
groups are explicitly unavailable in this small API. The review disables saving
and directs the user to the Vault host editor. The execution adapter enforces the
same restriction before claiming action authority, using the existing
`applyVaultHostUpdate` ownership rules. It also checks the live and persisted
managed-source catalog for changes before writing. This API does not initiate
managed-file sync or expose plugin file access.
The API excludes Vault and renderer stores, the internal MCP catalog, plaintext
credentials, private keys, TOTP configuration, startup scripts, and arbitrary
execution. Host selection and action UI live in Netcatty. Plugin business logic
stays in the sandboxed plugin.
## Typed target and action authority
`VaultHostRef` is a signed `host.selection` reference with `scope: "metadata"`,
an opaque handle, a revision, and an expiry. It identifies an exact chosen host.
It does not authorize editing, connecting, terminal input, or terminal output.
References bind source, private owner, plugin version, runtime, window owner and
generation, exact host ID, revision, and expiry. They live for five minutes at
most, with at most 500 retained references. Another selection replaces that
runtime's previous references. Lock, revoke, window replacement, and runtime stop
invalidate the affected references. Removal or replacement of a host makes its
renderer revision stale, even if its visible fields are identical.
`PluginAISourceBinding` supplies the existing host-only source identity primitive.
It revalidates after asynchronous authentication. AI selected-context handles are
text-disclosure handles and cannot target these actions.
The permission engine gates selection with `vault.metadata`, drafts with
`vault.write`, and connection requests with `ui.interact`. Every save or connection
also requires a separate host-owned confirmation and a signed one-use action
claim. A stored permission grant cannot bypass this review.
After review, the renderer prepares the existing transaction or connection
request. It claims action authority after the final asynchronous preparation,
immediately before persistence or dispatch. This claim is the action's commit
point. Completion only acknowledges the committed action. Automation changes
during connection review require fresh confirmation.
The connection label uses the same `resolveEffectiveTerminalProtocol` helper as
the launcher, including Mosh/ET flags and implicit localhost shells. Local
execution displays an explicit on-device shell notice and omits the remote SSH
username and port.
## Sample and validation
`examples/plugins/host-actions` adds four commands through the existing command
palette slot. Its create draft uses `synthetic.invalid`; it requests no network,
filesystem, terminal-content, or credential permissions.
`components/PluginHostActions.entrypoint.test.tsx` installs that sample and drives
its actual entrypoint, SDK, RPC registry, source binding, signed replies,
production React dialog, and renderer execution adapter. It covers explicit
selection, cancel, stale revisions, locked Vault, host removal and replacement,
revoke, source replacement, runtime removal, and connection confirmation. Registry
checks reject text handles, tampered references, caller owners, and mismatched
expected revisions. Only synthetic resources are used. The Node transport fixture
does not certify the operating-system sandbox.
Focused action tests compare Mosh, ET, and localhost confirmations with the real
`handleConnectToHostImpl` dispatcher while replacing terminal creation with a
synthetic recorder. Managed A-to-B/outside moves, managed destinations, ordinary
unmanaged saves, and catalog changes use synthetic source records; no SSH config
files are read or written.
Native screenshots and video run on the parent's existing cloud GUI. This branch
does not change Electron sandbox or helper permissions. Tests use synthetic hosts
and credentials. No real host connection or credential access is authorized.
+18 -1
View File
@@ -60,7 +60,7 @@ recovery and uninstall. Cross-source install or update is rejected, even when
version and package bytes match. Local imports are a separate source.
Private settings, view state, persistent and in-memory grants, secrets, and cloud
sidecars are namespaced by host-derived source identity. KV keys are qualified
sidecars are namespaced by host-derived source identity. Every source, including local imports, uses an opaque encoded owner; public plugin IDs are never storage-owner aliases in sync payloads. Before each sync entry and after every awaited contribution update/reset, the host rechecks that owner against the live source and contributions. Hydration captures its owner before yielding and never remaps a public ID for a later write. KV keys are qualified
inside the existing per-plugin FK. Code, permissions and source configuration do
not participate in cloud sync. Old-source cloud settings may remain orphaned;
they cannot hydrate the same plugin ID from a different source. Generic KV still
@@ -82,3 +82,20 @@ Tests use synthetic source responses and first-party packages. Passing tests is
not a sandbox certification. Native UI, real third-party hosting, network
availability and multi-device encrypted-sync acceptance must be reported
separately. No real credentials or untrusted community code are needed for tests.
## Source-owner integration
Use `PluginDatabase.getActivePlugin(context.pluginId)` to require a current host
installation, then `database.privateOwner(plugin.id)` for an opaque data partition.
`plugin.source` is host-owned `{id, kind, name, origin}`.
`getRetainedPluginSource(pluginId)` returns a retained uninstall binding or null.
`publicPluginId(owner)` resolves only an encoded owner matching the current source;
it rejects raw public IDs and stale owners. Never accept owner/source from a plugin
request or invent a `pluginId` fallback. Capture owner/source at async operation
start and revalidate before committing; follow runtime lifecycle cancellation.
Plugin database schema 7 atomically converts existing raw local private rows and
KV keys to the same encoded-owner model without deleting them. This is a one-time
conversion, not a parallel runtime compatibility path. A conflicting conversion
fails without publishing schema 7. Old raw-ID cloud entries remain orphaned and
cannot hydrate an installed plugin. Host/Vault/input schemas are unchanged.
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostFieldDiff, pluginHostRunsSavedScripts } from './pluginHostActions';
import type { Host, Snippet } from './models';
const host: Host = { id: 'synthetic-host', label: 'Synthetic', hostname: 'host.invalid', username: 'test', os: 'linux', tags: [], password: 'synthetic-secret', startupCommand: 'synthetic-script' };
test('host action drafts reuse importer normalization and discard credential/executable fields', () => {
const created = normalizePluginHostDraft({ ...host, password: 'untrusted', protocol: 'local' } as never, 'host-owned-id');
assert.equal(created.id, 'host-owned-id');
assert.equal(created.protocol, 'ssh');
assert.equal(created.password, undefined);
assert.equal(created.startupCommand, undefined);
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid', port: 0 }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid\ninvalid' }, 'id'));
assert.throws(() => normalizePluginHostDraft({ hostname: 'host.invalid other.invalid' }, 'id'));
});
test('edit draft preserves saved connection and credentials and diff includes only approved metadata', () => {
const edited = applyPluginHostEdit(host, { label: ' Renamed ', tags: ['new'], hostname: 'attacker.invalid', password: 'untrusted' } as never);
assert.equal(edited.password, host.password);
assert.equal(edited.hostname, host.hostname);
assert.equal(edited.startupCommand, host.startupCommand);
assert.throws(() => applyPluginHostEdit(host, { label: ' ' }), /required/);
assert.deepEqual(pluginHostFieldDiff(host, edited), [
{ field: 'label', before: 'Synthetic', after: 'Renamed' },
{ field: 'tags', before: [], after: ['new'] },
]);
assert.equal(JSON.stringify(pluginHostFieldDiff(undefined, normalizePluginHostDraft(host, 'id'))).includes('synthetic-secret'), false);
});
test('connection warning covers host, inherited, global and output automation without returning script content', () => {
const clean = { ...host, startupCommand: undefined };
assert.equal(pluginHostRunsSavedScripts(clean, [], []), false);
assert.equal(pluginHostRunsSavedScripts(host, [], []), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, group: 'Synthetic' }, [{ path: 'Synthetic', startupCommand: 'synthetic-group-script' }], []), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'global', label: 'Global', command: 'synthetic', kind: 'script', trigger: 'onConnect', targetsAllHosts: true } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts(clean, [], [{ id: 'output', label: 'Output', command: 'synthetic', kind: 'script', trigger: 'onOutput', triggerPattern: 'ready' } as Snippet]), true);
assert.equal(pluginHostRunsSavedScripts({ ...clean, outputTriggers: [{ id: 't', pattern: 'ready', scriptId: 'synthetic' }] }, [], []), true);
});
+77
View File
@@ -0,0 +1,77 @@
import type { ImporterHostDraft } from '@netcatty/plugin-contract';
import type { GroupConfig, Host, ManagedSource, Snippet } from './models';
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
import { hasHostConnectAutomation } from './hostConnectScripts';
import { normalizePluginImporterRecords } from './pluginImporter';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import { isScriptSnippet } from './snippetScript';
import { snippetAppliesToOutputTrigger } from './snippetTargets';
// Keep the public host draft a subset of the existing typed importer. In
// particular, references to credentials, plugin configuration and executable
// settings are never accepted by this surface.
export type PluginHostDraftFields = Pick<ImporterHostDraft,
'label' | 'hostname' | 'username' | 'port' | 'group' | 'tags'>;
export type PluginHostEditFields = Pick<PluginHostDraftFields, 'label' | 'group' | 'tags'>;
export const PLUGIN_HOST_CREATE_FIELDS = ['label', 'hostname', 'username', 'port', 'group', 'tags'] as const;
export const PLUGIN_HOST_EDIT_FIELDS = ['label', 'group', 'tags'] as const;
export function normalizePluginHostDraft(draft: PluginHostDraftFields, id: string): Host {
const fields = Object.fromEntries(PLUGIN_HOST_CREATE_FIELDS.flatMap((field) => (
draft[field] === undefined ? [] : [[field, draft[field]]]
)));
// Validate before sanitization as well: an invalid address must not become
// a different, valid endpoint when the importer removes control characters.
const raw = { ...fields, hostname: draft.hostname ?? '', protocol: 'ssh' as const };
if (!buildVaultHostFromDraft(raw).ok) throw new Error('Host draft is invalid');
const normalized = normalizePluginImporterRecords([{
type: 'draft', draft: { kind: 'host', value: raw },
}]);
if (normalized.errors.length || normalized.hosts.length !== 1) {
throw new Error('Host draft is invalid');
}
const validated = buildVaultHostFromDraft(normalized.hosts[0]);
if (!validated.ok) throw new Error('Host draft is invalid');
return { ...validated.host, id };
}
export function applyPluginHostEdit(host: Host, draft: PluginHostEditFields): Host {
if (draft.label !== undefined && !draft.label.trim()) throw new Error('Host label is required');
const normalized = normalizePluginHostDraft({
label: draft.label ?? host.label,
hostname: 'draft.invalid', username: '',
group: draft.group ?? host.group, tags: draft.tags ?? host.tags,
}, host.id);
return { ...host, label: normalized.label, group: normalized.group, tags: normalized.tags };
}
export function pluginHostFieldDiff(before: Host | undefined, after: Host) {
const fields = before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS;
return fields.flatMap((field) => {
const previous = before?.[field];
const value = after[field];
return JSON.stringify(previous) === JSON.stringify(value) ? [] : [{
field, before: previous ?? null, after: value ?? null,
}];
});
}
/** Managed SSH-config writes stay in the full Vault editor. Reuse its group
* ownership rules without opening or syncing any managed file here. */
export function pluginHostDraftTouchesManagedSource(host: Host, managedSources: ManagedSource[]): boolean {
if (host.managedSourceId) return true;
if (managedSources.length === 0) return false;
const reconciled = applyVaultHostUpdate([host], [], host.id, { group: host.group ?? '' }, { managedSources });
if (!reconciled.ok) throw new Error('Host draft is invalid');
return Boolean(reconciled.updatedHost.managedSourceId);
}
/** Only the host UI consumes this disclosure; script text never crosses RPC. */
export function pluginHostRunsSavedScripts(host: Host, groups: GroupConfig[], snippets: Snippet[]): boolean {
const effective = applyGroupDefaults(host, host.group ? resolveGroupDefaults(host.group, groups) : {});
return Boolean(effective.startupCommand?.trim())
|| hasHostConnectAutomation(effective, snippets)
|| snippets.some((snippet) => isScriptSnippet(snippet) && snippetAppliesToOutputTrigger(snippet, effective))
|| Boolean(effective.outputTriggers?.length);
}
+4
View File
@@ -11,6 +11,9 @@ const PERMISSION_DESCRIPTIONS = {
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
"ui.interact": [false, "Host prompts", "Ask the host to show bounded input and choice dialogs.", "宿主交互", "请求宿主显示有界的输入和选择对话框。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"ai.invoke": [true, "Use configured AI model", "Send plugin prompts and separately approved selected text to the configured model. Provider charges may apply. Does not grant tools or host actions.", "使用已配置的 AI 模型", "向已配置的模型发送插件提示和另行批准的所选文本。模型提供商可能收费。此权限不授予工具或宿主操作权限。"],
"context.selected.list": [true, "Selected context summaries", "Ask you to share summaries of selected workspace, tab, session, host, or Note references. Does not read their text.", "所选上下文摘要", "请求你分享所选工作区、标签页、会话、主机或笔记引用的摘要。此权限不会读取其文本。"],
"context.selected.read": [true, "Read selected context", "Read text from references you explicitly select and approve. It may contain private data.", "读取所选上下文", "读取你明确选择并批准的引用中的文本,其中可能包含隐私信息。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
@@ -26,6 +29,7 @@ const PERMISSION_DESCRIPTIONS = {
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"network.stream": [true, "TCP and TLS connections", "Send and receive data over TCP or TLS at approved endpoints. Data may leave this device.", "TCP 和 TLS 连接", "通过 TCP 或 TLS 与已批准的端点收发数据。数据可能离开此设备。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
+23 -4
View File
@@ -7,7 +7,7 @@ const { DatabaseSync } = require("node:sqlite");
const { LOCAL_SOURCE, assertSource, privateOwner } = require("./sourceIdentity.cjs");
const SCHEMA_VERSION = 6;
const SCHEMA_VERSION = 7;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
@@ -241,6 +241,25 @@ class PluginDatabase {
}
this.db.exec("PRAGMA user_version = 6");
});
if (version <= 6) this.transaction(() => {
// One-time conversion of existing local private rows; all sources now use
// immutable encoded owners. Never retain a raw-ID lookup fallback.
for (const table of ["plugin_settings", "plugin_view_state", "plugin_permission_grants", "plugin_secrets", "plugin_sync_sidecars"]) {
const rows = this.db.prepare(`SELECT DISTINCT plugin_id FROM ${table}`).all();
for (const { plugin_id: id } of rows) {
if (/^[a-z0-9][a-z0-9.-]{1,127}$/u.test(id)) {
this.db.prepare(`UPDATE ${table} SET plugin_id = ? WHERE plugin_id = ?`).run(privateOwner(LOCAL_SOURCE, id), id);
}
}
}
for (const row of this.db.prepare("SELECT plugin_id, key FROM plugin_kv").all()) {
if (this.getPluginSource(row.plugin_id).id === "local") {
const prefix = `${privateOwner(LOCAL_SOURCE, row.plugin_id)}:`;
if (!row.key.startsWith(prefix)) this.db.prepare("UPDATE plugin_kv SET key = ? WHERE plugin_id = ? AND key = ?").run(prefix + row.key, row.plugin_id, row.key);
}
}
this.db.exec("PRAGMA user_version = 7");
});
// Legacy-map backfill is deferred until PackageStore.recover() has finished
// (hostService seeds after package initialize). Running here would promote
// sole map candidates before recovered manifests are visible, then skip
@@ -299,11 +318,11 @@ class PluginDatabase {
// Opaque owners enter only from host sync storage, never plugin RPC identity.
if (/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(pluginId)) return pluginId;
const source = this.getPluginSource(pluginId);
return source.id === "local" ? pluginId : privateOwner(source, pluginId);
return privateOwner(source, pluginId);
}
publicPluginId(owner) {
if (!owner.startsWith("source:")) return owner;
if (!/^source:[a-f0-9]{64}:[a-z0-9][a-z0-9.-]{1,127}$/u.test(owner)) return null;
const pluginId = owner.slice(owner.lastIndexOf(":") + 1);
return this.privateOwner(pluginId) === owner ? pluginId : null;
}
@@ -989,7 +1008,7 @@ class PluginDatabase {
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
const byProvider = new Map();
for (const row of legacyRows) {
const pluginId = row.plugin_id;
const pluginId = this.publicPluginId(row.plugin_id);
const key = row.key;
if (typeof pluginId !== "string" || typeof key !== "string") continue;
if (!key.startsWith(legacyPrefix)) continue;
+14 -13
View File
@@ -6,6 +6,7 @@ const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { DatabaseSync } = require("node:sqlite");
const { privateOwner, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
const {
MAX_SECURITY_AUDIT_DETAILS_BYTES,
@@ -488,7 +489,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync",
"ref-legacy-map",
Buffer.from("sealed"),
@@ -499,7 +500,7 @@ test("schema upgrade backfills bindings from legacy sync-provider-map secrets",
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-credential",
"ref-cred",
Buffer.from("sealed-cred"),
@@ -532,7 +533,7 @@ test("legacy map backfill does not overwrite an existing host binding", (context
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync.foo",
"ref-stale-parent-map",
Buffer.from("sealed"),
@@ -566,7 +567,7 @@ test("legacy map backfill skips when no candidate holds sync credentials", (cont
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
@@ -589,7 +590,7 @@ test("legacy map backfill binds sole credential-backed owner when maps conflict"
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 1);
@@ -618,7 +619,7 @@ test("legacy map backfill does not let longest map override credential-backed pa
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 1);
@@ -648,7 +649,7 @@ test("legacy map backfill skips when multiple credential-backed candidates exist
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
}
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
@@ -672,7 +673,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync.foo",
"ref-tombstone-map",
Buffer.from("sealed"),
@@ -683,7 +684,7 @@ test("legacy map backfill still consumes maps after unbind tombstone blocks prom
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-credential",
"ref-cred",
Buffer.from("secret"),
@@ -711,7 +712,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.custom",
"ref-plugin-owned",
Buffer.from("plugin-owned-payload"),
@@ -723,7 +724,7 @@ test("legacy map backfill deletes promoted map markers so unbind cannot resurrec
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-credential",
"ref-cred-keep",
Buffer.from("cred"),
@@ -750,7 +751,7 @@ test("legacy map backfill does not resurrect after explicit unbind tombstone", (
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
"com.example",
privateOwner(LOCAL_SOURCE, "com.example"),
"sync-provider-map:com.example.sync",
"ref-leftover-map",
Buffer.from("sealed"),
@@ -823,7 +824,7 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
database.db.prepare(`
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
`).run(privateOwner(LOCAL_SOURCE, pluginId), key, `ref-${pluginId}`, Buffer.from("x"), now, now);
}
// Parent prefix alone is not enough: a removed plugin may have owned
// com.example.backup.sync while only com.example still has credentials.
@@ -231,6 +231,565 @@
},
"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,
@@ -431,6 +990,12 @@
}
},
"additionalProperties": false
},
{
"$ref": "#/$defs/VaultHostSelectedResult"
},
{
"$ref": "#/$defs/VaultHostActionAccepted"
}
]
},
@@ -456,13 +1021,32 @@
"enum": [
"cancel",
"preview",
"submit"
"submit",
"commit",
"complete",
"fail"
]
},
"value": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"resources": {
"$ref": "#/$defs/VaultHostSelectedResources"
},
"draft": {
"oneOf": [
{
"$ref": "#/$defs/VaultHostCreateFields"
},
{
"$ref": "#/$defs/VaultHostEditFields"
}
]
},
"scriptsRun": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -526,16 +1110,37 @@
"phase": {
"enum": [
"request",
"preview"
"preview",
"commit"
]
},
"request": {
"$ref": "#/$defs/HostInteractionRequest"
"oneOf": [
{
"$ref": "#/$defs/HostInteractionRequest"
},
{
"$ref": "#/$defs/VaultHostActionRequest"
}
]
},
"previewText": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"vaultTarget": {
"$ref": "#/$defs/VaultHostLocalTarget"
},
"reviewedDraft": {
"oneOf": [
{
"$ref": "#/$defs/VaultHostCreateFields"
},
{
"$ref": "#/$defs/VaultHostEditFields"
}
]
}
},
"additionalProperties": false
+37 -4
View File
@@ -61,6 +61,7 @@ class PluginHostInteractionService {
this.admissionRevision = 0;
this.focusRestoreMs = Math.min(options.focusRestoreMs ?? 1_000, 1_000);
this.owner = null;
this.ownerGeneration = 0;
this.ownerCleanup = null;
this.closed = false;
this.revocation = this.permissionEngine.onDidRevoke((event) => this.releasePlugin(event.pluginId));
@@ -72,15 +73,19 @@ class PluginHostInteractionService {
metadata: { capability: "ui", mutating: true },
validateParams: assertInteractionRequest,
authorization: (params, context) => {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
this.admitRequest(params, context);
return interactionAuthorization(params);
},
});
}
admitRequest(params, context) {
const sender = this.#admit(params, context);
let guard = this.admissionGuards.get(context.runtimeId);
if (!guard) { guard = { active: true }; this.admissionGuards.set(context.runtimeId, guard); }
this.admissions.set(params, { sender, runtimeId: context.runtimeId, revision: this.admissionRevision, guard });
}
setReady(sender, ready) {
const window = this.getWindow();
if (!sender || window?.webContents !== sender || sender.isDestroyed?.()) {
@@ -92,6 +97,7 @@ class PluginHostInteractionService {
if (this.owner === sender) return true;
if (this.owner) this.releaseWindow(this.owner);
this.owner = sender;
this.ownerGeneration++;
const close = () => this.releaseWindow(sender);
// A native permission dialog can temporarily blur its parent. Already
// displayed interactions still cancel immediately; pre-prompt admission
@@ -144,6 +150,8 @@ class PluginHostInteractionService {
pluginId: entry.context.pluginId, pluginVersion: entry.context.pluginVersion,
expiresAt: entry.expiresAt, phase: entry.phase, request: entry.params,
...(entry.previewText === undefined ? {} : { previewText: entry.previewText }),
...(this.vaultService?.isRequest(entry.params) ? this.vaultService.describePrompt(entry) : {}),
...(entry.reviewedDraft === undefined ? {} : { reviewedDraft: entry.reviewedDraft }),
});
}
@@ -258,6 +266,25 @@ class PluginHostInteractionService {
this.#audit(entry, "cancelled");
return this.#finish(entry, { cancelled: true });
}
if (this.vaultService?.isRequest(entry.params)) {
const outcome = await this.vaultService.respond(entry, response);
await entry.context.assertActive();
this.#assertWindow(sender);
if (this.pending.get(entry.id) !== entry) return false;
if (outcome.result) {
this.#audit(entry, "completed");
return this.#finish(entry, outcome.result);
}
this.#audit(entry, outcome.phase === "commit" ? "action-confirmed" : "action-claimed");
entry.phase = outcome.phase;
if (outcome.reviewedDraft !== undefined) entry.reviewedDraft = outcome.reviewedDraft;
if (entry.phase === "executing") {
entry.actionToken = this.#token(entry);
return { completionToken: entry.actionToken };
}
this.#present(entry);
return true;
}
if (entry.params.kind === "clipboard.read" && entry.phase === "request") {
if (response.action !== "preview") throw fail("invalidArgument", "Clipboard sharing requires a preview action");
this.#audit(entry, "preview-confirmed");
@@ -298,6 +325,8 @@ class PluginHostInteractionService {
releaseWindow(sender) {
if (this.owner !== sender) return;
this.admissionRevision++;
this.ownerGeneration++;
this.vaultService?.clearSelections();
this.owner = null;
this.ownerCleanup?.(); this.ownerCleanup = null;
for (const entry of [...this.pending.values()]) {
@@ -306,6 +335,7 @@ class PluginHostInteractionService {
}
releasePlugin(pluginId) {
this.admissionRevision++;
this.vaultService?.releasePlugin(pluginId);
for (const entry of [...this.pending.values()]) {
if (entry.context.pluginId === pluginId) this.#finish(entry, null, fail("cancelled", "Plugin access was revoked"));
}
@@ -313,6 +343,7 @@ class PluginHostInteractionService {
releaseRuntime(runtimeId) {
this.cancelRuntimeRequests(runtimeId, "Plugin runtime stopped");
this.rates.delete(runtimeId);
this.vaultService?.releaseRuntime(runtimeId);
}
cancelRuntimeRequests(runtimeId, reason = "Host command finished") {
const guard = this.admissionGuards.get(runtimeId);
@@ -324,6 +355,7 @@ class PluginHostInteractionService {
}
cancelAll(invalidateAdmission = true) {
if (invalidateAdmission) this.admissionRevision++;
if (invalidateAdmission) this.vaultService?.clearSelections();
for (const entry of [...this.pending.values()]) this.#finish(entry, null, fail("cancelled", "Host interaction was cancelled"));
}
shutdown() {
@@ -333,6 +365,7 @@ class PluginHostInteractionService {
this.cancelAll(); this.rates.clear(); this.admissionGuards.clear();
this.revocation.dispose(); this.unsubscribeLock?.();
this.key.fill(0);
this.vaultService?.shutdown();
}
}
+10 -1
View File
@@ -31,6 +31,9 @@ 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");
@@ -93,7 +96,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, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const supportedFeatures = [...new Set(["appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
const database = new PluginDatabase(paths.database);
let interactionService;
let byteStreamService;
@@ -198,6 +201,11 @@ function createPluginHostService(options) {
subscribeLock: options.subscribeAppLock,
});
interactionService.registerRpcCapabilities(rpcRegistry);
const hostVaultService = new PluginHostVaultService({
interactionService,
sourceBinding: new PluginAISourceBinding({ database, getRuntimeIdentity: (pluginId) => runtimeSupervisor.getRuntimeIdentity(pluginId) }),
});
hostVaultService.registerRpcCapabilities(rpcRegistry);
const configuredRegistry = options.configureRpcRegistry?.(rpcRegistry);
if (configuredRegistry && typeof configuredRegistry.then === "function") {
throw new TypeError("Plugin host RPC registry configuration must be synchronous");
@@ -422,6 +430,7 @@ function createPluginHostService(options) {
return {
themeService,
interactionService,
hostVaultService,
catalogService: new MarketSourcesService({ database, manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
+153
View File
@@ -0,0 +1,153 @@
"use strict";
const { createHmac, randomBytes } = require("node:crypto");
const { createDefinitionValidator } = require("./contractValidator.cjs");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const fail = (code, message) => new PluginRpcError(RPC_ERRORS[code], message);
const METHODS = ["vault.selectHosts", "vault.requestHostDraft", "vault.requestConnect"];
const MAX_SELECTED = 50;
const MAX_REFERENCES = 500;
const TARGET_LIFETIME_MS = 5 * 60_000;
/** Typed host selection only. Selection references identify a reviewed target;
* the interaction service owns separate operation-specific commit authority. */
class PluginHostVaultService {
constructor({ sourceBinding, interactionService, clock = Date.now }) {
this.sourceBinding = sourceBinding;
this.interactionService = interactionService;
this.clock = clock;
this.key = randomBytes(32);
this.targets = new Map();
this.admissions = new WeakMap();
this.validators = Object.fromEntries([
"VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams",
"VaultHostSelectedResources", "VaultHostCreateFields", "VaultHostEditFields",
].map((name) => [name, createDefinitionValidator(name)]));
interactionService.vaultService = this;
}
isRequest(params) { return METHODS.includes(params?.kind); }
#validate(name, value) {
if (!this.validators[name](value)) throw fail("invalidArgument", "Host action parameters are invalid");
if (Buffer.byteLength(JSON.stringify(value)) > 65_536) throw fail("resourceExhausted", "Host action exceeds its size limit");
return structuredClone(value);
}
registerRpcCapabilities(registry) {
const definitions = ["VaultSelectHostsParams", "VaultHostDraftParams", "VaultHostConnectParams"];
for (const [index, method] of METHODS.entries()) {
registry.registerRequest(method, (params, context) => this.request(params, context), {
metadata: { capability: "vault", mutating: method !== "vault.selectHosts" },
validateParams: (params) => Object.freeze({ ...this.#validate(definitions[index], params), kind: method }),
authorization: async (params, context) => {
const binding = await this.sourceBinding.resolve(context);
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
this.interactionService.admitRequest(params, context);
this.admissions.set(params, binding);
return {
permission: method === "vault.requestHostDraft" ? "vault.write" : method === "vault.selectHosts" ? "vault.metadata" : "ui.interact",
resources: ["*"], allowedScopes: ["once"],
reason: method === "vault.selectHosts" ? "Choose the current host metadata to share" : method === "vault.requestHostDraft" ? "Review a host draft before saving" : "Review a saved host connection and its script disclosure",
operationId: method,
};
},
});
}
}
async request(params, originalContext) {
const binding = await this.sourceBinding.resolve(originalContext);
if (this.admissions.get(params) !== binding) throw fail("permissionDenied", "Host action admission is unavailable");
this.admissions.delete(params);
const assertActive = async () => {
if (await this.sourceBinding.resolve(originalContext) !== binding) throw fail("permissionDenied", "Host action source changed");
if (params.target) this.#resolveTarget(params.target, params.expectedRevision, binding);
};
await assertActive();
return this.interactionService.request(params, Object.freeze({ ...originalContext, assertActive, vaultBinding: binding }));
}
#resolveTarget(ref, expectedRevision, binding) {
const entry = this.targets.get(ref?.handle);
if (!entry || ref.kind !== "host.selection" || ref.scope !== "metadata" || entry.binding !== binding
|| ref.revision !== entry.revision || expectedRevision !== entry.revision || ref.expiresAt !== entry.expiresAt
|| this.clock() >= entry.expiresAt || entry.owner !== this.interactionService.owner
|| entry.generation !== this.interactionService.ownerGeneration) {
throw fail("permissionDenied", "Host selection expired or changed; select the host again");
}
return entry;
}
#issueTarget(resource, context) {
const entry = {
binding: context.vaultBinding, resourceId: resource.resourceId, revision: resource.revision,
owner: this.interactionService.owner, generation: this.interactionService.ownerGeneration,
expiresAt: this.clock() + TARGET_LIFETIME_MS,
};
const nonce = randomBytes(24).toString("base64url");
const signature = createHmac("sha256", this.key).update(JSON.stringify([
nonce, entry.binding, entry.owner.id, entry.generation, entry.resourceId,
entry.revision, entry.expiresAt, "host.selection", "metadata",
])).digest("base64url");
const handle = `${nonce}.${signature}`;
this.targets.set(handle, entry);
return { kind: "host.selection", scope: "metadata", handle, revision: entry.revision, expiresAt: entry.expiresAt };
}
describePrompt(entry) {
if (!entry.params.target) return {};
const target = this.#resolveTarget(entry.params.target, entry.params.expectedRevision, entry.context.vaultBinding);
return { vaultTarget: { resourceId: target.resourceId, revision: target.revision } };
}
async respond(entry, response) {
await entry.context.assertActive();
if (response.action === "fail") throw fail("cancelled", "Host resource changed or the action could not complete");
if (entry.phase === "request" && response.action === "submit") {
if (entry.params.kind === "vault.selectHosts") {
const resources = this.#validate("VaultHostSelectedResources", response.resources);
if (resources.length > MAX_SELECTED || new Set(resources.map((resource) => resource.resourceId)).size !== resources.length) {
throw fail("invalidArgument", "Host selection is invalid");
}
this.releaseSelections(entry.context.vaultBinding);
this.pruneExpired();
if (this.targets.size + resources.length > MAX_REFERENCES) throw fail("resourceExhausted", "Too many host selections");
const hosts = resources.map(({ resourceId, revision, ...metadata }) => ({
target: this.#issueTarget({ resourceId, revision }, entry.context), metadata,
}));
const groups = [...new Set(hosts.map((host) => host.metadata.group).filter(Boolean))].map((path) => ({
path, members: hosts.filter((host) => host.metadata.group === path).map((host) => host.target),
}));
return { result: { cancelled: false, hosts, groups } };
}
if (entry.params.kind === "vault.requestHostDraft") {
const draft = this.#validate(entry.params.target ? "VaultHostEditFields" : "VaultHostCreateFields", response.draft);
return { phase: "commit", reviewedDraft: draft };
}
if (typeof response.scriptsRun !== "boolean") throw fail("invalidArgument", "Connection script disclosure is required");
return { phase: "commit" };
}
if (entry.phase === "commit" && response.action === "commit") return { phase: "executing" };
if (entry.phase === "executing" && response.action === "complete") {
return { result: { cancelled: false, status: entry.params.kind === "vault.requestConnect" ? "requested" : "saved" } };
}
throw fail("invalidArgument", "Host action phase is invalid");
}
pruneExpired() { for (const [handle, entry] of this.targets) if (this.clock() >= entry.expiresAt) this.targets.delete(handle); }
releaseSelections(binding) { for (const [handle, entry] of this.targets) if (entry.binding === binding) this.targets.delete(handle); }
releaseRuntime(runtimeId) {
for (const [handle, entry] of this.targets) if (entry.binding.runtimeId === runtimeId) this.targets.delete(handle);
this.sourceBinding.releaseRuntime(runtimeId);
}
releasePlugin(pluginId) {
for (const [handle, entry] of this.targets) if (entry.binding.pluginId === pluginId) this.targets.delete(handle);
// Keep the source helper's pinned runtime binding until runtime stop. A
// revoked old runtime must never rebind itself to a replacement source.
}
clearSelections() { this.targets.clear(); }
shutdown() { this.targets.clear(); this.sourceBinding.shutdown(); this.key.fill(0); }
}
module.exports = { PluginHostVaultService, METHODS, MAX_SELECTED, TARGET_LIFETIME_MS };
+22 -8
View File
@@ -35,11 +35,16 @@ class PluginSyncSidecarService {
sync: setting.sync === true,
scope: setting.scope,
}));
map.set(this.database.privateOwner?.(plugin.id) ?? plugin.id, fields);
map.set(this.database.privateOwner(plugin.id), fields);
}
return map;
}
#isCurrentOwner(owner, publicId) {
return publicId !== null && this.database.publicPluginId(owner) === publicId
&& this.#declaredSettingsByPlugin().has(owner);
}
/**
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
*/
@@ -170,12 +175,14 @@ class PluginSyncSidecarService {
}
}
for (const entry of resetCandidates) {
const publicId = this.database.publicPluginId(entry.pluginId);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
if (typeof this.contributionService?.resetSetting === "function") {
try {
await this.contributionService.resetSetting(
this.database.publicPluginId?.(entry.pluginId) ?? entry.pluginId,
publicId,
parsed.settingId,
parsed.scopeId,
);
@@ -183,6 +190,7 @@ class PluginSyncSidecarService {
// Validation rejected the reset (e.g. required setting) — still try
// the stored-coordinate delete below for stale-scope rows.
}
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
// resetSetting normalizes against the *current* declared scope. After a
// scope migration (application → device), that may miss the stale row
// still stored under the old coordinates; always delete those too.
@@ -201,6 +209,8 @@ class PluginSyncSidecarService {
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
}
for (const entry of safe) {
const publicId = this.database.publicPluginId(entry.pluginId);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
if (entry.kind !== "settings") continue;
const fields = declared.get(entry.pluginId);
if (!fields) continue;
@@ -218,12 +228,13 @@ class PluginSyncSidecarService {
if (typeof this.contributionService?.updateSetting === "function") {
try {
await this.contributionService.updateSetting(
this.database.publicPluginId?.(entry.pluginId) ?? entry.pluginId,
publicId,
parsed.settingId,
entry.value,
targetScopeId,
{ source: "host" },
);
if (!this.#isCurrentOwner(entry.pluginId, publicId)) continue;
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
this.database.setSetting(
entry.pluginId,
@@ -312,12 +323,14 @@ class PluginSyncSidecarService {
*/
async hydrateInstalledPluginSettings(pluginId) {
if (typeof pluginId !== "string" || pluginId.length < 1) return;
const declared = this.#declaredSettingsByPlugin().get(this.database.privateOwner?.(pluginId) ?? pluginId);
const owner = this.database.privateOwner(pluginId);
const declared = this.#declaredSettingsByPlugin().get(owner);
if (!declared) return;
const rows = typeof this.database.listSyncSidecars === "function"
? this.database.listSyncSidecars(pluginId)
: [];
for (const entry of rows) {
if (!this.#isCurrentOwner(owner, pluginId)) return;
if (entry.kind !== "settings") continue;
const parsed = parseSettingsSidecarKey(entry.key);
if (!parsed) continue;
@@ -347,8 +360,9 @@ class PluginSyncSidecarService {
targetScopeId,
{ source: "host" },
);
if (!this.#isCurrentOwner(owner, pluginId)) return;
this.database.setSetting(
pluginId,
owner,
parsed.settingId,
targetScope,
targetScopeId,
@@ -358,7 +372,7 @@ class PluginSyncSidecarService {
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
try {
this.database.deleteSetting(
pluginId,
owner,
parsed.settingId,
parsed.scope,
parsed.scopeId,
@@ -369,14 +383,14 @@ class PluginSyncSidecarService {
if (typeof this.database.setSyncSidecar === "function") {
try {
this.database.setSyncSidecar(
pluginId,
owner,
"settings",
nextKey,
entry.value,
entry.updatedAt,
);
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
this.database.deleteSyncSidecar(owner, "settings", entry.key);
}
} catch {
// Sidecar re-key is best-effort after settings write.
@@ -33,7 +33,7 @@ test("sidecar table has no package cascade and survives removePlugin", (context)
database.removePlugin("com.example.sync");
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
pluginId: "com.example.sync",
pluginId: database.privateOwner("com.example.sync"),
kind: "account_baseline",
key: "account",
value: { id: "acct" },
@@ -68,7 +68,7 @@ test("collectForSync excludes secrets and preserves missing-plugin baselines", (
const service = new PluginSyncSidecarService({ database, contributionService });
const bundle = service.collectForSync();
assert.equal(bundle.version, 1);
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === database.privateOwner("com.missing.plugin")));
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
// Collected settings must land in the non-cascade table for post-uninstall collect.
@@ -187,7 +187,7 @@ test("collectForSync omits deleted settings for installed plugins but keeps miss
const bundle = service.collectForSync();
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
assert.ok(bundle.entries.some((e) => e.value === "ok"));
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
assert.ok(bundle.entries.some((e) => e.pluginId === database.privateOwner("com.missing.plugin") && e.value === "keep"));
});
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
@@ -210,7 +210,7 @@ test("applyFromSync preserves remote updatedAt when writing settings", async (co
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.sync",
pluginId: database.privateOwner("com.example.sync"),
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
@@ -234,7 +234,7 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.remote.plugin",
pluginId: database.privateOwner("com.remote.plugin"),
kind: "settings",
key: "com.remote.plugin.theme\0application\0application",
value: "light",
@@ -243,8 +243,8 @@ test("applyFromSync does not drop local baselines for plugins absent remotely",
});
const all = database.listAllSyncSidecars();
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.local.only")));
assert.ok(all.some((entry) => entry.pluginId === database.privateOwner("com.remote.plugin") && entry.value === "light"));
});
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
@@ -449,7 +449,7 @@ test("applyFromSync does not resurrect missing-plugin keys deleted on another de
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.missing.plugin",
pluginId: database.privateOwner("com.missing.plugin"),
kind: "settings",
key: "com.missing.plugin.kept\0application\0application",
value: "remote-new",
@@ -498,7 +498,7 @@ test("applyFromSync keeps newer local baseline over older remote (LWW)", async (
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.plugin",
pluginId: database.privateOwner("com.example.plugin"),
kind: "crdt_baseline",
key: "crdt",
value: { clock: 50 },
+4
View File
@@ -380,6 +380,9 @@ function createPluginContext(config, client, runtimeApi) {
quickPick: (title, items) => interact({ kind: "quickPick", title, items }),
input: (title, initialValue) => interact({ kind: "input", title, ...(initialValue === undefined ? {} : { initialValue }) }),
};
const vault = Object.fromEntries(["selectHosts", "requestHostDraft", "requestConnect"].map((method) => [
method, (params) => client.request(`vault.${method}`, params, { deadlineMs: PLUGIN_HOST_INTERACTION_LIMITS.timeoutMs }),
]));
const clipboard = {
readText: (title) => interact({ kind: "clipboard.read", title }),
writeText: (text, title) => interact({ kind: "clipboard.write", title, text }).then((value) => value === true),
@@ -574,6 +577,7 @@ function createPluginContext(config, client, runtimeApi) {
ai,
selectedContext,
ui,
vault,
clipboard,
filesystem,
companions,
+125
View File
@@ -6,6 +6,7 @@ const os = require("node:os");
const path = require("node:path");
const { PluginDatabase } = require("./database.cjs");
const { thirdPartySource, LOCAL_SOURCE } = require("./sourceIdentity.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
const { settingsSidecarKey } = require("./pluginSyncSidecarHelpers.cjs");
const id = "example.plugin";
@@ -110,3 +111,127 @@ test("old-source cloud settings remain orphaned and cannot hydrate a new source"
assert.equal(calls[0][0], id);
assert.equal(db.getSetting(id, "greeting", "application", "application"), "new source");
});
test("in-flight sync cannot apply local B after A yields and B changes source", { timeout: 10000 }, async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-sync-race-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
const a = "aa.plugin", b = "bb.plugin";
const put = (pluginId, source) => db.installVersion({ pluginId, version: "1.0.0", manifest: {
id: pluginId, version: "1.0.0", main: { browser: "index.js" },
contributes: { settings: [{ id: `${pluginId}.greeting`, label: "Greeting", control: "text", scope: "application", sync: true }] },
}, archiveSha256: "a".repeat(64), packageRelativePath: `${pluginId}/1.0.0`, source }, { enable: true });
put(a, LOCAL_SOURCE); put(b, LOCAL_SOURCE);
let release, entered;
const waiting = new Promise(resolve => { entered = resolve; });
const paused = new Promise(resolve => { release = resolve; });
const calls = [];
const contributionService = new PluginContributionService({ database: db, runtimeSupervisor: {
async notify(pluginId) {
calls.push(pluginId);
if (pluginId === a) { entered(); await paused; }
},
} });
const service = new PluginSyncSidecarService({ database: db, contributionService });
const entries = [a, b].map(pluginId => ({ pluginId: db.privateOwner(pluginId), kind: "settings", key: settingsSidecarKey(`${pluginId}.greeting`, "application", "application"), value: `OLD ${pluginId}`, updatedAt: 1000 }));
const applying = service.applyFromSync({ version: 1, entries });
await waiting;
db.removePlugin(b); db.clearUninstalledSourceData(b, "local"); put(b, first);
release(); await applying;
assert.deepEqual(calls, [a]);
assert.equal(db.getSetting(b, `${b}.greeting`, "application", "application"), undefined);
});
for (const mode of ["apply", "hydrate", "reset"]) {
test(`${mode} drops post-await writes after the current plugin changes source`, async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-await-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
install(db, LOCAL_SOURCE);
const oldOwner = db.privateOwner(id);
const key = settingsSidecarKey("greeting", "application", "application");
db.setSyncSidecar(id, "settings", key, "OLD", 1000);
db.setSetting(id, "greeting", "application", "application", "OLD", 1000);
let release, entered;
const waiting = new Promise(resolve => { entered = resolve; });
const paused = new Promise(resolve => { release = resolve; });
const service = new PluginSyncSidecarService({ database: db, contributionService: {
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
async updateSetting(pluginId, settingId, value) {
db.setSetting(pluginId, settingId, "application", "application", value);
entered(); await paused;
},
async resetSetting(pluginId, settingId) {
db.deleteSetting(pluginId, settingId, "application", "application");
entered(); await paused;
},
} });
const applying = mode === "hydrate" ? service.hydrateInstalledPluginSettings(id)
: service.applyFromSync({ version: 1, entries: mode === "reset" ? [] : [{ pluginId: oldOwner, kind: "settings", key, value: "OLD REMOTE", updatedAt: 2000 }] });
await waiting;
db.removePlugin(id); db.clearUninstalledSourceData(id, "local"); install(db, first);
db.setSetting(id, "greeting", "application", "application", "NEW", 3000);
release(); await applying;
assert.equal(db.getSetting(id, "greeting", "application", "application"), "NEW");
assert.equal(db.listAllSettings().some(row => row.pluginId === oldOwner), false, "a completed reset must not be undone by stale LWW writes");
});
}
test("local owners are encoded and raw remote IDs never resolve into another source", async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-local-owner-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const db = new PluginDatabase(path.join(root, "db.sqlite"));
t.after(() => db.close());
install(db, LOCAL_SOURCE);
const owner = db.privateOwner(id);
assert.notEqual(owner, id);
assert.equal(db.publicPluginId(id), null);
assert.equal(db.publicPluginId(owner), id);
db.removePlugin(id); db.clearUninstalledSourceData(id, "local"); install(db, first);
assert.equal(db.publicPluginId(owner), null);
const calls = [];
const service = new PluginSyncSidecarService({ database: db, contributionService: {
snapshot: () => ({ plugins: [{ id, settings: [{ id: "greeting", scope: "application", sync: true }] }] }),
updateSetting: (...args) => calls.push(args),
} });
await service.applyFromSync({ version: 1, entries: [{ pluginId: id, kind: "settings", key: settingsSidecarKey("greeting", "application", "application"), value: "RAW OLD LOCAL", updatedAt: 1000 }] });
assert.deepEqual(calls, []);
assert.equal(db.getSetting(id, "greeting", "application", "application"), undefined);
});
test("schema-6 raw local data converts atomically without a runtime fallback", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-owner-upgrade-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
let db = new PluginDatabase(file);
install(db, LOCAL_SOURCE);
db.setSetting(id, "greeting", "application", "application", "RETAIN");
db.setValue(id, "key", "KEEP KV");
const owner = db.privateOwner(id);
db.db.prepare("UPDATE plugin_settings SET plugin_id = ? WHERE plugin_id = ?").run(id, owner);
db.db.prepare("UPDATE plugin_kv SET key = 'key' WHERE plugin_id = ?").run(id);
db.db.exec("PRAGMA user_version = 6");
db.close();
db = new PluginDatabase(file); t.after(() => db.close());
assert.equal(db.getSetting(id, "greeting", "application", "application"), "RETAIN");
assert.equal(db.getValue(id, "key"), "KEEP KV");
assert.deepEqual(db.listKeys(id), ["key"]);
assert.equal(db.listAllSettings()[0].pluginId, owner);
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 7);
});
test("owner conversion conflict preserves schema-6 rows and does not publish schema 7", (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-source-owner-conflict-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "db.sqlite");
const db = new PluginDatabase(file); t.after(() => db.close());
install(db, LOCAL_SOURCE);
db.setSetting(id, "greeting", "application", "application", "ENCODED");
db.db.prepare("INSERT INTO plugin_settings(plugin_id,setting_id,scope,scope_id,value_json,updated_at) VALUES(?, 'greeting', 'application', 'application', ?, 1)").run(id, JSON.stringify("RAW"));
db.db.exec("PRAGMA user_version = 6");
assert.throws(() => new PluginDatabase(file, { database: db.db }), /UNIQUE constraint/);
assert.equal(db.db.prepare("PRAGMA user_version").get().user_version, 6);
assert.equal(db.db.prepare("SELECT count(*) AS count FROM plugin_settings").get().count, 2);
});
+1
View File
@@ -1,3 +1,4 @@
import { mkdir, copyFile } from 'node:fs/promises';
await mkdir(new URL('./dist/', import.meta.url), { recursive: true });
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', import.meta.url));
import { URL } from 'node:url';
+19
View File
@@ -0,0 +1,19 @@
# Host action sample
The sample adds four commands to the command palette. It runs as an offline,
sandboxed browser plugin and requests no network, filesystem, or credential access.
`select` asks Netcatty to share explicitly chosen current hosts. The result contains
saved labels, addresses, usernames, groups, tags, and metadata-only target references.
Group results contain the chosen current members and never include future members.
`create` opens a host-owned SSH draft for `synthetic.invalid`. `edit` proposes a
label change for the first chosen host. Netcatty displays editable fields and the
normalized field diff before either action saves.
`connect` asks Netcatty to confirm a saved-host connection. The confirmation shows
whether saved scripts can run. The acknowledgement contains no session identifier
or terminal data. Selecting metadata never authorizes a connection by itself.
All sample test resources use synthetic hostnames. Native acceptance must use
synthetic hosts and must not establish a real host connection.
+31
View File
@@ -0,0 +1,31 @@
// This offline BrowserWindow plugin has no filesystem, network or credential
// permissions. Netcatty owns every picker, field diff and connection prompt.
export default {
activate(context) {
const select = () => context.vault.selectHosts({ title: 'Share saved host names, addresses, usernames, groups and tags' });
const commands = {
select,
create: () => context.vault.requestHostDraft({ draft: {
label: 'Synthetic SSH host', hostname: 'synthetic.invalid', username: 'example', group: 'Samples', tags: ['synthetic'],
} }),
edit: async () => {
const selection = await select();
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
const host = selection.hosts[0];
return context.vault.requestHostDraft({
target: host.target, expectedRevision: host.target.revision,
draft: { label: `${host.metadata.label} (reviewed)` },
});
},
connect: async () => {
const selection = await select();
if (selection.cancelled || selection.hosts.length === 0) return { cancelled: true };
const target = selection.hosts[0].target;
return context.vault.requestConnect({ target, expectedRevision: target.revision });
},
};
for (const [name, execute] of Object.entries(commands)) {
context.subscriptions.add(context.commands.registerCommand(`com.netcatty.host-actions.${name}`, execute));
}
},
};
@@ -0,0 +1,37 @@
{
"manifestVersion": 1,
"id": "com.netcatty.host-actions",
"name": "host-actions",
"displayName": "Host action sample",
"description": "Explicit host metadata sharing, reviewed SSH drafts, and confirmed saved-host connections.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal <0.2.0" },
"features": { "required": ["vault.host-actions", "commands.interactive"] },
"main": { "browser": "browser.js" },
"activationEvents": [
"onCommand:com.netcatty.host-actions.select",
"onCommand:com.netcatty.host-actions.create",
"onCommand:com.netcatty.host-actions.edit",
"onCommand:com.netcatty.host-actions.connect"
],
"permissions": {
"required": ["commands", "menus"],
"optional": ["vault.metadata", "vault.write", "ui.interact"]
},
"contributes": {
"commands": [
{ "id": "com.netcatty.host-actions.select", "title": "Host sample: share selected metadata", "interactive": true },
{ "id": "com.netcatty.host-actions.create", "title": "Host sample: review a synthetic SSH draft", "interactive": true },
{ "id": "com.netcatty.host-actions.edit", "title": "Host sample: review a host label change", "interactive": true },
{ "id": "com.netcatty.host-actions.connect", "title": "Host sample: confirm a saved-host connection", "interactive": true }
],
"menus": [
{ "command": "com.netcatty.host-actions.select", "location": "commandPalette" },
{ "command": "com.netcatty.host-actions.create", "location": "commandPalette" },
{ "command": "com.netcatty.host-actions.edit", "location": "commandPalette" },
{ "command": "com.netcatty.host-actions.connect", "location": "commandPalette" }
]
}
}
Vendored
+1 -1
View File
@@ -459,7 +459,7 @@ declare global {
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginInteractionReady?(ready: boolean): Promise<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<boolean>;
respondPluginInteraction?(response: import("@netcatty/plugin-contract").HostInteractionResponse): Promise<import("@netcatty/plugin-contract").HostInteractionReceipt>;
onPluginInteraction?(callback: (event: import("@netcatty/plugin-contract").HostInteractionEvent) => void): () => void;
listPlugins?(): Promise<NetcattyInstalledPlugin[]>;
getPluginPermissions?(pluginId: string): Promise<NetcattyPluginPermissionSnapshot>;
+8
View File
@@ -132,6 +132,10 @@
"@vscode/windows-process-tree": "^0.7.0"
}
},
"examples/plugins/assistant": {
"name": "@netcatty/example-assistant-plugin",
"version": "0.1.0"
},
"examples/plugins/hello-netcatty": {
"name": "@netcatty/example-hello-plugin",
"version": "0.1.0",
@@ -4674,6 +4678,10 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@netcatty/example-assistant-plugin": {
"resolved": "examples/plugins/assistant",
"link": true
},
"node_modules/@netcatty/example-hello-plugin": {
"resolved": "examples/plugins/hello-netcatty",
"link": true
@@ -178,6 +178,565 @@
},
"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,
@@ -374,6 +933,12 @@
}
},
"additionalProperties": false
},
{
"$ref": "#/$defs/VaultHostSelectedResult"
},
{
"$ref": "#/$defs/VaultHostActionAccepted"
}
]
},
@@ -399,13 +964,32 @@
"enum": [
"cancel",
"preview",
"submit"
"submit",
"commit",
"complete",
"fail"
]
},
"value": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"resources": {
"$ref": "#/$defs/VaultHostSelectedResources"
},
"draft": {
"oneOf": [
{
"$ref": "#/$defs/VaultHostCreateFields"
},
{
"$ref": "#/$defs/VaultHostEditFields"
}
]
},
"scriptsRun": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -469,16 +1053,37 @@
"phase": {
"enum": [
"request",
"preview"
"preview",
"commit"
]
},
"request": {
"$ref": "#/$defs/HostInteractionRequest"
"oneOf": [
{
"$ref": "#/$defs/HostInteractionRequest"
},
{
"$ref": "#/$defs/VaultHostActionRequest"
}
]
},
"previewText": {
"type": "string",
"maxLength": 4096,
"pattern": "^[^\\u0000]*$"
},
"vaultTarget": {
"$ref": "#/$defs/VaultHostLocalTarget"
},
"reviewedDraft": {
"oneOf": [
{
"$ref": "#/$defs/VaultHostCreateFields"
},
{
"$ref": "#/$defs/VaultHostEditFields"
}
]
}
},
"additionalProperties": false
@@ -333,6 +333,7 @@ test("required resource-scoped permissions declare activation-time bounds", () =
const validate = validator("PluginManifest");
const resourceScoped = [
["network", "https://example.com"],
["network.stream", "tls://example.com:443"],
["filesystem.read", "/tmp/plugin-read"],
["filesystem.write", "/tmp/plugin-write"],
["companion.execute", "com.example.contract-test.helper"],
@@ -365,6 +366,10 @@ test("required resource-scoped permissions declare activation-time bounds", () =
assert.equal(validate({
...validManifest,
permissions: { optional: [permission] },
}), permission !== "network.stream", `${permission}: ${JSON.stringify(validate.errors)}`);
assert.equal(validate({
...validManifest,
permissions: { optional: [{ permission, resources: [resource] }] },
}), true, JSON.stringify(validate.errors));
}
});
@@ -228,18 +228,27 @@ export type HostInteractionPrompt = {
pluginId: PluginId;
pluginVersion: SemanticVersion;
expiresAt: SafePositiveInteger;
phase: "request" | "preview";
request: HostInteractionRequest;
phase: "request" | "preview" | "commit";
request: (HostInteractionRequest) | (VaultHostActionRequest);
previewText?: string;
vaultTarget?: VaultHostLocalTarget;
reviewedDraft?: (VaultHostCreateFields) | (VaultHostEditFields);
};
export type HostInteractionReceipt = (boolean) | ({
completionToken: string;
});
export type HostInteractionRequest = (HostQuickPickRequest) | (HostInputRequest) | (HostClipboardReadRequest) | (HostClipboardWriteRequest);
export type HostInteractionResponse = {
requestId: string;
actionToken: string;
action: "cancel" | "preview" | "submit";
action: "cancel" | "preview" | "submit" | "commit" | "complete" | "fail";
value?: string;
resources?: VaultHostSelectedResources;
draft?: (VaultHostCreateFields) | (VaultHostEditFields);
scriptsRun?: boolean;
};
export type HostInteractionResult = ({
@@ -247,7 +256,7 @@ export type HostInteractionResult = ({
}) | ({
cancelled: false;
value: (string) | (true);
});
}) | (VaultHostSelectedResult) | (VaultHostActionAccepted);
export type HostQuickPickItem = {
id: string;
@@ -263,6 +272,8 @@ export type HostQuickPickRequest = {
export type HostSingleLineInputText = string;
export type HostVaultFeature = "vault.host-actions";
export type IconReference = (ThemeIcon) | (PackageIcon);
export type ImporterDetectPayload = {
@@ -1235,8 +1246,117 @@ export type ThemePackSelection = {
export type ThemePackVariant = "light" | "dark";
export type VaultHostActionAccepted = {
cancelled: false;
status: "saved" | "requested";
};
export type VaultHostActionRequest = ({
kind: "vault.selectHosts";
title: string;
}) | ({
kind: "vault.requestHostDraft";
draft: VaultHostCreateFields;
}) | ({
kind: "vault.requestHostDraft";
target: VaultHostRef;
expectedRevision: string;
draft: VaultHostEditFields;
}) | ({
kind: "vault.requestConnect";
target: VaultHostRef;
expectedRevision: string;
});
export type VaultHostActionResult = ({
cancelled: true;
}) | (VaultHostActionAccepted);
export type VaultHostConnectParams = {
target: VaultHostRef;
expectedRevision: string;
};
export type VaultHostCreateFields = {
label?: string;
hostname: string;
username?: string;
port?: number;
group?: string;
tags?: Array<string>;
};
export type VaultHostDraftParams = ({
draft: VaultHostCreateFields;
}) | ({
target: VaultHostRef;
expectedRevision: string;
draft: VaultHostEditFields;
});
export type VaultHostEditFields = {
label?: string;
group?: string;
tags?: Array<string>;
};
export type VaultHostLocalTarget = {
resourceId: string;
revision: string;
};
export type VaultHostMetadata = {
label: string;
hostname: string;
username: string;
port?: number;
group: string;
tags: Array<string>;
protocol: string;
};
export type VaultHostRef = {
kind: "host.selection";
scope: "metadata";
handle: string;
revision: string;
expiresAt: SafePositiveInteger;
};
export type VaultHostSelectedResources = Array<{
resourceId: string;
revision: string;
label: string;
hostname: string;
username: string;
port?: number;
group: string;
tags: Array<string>;
protocol: string;
}>;
export type VaultHostSelectedResult = {
cancelled: false;
hosts: Array<{
target: VaultHostRef;
metadata: VaultHostMetadata;
}>;
groups: Array<{
path: string;
members: Array<VaultHostRef>;
}>;
};
export type VaultHostSelectionResult = ({
cancelled: true;
}) | (VaultHostSelectedResult);
export type VaultPagesFeature = "views.vault-pages";
export type VaultSelectHostsParams = {
title: string;
};
export type ViewContribution = {
id: ContributionId;
title: LocalizedText;
+1
View File
@@ -58,6 +58,7 @@ export const PLUGIN_AI_MODEL_INVOCATION_FEATURE = "ai.model-invocation" as const
export const PLUGIN_SELECTED_CONTEXT_FEATURE = "context.selected-read" as const satisfies import("./generated/plugin-contract.js").PluginSelectedContextFeature;
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
export const PLUGIN_HOST_VAULT_FEATURE = "vault.host-actions" as const satisfies import("./generated/plugin-contract.js").HostVaultFeature;
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
+11
View File
@@ -115,6 +115,15 @@ export interface PluginHostUI {
input(title: string, initialValue?: string): Promise<string | undefined>;
}
/** Saved-host selection does not grant editing, connecting or session access.
* Each action uses the host's separate review and confirmation UI. */
export interface PluginVault {
selectHosts(params: import("@netcatty/plugin-contract").VaultSelectHostsParams): Promise<import("@netcatty/plugin-contract").VaultHostSelectionResult>;
/** Managed SSH-config drafts require the full Vault host editor. */
requestHostDraft(params: import("@netcatty/plugin-contract").VaultHostDraftParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
requestConnect(params: import("@netcatty/plugin-contract").VaultHostConnectParams): Promise<import("@netcatty/plugin-contract").VaultHostActionResult>;
}
export interface PluginClipboard {
/** The host previews text only after the user asks, then separately confirms sharing it. */
readText(title: string): Promise<string | undefined>;
@@ -759,6 +768,7 @@ export interface PluginContext {
readonly settings: PluginSettings;
readonly commands: PluginCommands;
readonly ui: PluginHostUI;
readonly vault: PluginVault;
readonly clipboard: PluginClipboard;
readonly contextKeys: PluginContextKeys;
readonly views: PluginViews;
@@ -956,6 +966,7 @@ export { PLUGIN_BYTE_STREAM_FEATURE, PLUGIN_BYTE_STREAM_LIMITS } from "@netcatty
export { PLUGIN_AI_MODEL_INVOCATION_FEATURE, PLUGIN_SELECTED_CONTEXT_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_HOST_VAULT_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";