Compare commits

...
31 changed files with 727 additions and 54 deletions

No files matched your search

+3
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.",
+3
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.",
+3
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": "Описание не предоставлено.",
+3
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": "作者暂未提供功能简介。",
+3
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": "作者尚未提供功能簡介。",
@@ -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,86 @@
# Byte stream acceptance evidence
The integration retains #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`,
source fixes through #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`, and
AI service checkpoint #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
The transport delta does not edit the source database or sync implementation.
Draft PR #3988 records the published review head. No merge or release is authorized.
## Completed local checks
The delegated environment uses Node 24.19.0 on Linux.
- `npm run test:plugin-contract`: 90 passed, zero skipped.
- `npm run test:plugin-runtime`: 604 passed, one native Electron test skipped.
Database-specific testing is now entirely in the example directory.
- Final focused transport, source-isolation, installed entrypoint and disposable
database run before the test-file move: 32 passed, zero skipped. After the
move, the same installed database test passed again at its example path.
- `NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs`:
real MariaDB result and server-side INSERT denial passed through both the
core and the ordinary installed example entrypoint. No host ports, fixture
override, real hosts, or real credentials were used by this database test.
Its container, network, package and profile were removed.
- `node --test examples/plugins/database-minimal/panel.test.cjs`: password
clearing before dispatch and safe text rendering passed.
- Production `npm run build`, generated contract drift, changed-source lint,
and whitespace checks passed.
Real broker TLS tests validate trust and the original requested hostname using
a disposable fixture CA. Database traffic is plain TCP inside an internal
Docker network. Neither check proves native BrowserWindow sandbox behavior,
database TLS, device testing, or real model inference. The registered AI/context
entrypoint test uses a synthetic trusted adapter, not the outstanding renderer
wiring owned by the AI task.
The EOF and last-await cancellation regressions each failed before their fix
and passed after it with real loopback sockets. Source changes before grant
publication, source changes during a pending read, and a stream returned after
runtime release have separate passing regressions.
## Reproducible native sample
Run the preparation tool for `tcp://172.30.247.2:3306` as described in
[`examples/plugins/database-minimal/README.md`](../../examples/plugins/database-minimal/README.md).
Two separate staging directories produced the same five-file package:
- Archive bytes: 11,621.
- SHA256: `53b1aebae27d0681d0e93b8f7529f574cc2f176f3ca34eb2eabd1c35cdfb3deb`.
- Content SHA256: `c83c6913f4ea40eae80d7042231b083be7d35b27022d6df24de9ce09022f0016`.
- Delegated artifact: `/workspace/artifacts/plugin-byte-stream/database-minimal.ncpkg`.
The artifact contains no credentials or server setup. Changing its approved
fixture endpoint requires a newly prepared package and a recorded new hash.
## Checks that did not pass locally
`npm run pack:dir` first failed in the existing Cursor SDK preparation step
because npm could not create `/home/agent/.npm/_cacache`. Retrying with npm and
Electron build caches under `/workspace` passed that step, then failed while
rebuilding `@serialport/bindings-cpp`: `@electron/rebuild` hard-codes the
unwritable `/home/agent/.electron-gyp` directory. The native rebuild was not
disabled, HOME was not changed, and no sandbox setting was weakened. Full app
directory packaging remains failed locally and needs the normal CI or native
QA build environment.
The broad `npm test` run reported failures in existing transfer-resume,
Markdown editor, SSH algorithm, SDK/CLI platform, terminal paste, and updater
tests, then stalled without a final aggregate. Its owned process tree was
terminated. It is not reported as a passing full test run.
An independent source-baseline worktree at `4213d8e` ran five affected files in
the same environment with shared dependencies: 145 tests, 118 passed,
24 failed, three skipped. It reproduced failures in transfer-resume,
Markdown editor, SSH algorithms, AI bridge resolution, and updater tests;
updater errors include the unavailable `/home/agent/.netcatty/tmp/Netcatty`.
This diagnoses that tested subset only. It does not classify every broad-suite
failure, replace exact-head CI, or make any failed result successful.
## Pending acceptance
Exact published-head CI and independent review remain pending. The parent owns
sequential native GUI acceptance for streams and the database example, then AI,
then logging integration. The native recipe covers ordinary install/source and
endpoint review, deny/retry/query, cancel/revoke/disable, restart with no saved
password, audit inspection, screenshots, and a short recording. No native GUI
run or screenshot/video result is claimed by this delegated executor.
@@ -1,7 +1,7 @@
# Byte stream integration checkpoint
The branch starts at #3978 `91b8927c56314e68c06e2c008e84559ddfb96d78`.
It integrates source APIs from #3981 `ab8764a31752fd2d226c9d029c4d65498ba7b063`
It integrates source APIs from #3981 `4213d8e6726c44e7a479418ece3adf16ea494782`
and AI service/adapters from #3986 `264f1d983cd1ed83fe4873974a614fff2f47f989`.
No source database or sync implementation is changed by the transport delta.
@@ -106,6 +106,6 @@ connection-management subsystem. See the example README for native setup.
removes the container and network. Database transport is plain TCP inside
that disposable network, not database TLS.
Normal native BrowserWindow, full app/package checks, exact-head independent
review, and screenshot/video acceptance remain separate required evidence.
Do not disable the Chromium sandbox to obtain that evidence.
See `byte-stream-acceptance.md` for the current checks, local packaging blocker,
sample hash, and remaining native/exact-head review work. Do not disable the
Chromium sandbox to obtain that evidence.
+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.
+4
View File
@@ -26,6 +26,10 @@ const PERMISSION_DESCRIPTIONS = {
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"network.stream": [true, "Outgoing byte connections", "Exchange bytes with explicitly approved TCP or TLS endpoints. Sent data leaves this device.", "传出字节连接", "与明确批准的 TCP 或 TLS 端点交换字节。发送的数据会离开此设备。"],
"ai.invoke": [true, "Use the selected AI model", "Send plugin prompts to the model selected in Netcatty. Model usage may incur charges.", "使用选定的 AI 模型", "向 Netcatty 中选定的模型发送插件提示词。模型用量可能产生费用。"],
"context.selected.list": [true, "Selected item summaries", "Read titles and summaries of items currently selected in the host.", "选中项目摘要", "读取宿主当前选中项目的标题和摘要。"],
"context.selected.read": [true, "Read selected text", "Request selected text for review before sharing it with the plugin.", "读取选中文本", "请求选中文本,在分享给插件前由你确认。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
@@ -38,13 +38,13 @@ test("installed browser entrypoint uses registered SDK/RPC streams, denial and r
main: { browser: "browser.js" }, features: { required: ["network.byte-streams", "ai.model-invocation", "context.selected-read"] },
activationEvents: [`onCommand:${id}.run`],
permissions: { required: ["commands"], optional: [{ permission: "network.stream", resources: [endpoint] }, "ai.invoke", "context.selected.list", "context.selected.read"] },
contributes: { commands: ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
contributes: { commands: ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"].map((name) => ({ id: `${id}.${name}`, title: name })) },
};
await fs.writeFile(path.join(packageRoot, "netcatty.plugin.json"), JSON.stringify(manifest));
await fs.writeFile(path.join(packageRoot, "browser.js"), `
export default { activate(context) {
let connection;
for (const name of ["run", "pending", "cancel", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
for (const name of ["run", "pending", "cancel", "cancelRead", "spoof", "ai", "selected", "aiSpoof"]) context.subscriptions.add(context.commands.registerCommand(${JSON.stringify(id)} + "." + name, async () => {
if (name === "aiSpoof") return context.ai.openChat({ sourceOwner: "spoofed" });
if (name === "selected") {
const selected = await context.selectedContext.list({});
@@ -62,6 +62,11 @@ export default { activate(context) {
if (name === "spoof") return context.network.connect({ endpoint: ${JSON.stringify(endpoint)}, sourceOwner: "spoofed" });
connection = await context.network.connect({ endpoint: ${JSON.stringify(endpoint)} });
if (name === "pending") return { connected: true };
if (name === "cancelRead") {
const reading = connection.readable.read();
connection.readable.cancel();
try { await reading; return false; } catch { return true; } finally { await connection.close(); }
}
try {
await connection.writable.write(Uint8Array.of(0, 255, 42));
return Array.from(await connection.readable.read());
@@ -104,6 +109,7 @@ export default { activate(context) {
assert.equal(service.byteStreamService.broker.connections.size, 0);
allow = true;
assert.deepEqual(await run("run"), [0, 255, 42]);
assert.equal(await run("cancelRead"), true);
assert.equal(service.byteStreamService.records.size, 0);
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
await assert.rejects(run("spoof"), /contract/u);
+3 -2
View File
@@ -104,14 +104,15 @@ class PluginByteStreamService {
onClose: (reason) => { if (reason !== "end") this.closeRecord(record); },
});
context.assertCommit();
if (record.closing) { record.output.cancel(); throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled"); }
if (record.closing) throw new PluginRpcError(RPC_ERRORS.cancelled, "Byte stream attachment cancelled");
context.signal?.addEventListener("abort", record.abort, { once: true });
// A cancelled attachment cannot leave a producer running after its RPC.
void this.pump(record).finally(() => context.signal?.removeEventListener("abort", record.abort));
return { attached: true };
} catch (error) {
try { record.output?.cancel(); } catch {}
const alreadyClosing = record.closing;
this.closeRecord(record, error);
if (alreadyClosing) { try { record.output?.cancel(); } catch {} }
throw error;
}
}, { validateParams: validator("ByteStreamHandleRequest"), metadata: { capability: "network.stream", mutating: true, public: true } });
@@ -0,0 +1,97 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const net = require("node:net");
const { once } = require("node:events");
const { PluginByteStreamService } = require("./byteStreamService.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
async function fixture(t, options = {}) {
let peer;
const sockets = new Set();
const server = net.createServer((socket) => { peer = socket; sockets.add(socket); socket.on("error", () => {}); });
server.listen(0, "127.0.0.1");
await once(server, "listening");
let service;
t.after(async () => {
service?.shutdown();
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
let source = "first";
const endpoint = `tcp://127.0.0.1:${server.address().port}`;
const identity = { pluginId: "com.netcatty.stream-source-test", pluginVersion: "0.1.0", runtimeId: "runtime-one",
runtimeKind: "browser", sourceOwner: "opaque-first", sourceId: "first", securityPrincipal: "host-principal",
manifest: { features: { required: ["network.byte-streams"] } },
};
const audit = [];
const writes = [];
const registry = new PluginHostRpcRegistry();
service = new PluginByteStreamService({
database: {
getActivePlugin: () => ({ enabled: true, source: { id: source } }),
getRetainedPluginSource: () => ({ id: source }),
privateOwner: () => `opaque-${source}`,
publicPluginId: (owner) => owner === `opaque-${source}` ? identity.pluginId : null,
recordSecurityAudit: (...args) => audit.push(args),
},
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: options.authorize ?? (async () => {}) },
runtimeSupervisor: { getRuntimeIdentity: () => identity, openStream: options.openStream ?? (async () => ({
write: async (bytes) => writes.push(bytes), end() {}, cancel() {},
})) },
});
service.broker.testLocalEndpoints.add(endpoint);
service.broker.isControlEndpoint = () => false;
service.register(registry);
const routes = registry.createRoutes({ ...identity, assertCurrent() {} });
const call = (method, params) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
return { service, call, endpoint, audit, writes, swap: () => { source = "second"; }, peer: () => peer };
}
test("source changes before grant publication cannot create a socket or grant", async (t) => {
let approve;
let grants = 0;
const f = await fixture(t, { authorize: async (_context, descriptor) => {
await new Promise((resolve) => { approve = resolve; });
descriptor.validateBeforeGrant();
grants++;
} });
const pending = f.call("network.stream.connect", { endpoint: f.endpoint });
while (!approve) await new Promise((resolve) => setImmediate(resolve));
f.swap(); approve();
await assert.rejects(pending, /source binding/u);
assert.equal(grants, 0);
assert.equal(f.peer(), undefined);
assert.equal(f.service.broker.connections.size, 0);
assert.equal(f.audit.length, 0);
});
test("source changes during a real pending read discard bytes and skip the new source audit", async (t) => {
const f = await fixture(t);
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
await f.call("network.stream.attach", { handleId: descriptor.handleId });
f.swap();
f.peer().write("synthetic old-source bytes");
for (let attempt = 0; attempt < 100 && f.service.records.size; attempt++) await new Promise((resolve) => setImmediate(resolve));
assert.equal(f.writes.length, 0);
assert.equal(f.service.records.size, 0);
assert.equal(f.service.broker.connections.size, 0);
assert.deepEqual(f.audit.map((entry) => entry[1]), ["network.stream.open"]);
await assert.rejects(f.call("network.stream.close", { handleId: descriptor.handleId }), /source binding/u);
});
test("a stream opened after runtime release is cancelled before attach can succeed", async (t) => {
let opened;
let cancelled = 0;
const f = await fixture(t, { openStream: () => new Promise((resolve) => { opened = resolve; }) });
const descriptor = await f.call("network.stream.connect", { endpoint: f.endpoint });
const attaching = f.call("network.stream.attach", { handleId: descriptor.handleId });
while (!opened) await new Promise((resolve) => setImmediate(resolve));
f.service.releaseRuntime("runtime-one");
opened({ cancel() { cancelled++; }, write: async () => {}, end() {} });
await assert.rejects(attaching, /cancelled/u);
assert.equal(cancelled, 1);
assert.equal(f.service.records.size, 0);
assert.equal(f.service.broker.connections.size, 0);
});
+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.
+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 },
+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);
});
+11
View File
@@ -5,6 +5,17 @@ const test = require("node:test");
const { PluginStreamRouter } = require("./streamRouter.cjs");
test("outgoing close hook observes idle peer cancellation exactly once", async () => {
const closed = [];
const router = new PluginStreamRouter({ send() {} });
const stream = await router.openOutgoing("idle-close-hook", 1024, { onClose: (reason) => closed.push(reason) });
await router.accept({ frame: { streamId: "idle-close-hook", sequence: 1, kind: "cancel" } });
stream.cancel();
router.close();
assert.deepEqual(closed, ["cancel"]);
assert.equal(router.outgoing.size, 0);
});
test("incoming stream credit is returned only after the consumer releases a chunk", async () => {
const sent = [];
const received = [];
+1
View File
@@ -1,3 +1,4 @@
import { mkdir, copyFile } from 'node:fs/promises';
import { URL } from 'node:url';
await mkdir(new URL('./dist/', import.meta.url), { recursive: true });
await copyFile(new URL('./src/index.js', import.meta.url), new URL('./dist/index.js', import.meta.url));
+111
View File
@@ -0,0 +1,111 @@
# Run the disposable database example
Use the Linux cloud GUI machine assigned by the parent. Keep the existing
Chromium sandbox settings. Use a fresh, isolated Netcatty QA profile. This
example does not use saved SSH or AI credentials.
## Prepare the fixture and package
1. Check out the exact transport PR head and run `npm ci` followed by
`npm run build:plugin-runtime-deps`.
2. Create a dedicated internal Docker network. Do not alter an existing
network if this subnet is already in use.
```sh
docker network create --internal --subnet 172.30.247.0/24 netcatty-plugin-db-qa
docker run --detach --rm --name netcatty-plugin-db-qa \
--network netcatty-plugin-db-qa --ip 172.30.247.2 \
--env MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1 \
mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873
```
3. Wait for `docker exec netcatty-plugin-db-qa mariadb-admin --host=127.0.0.1 ping --silent`
to succeed. The container publishes no host ports and has no external route.
The empty administrative password exists only in this disposable container.
4. Open its local client with
`docker exec -it netcatty-plugin-db-qa mariadb --protocol=socket --user=root`.
Create the fixture below. Replace the password placeholder with a new
synthetic session password. Do not put a real password in this example.
```sql
CREATE DATABASE plugin_demo;
CREATE TABLE plugin_demo.example_result (value VARCHAR(64));
INSERT INTO plugin_demo.example_result VALUES ('synthetic stream result');
CREATE USER 'plugin_demo'@'%' IDENTIFIED BY '<new-synthetic-session-password>';
GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
```
5. Build the normal browser package for this exact endpoint:
```sh
node examples/plugins/database-minimal/prepare.mjs \
tcp://172.30.247.2:3306 /tmp/database-minimal.ncpkg
```
The tool prints the package hash, not credentials. Its temporary staging
directory is removed. Only the manifest, browser bundle, and three view
assets enter the five-file package. No Node preparation tool, server setup,
dependency tree, or credential file enters the package.
If a different private subnet is needed, prepare a new package for its exact
fixture IP. Record the new package hash and review its endpoint permission.
The example page does not accept arbitrary hosts or SQL.
## Run automated entrypoint acceptance
From the repository root, run:
```sh
NETCATTY_BYTE_STREAM_DATABASE_TEST=1 node --test examples/plugins/database-minimal/protocol.live.test.cjs
```
The test creates its own isolated server, random session password, and temporary
package. It exercises both the transport core and the installed example entry
through the registered SDK/RPC path. It removes the container, network, package
and profile afterward. The in-process runtime harness does not prove native
browser sandbox behavior.
## Record native acceptance
1. Start the exact build with the existing sandbox settings and isolated
profile. Install the `.ncpkg` through the ordinary plugin manager. Its source
is a local import, not a claim of official maintenance.
2. Enable the plugin and open **Disposable database example** from its view
action. Record the plugin detail screen, source, version, and exact endpoint
resource. The plugin has a browser entry only.
3. Enter `plugin_demo` and the synthetic session password. The password input
is masked and clears before dispatch. Click **Connect and read** and deny
the endpoint prompt. The page must show a bounded failure and remain usable.
4. Retry and allow only `tcp://172.30.247.2:3306`. Record the permission prompt
and the result `{"columns":["value"],"rows":[["synthetic stream result"]]}`.
Do not record the password or administrative client session.
5. Revoke the stream grant in the manager, retry, and deny. Check that no
connection remains. Test **Cancel**, close/reopen the view, and disable the
plugin during a pending request. Restart with the same QA profile: the
plugin must require the session password again.
6. Inspect generic connection audit records. Record endpoint, open/close and
byte counts; verify that neither the password, SQL nor result body appears.
Verify the account's server-side write denial in the disposable server.
Save screenshots of install/details, denial, endpoint approval and the result,
plus a short recording of denial/retry/query/revoke/disable. This recipe does
not claim that native QA has run. The automated Node entrypoint harness is not
a BrowserWindow sandbox test.
The example database connection is plaintext only inside the disposable
network. It does not test MySQL STARTTLS. Generic TLS transport has separate
real-socket certificate and hostname validation tests. Do not use this example
with real data or an existing database.
## Remove the fixture
Disable and uninstall the example, then remove the temporary container and
network:
```sh
docker rm --force netcatty-plugin-db-qa
docker network rm netcatty-plugin-db-qa
```
The container, its synthetic credential hashes, and data disappear. Remove the
prepared package and isolated QA profile after retaining the required evidence.
@@ -0,0 +1,47 @@
"use strict";
/* global __dirname */
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const test = require("node:test");
test("the example clears session passwords before dispatch and renders result text safely", async () => {
const elements = new Map();
for (const id of ["username", "password", "query", "cancel", "result", "form", "endpoint"]) {
const element = { value: "", textContent: "", disabled: false, listeners: {},
addEventListener(event, listener) { this.listeners[event] = listener; },
};
Object.defineProperty(element, "innerHTML", { set() { throw new Error("HTML output is forbidden"); } });
elements.set(id, element);
}
elements.get("username").value = "plugin_demo";
elements.get("password").value = "synthetic-session-only";
let settle;
let submitted;
vm.runInNewContext(fs.readFileSync(path.join(__dirname, "panel.js"), "utf8"), {
document: { getElementById: (id) => elements.get(id), documentElement: { style: { setProperty() {}, removeProperty() {} } } },
window: { netcattyView: {
onDidChangeEnvironment() {},
executeCommand: async (name, params) => {
if (name.endsWith(".endpoint")) return { endpoint: "tcp://172.30.247.2:3306" };
assert.ok(name.endsWith(".query"));
assert.equal(elements.get("password").value, "");
submitted = params;
return new Promise((resolve) => { settle = resolve; });
},
} },
});
const pending = elements.get("form").listeners.submit({ preventDefault() {} });
assert.equal(submitted.password, "synthetic-session-only");
assert.equal(elements.get("query").disabled, true);
settle({ columns: ["value"], rows: [["<img src=x onerror=alert(1)>"]] });
await pending;
assert.equal(submitted.password, "");
assert.equal(elements.get("password").value, "");
assert.equal(elements.get("query").disabled, false);
assert.match(elements.get("result").textContent, /<img/u);
const html = fs.readFileSync(path.join(__dirname, "panel.html"), "utf8");
assert.match(html, /id="password"[^>]*type="password"/u);
});
@@ -25,7 +25,8 @@ export async function prepareDatabaseExample(targetEndpoint, outputPath) {
for (const file of ["browser.js", "protocol.mjs", "panel.html", "panel.css", "panel.js"]) await fs.copyFile(path.join(root, file), path.join(temporary, file));
await fs.writeFile(path.join(temporary, "fixture-config.mjs"), `export const endpoint = ${JSON.stringify(endpoint)};\n`);
const bundle = await build({ entryPoints: [path.join(temporary, "browser.js")], write: false,
bundle: true, platform: "browser", format: "esm", nodePaths: [path.resolve(root, "../../../node_modules")] });
bundle: true, platform: "browser", format: "esm", minify: true,
nodePaths: [path.resolve(root, "../../../node_modules")] });
await fs.writeFile(path.join(temporary, "browser.js"), bundle.outputFiles[0].contents);
await fs.rm(path.join(temporary, "protocol.mjs"));
await fs.rm(path.join(temporary, "fixture-config.mjs"));
@@ -1,4 +1,5 @@
"use strict";
/* global __dirname */
// Creates only an isolated disposable fixture. Never accepts a user host,
// existing database, database URL, or real credentials from the environment.
@@ -8,7 +9,13 @@ const { execFileSync } = require("node:child_process");
const { randomUUID, randomBytes } = require("node:crypto");
const { pathToFileURL } = require("node:url");
const path = require("node:path");
const { PluginByteStreamBroker } = require("./byteStreamBroker.cjs");
const process = require("node:process");
const { setTimeout } = require("node:timers");
const fs = require("node:fs/promises");
const os = require("node:os");
const { PluginByteStreamBroker } = require("../../../electron/plugins/byteStreamBroker.cjs");
const { createPluginHostService } = require("../../../electron/plugins/hostService.cjs");
const { inProcessBrowserRuntime } = require("../../../electron/plugins/fixtures/inProcessBrowserRuntime.cjs");
const FIXTURE_IMAGE = "mariadb:11.4@sha256:250e1609e93c0e6aecf64bfbab2d84d5e89d2481540fe77447ff6c855c3b1873";
function docker(args, input) {
@@ -23,11 +30,15 @@ test("minimal browser protocol queries a disposable MariaDB through the real byt
const network = `netcatty-stream-test-${suffix}`;
const container = `netcatty-stream-test-${suffix}`;
const sessionPassword = randomBytes(24).toString("hex");
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-db-entry-"));
let service;
docker(["network", "create", "--internal", network]);
let created = false;
t.after(() => {
t.after(async () => {
await service?.manager.shutdown();
if (created) docker(["rm", "--force", container]);
docker(["network", "rm", network]);
await fs.rm(root, { recursive: true, force: true });
});
docker(["run", "--detach", "--rm", "--name", container, "--network", network,
"--env", "MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=1", FIXTURE_IMAGE]);
@@ -59,7 +70,7 @@ GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
const context = { pluginId: "com.netcatty.database-minimal", runtimeId: `fixture-${suffix}`, assertActive: async () => {} };
const { handleId } = await broker.connect(endpoint, context);
const { queryExample, READ_QUERY } = await import(pathToFileURL(path.resolve(
__dirname, "../../examples/plugins/database-minimal/protocol.mjs",
__dirname, "./protocol.mjs",
)).href);
assert.equal(READ_QUERY, "SELECT value FROM plugin_demo.example_result LIMIT 1");
const result = await queryExample({
@@ -69,6 +80,34 @@ GRANT SELECT ON plugin_demo.example_result TO 'plugin_demo'@'%';
}, "plugin_demo", sessionPassword);
assert.deepEqual(result, { columns: ["value"], rows: [["synthetic stream result"]] });
assert.equal(broker.connections.size, 0);
const { prepareDatabaseExample } = await import("./prepare.mjs");
const archive = path.join(root, "database-minimal.ncpkg");
const packed = await prepareDatabaseExample(endpoint, archive);
assert.equal(packed.fileCount, 5);
const decisions = [];
service = createPluginHostService({
app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0" },
electron: {}, env: {},
requestPermissionDecision: async (request) => {
decisions.push(request);
return { requestId: request.requestId, decision: "allow", scope: "application" };
},
});
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
await service.manager.initialize();
await service.manager.install(archive, { enable: true });
const installedResult = await service.contributionService.executeCommand("com.netcatty.database-minimal.query", {
username: "plugin_demo", password: sessionPassword,
});
assert.deepEqual(installedResult, result);
assert.deepEqual(decisions.find((request) => request.permission === "network.stream").resources, [endpoint]);
assert.equal(service.byteStreamService.records.size, 0);
assert.equal(service.byteStreamService.broker.connections.size, 0);
const audits = JSON.stringify(service.database.listSecurityAudit("com.netcatty.database-minimal", 100));
assert.equal(audits.includes(sessionPassword), false);
assert.equal(audits.includes(READ_QUERY), false);
assert.equal(audits.includes("synthetic stream result"), false);
assert.equal(audits.includes("network.stream.open"), true);
// Server-side grants, not a SQL-prefix check, enforce the read-only fixture.
// MYSQL_PWD exists only inside this disposable test exec and is never logged.
assert.throws(() => execFileSync("docker", ["exec", "--interactive", container, "sh", "-c",
+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
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { validateManifestValue } from "./manifest.js";
import { checkPluginCompatibility } from "./compatibility.js";
const manifest = JSON.parse(await readFile(new URL("../../../examples/plugins/database-minimal/netcatty.plugin.json", import.meta.url), "utf8"));
test("byte stream packages require negotiated features and exact resources in optional grants", () => {
assert.equal(validateManifestValue(manifest).valid, true);
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
for (const resources of [["*"], ["tcp://*:3306"], ["tcp://host:3306/"], ["udp://host:3306"], ["https://host"]]) {
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: [{ permission: "network.stream", resources }] } }).valid, false);
}
assert.equal(validateManifestValue({ ...manifest, permissions: { required: ["commands", "views"], optional: ["network.stream"] } }).valid, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: [] }).compatible, false);
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: "1.0.0", features: ["network.byte-streams"] }).compatible, true);
});
+4
View File
@@ -372,6 +372,10 @@ function validateSemantics(manifest: PluginManifest): string[] {
const hasByteStreams = [...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])]
.some((declaration) => permissionName(declaration) === "network.stream");
if (hasByteStreams && !requiredFeatures.has("network.byte-streams")) errors.push("Byte streams require network.byte-streams");
const declaredCapabilityNames = new Set([...(manifest.permissions?.required ?? []), ...(manifest.permissions?.optional ?? [])].map(permissionName));
if (declaredCapabilityNames.has("ai.invoke") && !requiredFeatures.has("ai.model-invocation")) errors.push("AI invocation requires ai.model-invocation");
if ((declaredCapabilityNames.has("context.selected.list") || declaredCapabilityNames.has("context.selected.read"))
&& !requiredFeatures.has("context.selected-read")) errors.push("Selected context requires context.selected-read");
if ((manifest.main.data || manifest.contributes?.themes?.length) && !requiredFeatures.has('appearance.theme-packs')) errors.push('Theme packs require appearance.theme-packs');
for (const theme of manifest.contributes?.themes ?? []) if (!theme.entry.endsWith('.json')) errors.push('Theme palette entry must be JSON');
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
@@ -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,7 +366,7 @@ test("required resource-scoped permissions declare activation-time bounds", () =
assert.equal(validate({
...validManifest,
permissions: { optional: [permission] },
}), true, JSON.stringify(validate.errors));
}), permission !== "network.stream", JSON.stringify(validate.errors));
}
});
+27
View File
@@ -24,6 +24,33 @@ const testSecretRef: SecretRef = {
key: "token",
};
test("byte streams and AI/context clients expose only canonical requests", () => {
assertSdkTypeChecks(`
import type { PluginContext } from "./index.ts";
declare const context: PluginContext;
async function useCapabilities() {
const connection = await context.network.connect({ endpoint: "tls://example.test:443" });
await connection.writable.write(new Uint8Array([1]));
await connection.readable.read();
await connection.close();
const chat = await context.ai.openChat({});
const turn = await context.ai.startTurn({ chatHandle: chat.chatHandle, prompt: "synthetic" });
await context.ai.readTurn({ turnHandle: turn.turnHandle });
const selected = await context.selectedContext.list({});
await context.selectedContext.read({ handle: selected.items[0].handle, version: selected.items[0].version });
// @ts-expect-error source identity is host-owned
await context.network.connect({ endpoint: "tcp://example.test:22", sourceOwner: "spoofed" });
// @ts-expect-error TLS validation cannot be disabled
await context.network.connect({ endpoint: "tls://example.test:443", rejectUnauthorized: false });
// @ts-expect-error model selection belongs to the host
await context.ai.openChat({ model: "plugin-chosen" });
// @ts-expect-error selected context requires a version
await context.selectedContext.read({ handle: "opaque" });
}
void useCapabilities;
`);
});
const testSecretStore: PluginSecretStore = {
async get() {
return testSecretRef;