From bb0c24d5e51a6ddd7241fbf2b17e788eb2ac1831 Mon Sep 17 00:00:00 2001 From: Kang Date: Sat, 3 Oct 2026 16:09:58 +0800 Subject: [PATCH] feat(plugin): add new plugin creation, packaging, and example commands; enhance Vite config and documentation for plugin development --- docs/plugin-development-plan.md | 30 + docs/plugin-development.md | 87 ++ docs/plugin-system-plan.md | 22 + docs/plugin-system-validation.md | 85 ++ docs/plugins.md | 309 ++++++ package.json | 3 + plugins/README.md | 17 + plugins/examples/native-counter/Cargo.lock | 247 +++++ plugins/examples/native-counter/Cargo.toml | 8 + plugins/examples/native-counter/src/main.rs | 19 + .../examples/session-toolbox/manifest.json | 29 + .../examples/session-toolbox/ui/index.html | 86 ++ .../examples/session-toolbox/ui/toolbox.js | 46 + plugins/sdk/nyaterm.d.ts | 48 + plugins/sdk/nyaterm.js | 85 ++ .../sdk/rust/nyaterm-plugin-sdk/Cargo.lock | 239 +++++ .../sdk/rust/nyaterm-plugin-sdk/Cargo.toml | 19 + plugins/sdk/rust/nyaterm-plugin-sdk/README.md | 59 ++ .../sdk/rust/nyaterm-plugin-sdk/src/lib.rs | 480 +++++++++ .../sdk/rust/nyaterm-plugin-sdk/src/wire.rs | 143 +++ plugins/templates/rust/build.mjs | 41 + plugins/templates/rust/main.rs | 22 + plugins/templates/ui/index.html | 32 + scripts/build-plugin-native-example.mjs | 64 ++ scripts/create-plugin.mjs | 131 +++ scripts/create-plugin.node-test.mjs | 72 ++ scripts/package-plugin.mjs | 34 + src-tauri/Cargo.lock | 21 + src-tauri/Cargo.toml | 1 + .../crates/nyaterm-plugin-runtime/Cargo.lock | 964 ++++++++++++++++++ .../crates/nyaterm-plugin-runtime/Cargo.toml | 36 + .../src/bin/nyaterm-plugin.rs | 105 ++ .../src/bin/plugin-fixture.rs | 40 + .../src/bin/sdk-fixture.rs | 77 ++ .../nyaterm-plugin-runtime/src/diagnostics.rs | 288 ++++++ .../crates/nyaterm-plugin-runtime/src/lib.rs | 26 + .../nyaterm-plugin-runtime/src/manifest.rs | 312 ++++++ .../nyaterm-plugin-runtime/src/package.rs | 220 ++++ .../nyaterm-plugin-runtime/src/registry.rs | 396 +++++++ .../nyaterm-plugin-runtime/src/sidecar.rs | 599 +++++++++++ .../tests/sdk_interop.rs | 215 ++++ .../tests/sidecar_process.rs | 117 +++ src-tauri/src/app.rs | 12 + src-tauri/src/cmd/app.rs | 6 + src-tauri/src/cmd/mod.rs | 1 + src-tauri/src/cmd/plugins.rs | 162 +++ src-tauri/src/config/ui.rs | 6 +- src-tauri/src/core/mod.rs | 1 + src-tauri/src/core/plugins/gateway.rs | 372 +++++++ src-tauri/src/core/plugins/ipc_transport.js | 95 ++ src-tauri/src/core/plugins/lifecycle.rs | 84 ++ src-tauri/src/core/plugins/mod.rs | 700 +++++++++++++ .../src/core/plugins/process_ipc_message.js | 46 + src-tauri/src/core/plugins/protocol.rs | 77 ++ src-tauri/src/core/session.rs | 5 +- src-tauri/src/lib.rs | 35 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 20 +- src/components/app/AppPanelContent.tsx | 9 +- .../saved-connections/ConnectionItem.tsx | 2 + src/components/plugins/PluginApprovalHost.tsx | 96 ++ src/components/plugins/PluginCommandHost.tsx | 118 +++ .../plugins/PluginContextMenuItems.tsx | 45 + .../plugins/PluginDiagnostics.test.tsx | 100 ++ src/components/plugins/PluginDiagnostics.tsx | 176 ++++ src/components/plugins/PluginPanel.tsx | 158 +++ src/components/plugins/PluginsPanel.tsx | 376 +++++++ .../terminal/TerminalContextMenu.tsx | 2 + src/context/PluginContext.tsx | 113 ++ src/hooks/useActivityBarController.tsx | 74 +- src/i18n/locales/en.json | 63 ++ src/i18n/locales/ko.json | 63 ++ src/i18n/locales/zh-CN.json | 63 ++ src/i18n/locales/zh-TW.json | 63 ++ src/lib/appWorkspace.ts | 8 +- src/lib/pluginBridge.test.ts | 116 +++ src/lib/pluginBridge.ts | 108 ++ src/lib/pluginIpc.test.ts | 100 ++ src/lib/pluginSdk.test.ts | 71 ++ src/lib/plugins.test.ts | 72 ++ src/lib/plugins.ts | 96 ++ src/main.tsx | 13 +- src/types/plugins.ts | 77 ++ vite.config.ts | 22 +- 84 files changed, 9683 insertions(+), 19 deletions(-) create mode 100644 docs/plugin-development-plan.md create mode 100644 docs/plugin-development.md create mode 100644 docs/plugin-system-plan.md create mode 100644 docs/plugin-system-validation.md create mode 100644 docs/plugins.md create mode 100644 plugins/README.md create mode 100644 plugins/examples/native-counter/Cargo.lock create mode 100644 plugins/examples/native-counter/Cargo.toml create mode 100644 plugins/examples/native-counter/src/main.rs create mode 100644 plugins/examples/session-toolbox/manifest.json create mode 100644 plugins/examples/session-toolbox/ui/index.html create mode 100644 plugins/examples/session-toolbox/ui/toolbox.js create mode 100644 plugins/sdk/nyaterm.d.ts create mode 100644 plugins/sdk/nyaterm.js create mode 100644 plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.lock create mode 100644 plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml create mode 100644 plugins/sdk/rust/nyaterm-plugin-sdk/README.md create mode 100644 plugins/sdk/rust/nyaterm-plugin-sdk/src/lib.rs create mode 100644 plugins/sdk/rust/nyaterm-plugin-sdk/src/wire.rs create mode 100644 plugins/templates/rust/build.mjs create mode 100644 plugins/templates/rust/main.rs create mode 100644 plugins/templates/ui/index.html create mode 100644 scripts/build-plugin-native-example.mjs create mode 100644 scripts/create-plugin.mjs create mode 100644 scripts/create-plugin.node-test.mjs create mode 100644 scripts/package-plugin.mjs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/bin/plugin-fixture.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/bin/sdk-fixture.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/diagnostics.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/manifest.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/package.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/src/sidecar.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/tests/sdk_interop.rs create mode 100644 src-tauri/crates/nyaterm-plugin-runtime/tests/sidecar_process.rs create mode 100644 src-tauri/src/cmd/plugins.rs create mode 100644 src-tauri/src/core/plugins/gateway.rs create mode 100644 src-tauri/src/core/plugins/ipc_transport.js create mode 100644 src-tauri/src/core/plugins/lifecycle.rs create mode 100644 src-tauri/src/core/plugins/mod.rs create mode 100644 src-tauri/src/core/plugins/process_ipc_message.js create mode 100644 src-tauri/src/core/plugins/protocol.rs create mode 100644 src/components/plugins/PluginApprovalHost.tsx create mode 100644 src/components/plugins/PluginCommandHost.tsx create mode 100644 src/components/plugins/PluginContextMenuItems.tsx create mode 100644 src/components/plugins/PluginDiagnostics.test.tsx create mode 100644 src/components/plugins/PluginDiagnostics.tsx create mode 100644 src/components/plugins/PluginPanel.tsx create mode 100644 src/components/plugins/PluginsPanel.tsx create mode 100644 src/context/PluginContext.tsx create mode 100644 src/lib/pluginBridge.test.ts create mode 100644 src/lib/pluginBridge.ts create mode 100644 src/lib/pluginIpc.test.ts create mode 100644 src/lib/pluginSdk.test.ts create mode 100644 src/lib/plugins.test.ts create mode 100644 src/lib/plugins.ts create mode 100644 src/types/plugins.ts diff --git a/docs/plugin-development-plan.md b/docs/plugin-development-plan.md new file mode 100644 index 00000000..34c2dde6 --- /dev/null +++ b/docs/plugin-development-plan.md @@ -0,0 +1,30 @@ +# Plugin development and diagnostics + +This stage completes four deliverables on top of the local plugin host: + +Implemented and verified. See [the development guide](plugin-development.md), +[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md) and +[validation report](plugin-system-validation.md#developer-tooling-and-diagnostics-validation). + +- `pnpm plugin:create --template ui|rust` creates a new, buildable + project without overwriting existing content. Each template includes a manifest, + working entrypoint, SDK integration, build/package instructions and README. +- A standalone Rust native SDK handles identity/API handshake, JSONL and framed + transport, concurrent requests, scoped reverse host calls, structured errors, + timeouts, cooperative cancellation and logging. The existing native counter + and generated Rust projects use the SDK. +- The Plugins panel displays idle/starting/running/stopped/error states for native + backends and distinguishes UI-only and disabled plugins. Users can stop a backend + and see its next invocation activate a fresh process. +- Users can inspect, refresh, copy and clear bounded in-memory logs. Startup, + protocol, crash and invocation errors are retained even during the handshake. + Old processes cannot overwrite the state of a newer activation. Logs are + treated as untrusted text, redact host-known secrets/scope tokens, and are not + persisted or added to backup/cloud sync. + +Verification must exercise generated projects through build/package validation, +the SDK through the real host runtime in both transports (including reverse RPC, +errors, cancellation and crash handling), status/log transitions and retention, +and the frontend diagnostic controls. Type checking, relevant lint and frontend +build remain final gates. Marketplace, connection provider APIs and browser data +stream subscriptions remain separate stages. diff --git a/docs/plugin-development.md b/docs/plugin-development.md new file mode 100644 index 00000000..33313e16 --- /dev/null +++ b/docs/plugin-development.md @@ -0,0 +1,87 @@ +# 插件开发与排错 + +插件安装、授权、运行状态和日志都在 **插件面板** 中操作。 + +## 创建项目 + +在 NyaTerm 仓库根目录执行: + +```sh +# 纯界面插件:查看选中终端的会话信息 +pnpm plugin:create example.session temp/plugins/my-session --template ui +pnpm plugin:pack temp/plugins/my-session temp/plugins/my-session.nyap + +# Rust 插件:界面调用后台,也提供原生命令 +pnpm plugin:create example.hello temp/plugins/my-hello --template rust +node temp/plugins/my-hello/build.mjs +pnpm plugin:pack temp/plugins/my-hello temp/plugins/my-hello.nyap +``` + +创建器拒绝覆盖已有目录。项目包含 manifest、可用入口、SDK 和 README; +Rust 模板还包含编译脚本,编译后自动填入当前平台的可执行文件路径。 +需要 Node 和 Rust 1.94 或更新版本。Rust SDK 使用本地路径依赖,移动项目时 +应一起提供 SDK 或调整 Cargo.toml 中的路径。 + +将生成的 `.nyap` 安装到插件面板,检查权限并启用。UI 模板需先选中已连接终端; +Rust 模板可以打开界面点击运行,也可以直接执行插件卡片上的原生命令。 + +## 编写 Rust 后台 + +入口实现 `Plugin`,由 `Server::from_env()` 启动服务。SDK 负责握手、传输、 +并发、作用域、超时和取消,无需手写 JSON-RPC: + +```rust +#[async_trait] +impl Plugin for MyPlugin { + async fn call(&self, context: Context, method: &str, input: Value) -> Result { + match method { + "ui/session" => { + let session = context.host().session().await?; + context.log("info", "Session inspected").await?; + Ok(session) + } + _ => Err(RpcError::method_not_found(method)), + } + } +} +``` + +以上会话读取需在 manifest 中声明并获授 `session.read`;原生执行需 `native`。 +UI 调用 `NyaTerm.backend("ui/session")`,contributions.commands 中的原生命令 +则使用 manifest 声明的方法。不要向 stdout 打印日志,它专用于协议;使用 +`Context::log` 或 stderr。不要记录口令、令牌、终端输入输出等敏感内容。 + +默认采用 JSONL。将 `backend.transport` 改为 `stdio-framed` 后,SDK 根据宿主 +环境自动切换协议,支持二进制帧;界面流式订阅尚未提供。完整 API 和取消约定 +见 [Rust SDK](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md)。 + +## 查看状态和日志 + +原生后台按需启动并复用进程。卡片显示未启动、启动中、运行中、已停止或运行异常; +禁用插件和仅界面插件有独立标识。RPC 方法返回错误时,只记录调用失败,后台仍可 +继续运行;进程退出、握手或协议失败则显示运行异常。 + +点击 **查看日志** 可刷新、复制和清空。日志包括时间、版本、级别、来源和消息。 +日志弹窗每秒刷新,卡片状态每 2.5 秒刷新;关闭插件管理面板后停止轮询。 +每个插件最多保留 100 条、64 KiB 消息与来源/版本文本,单条消息最多 2 KiB。 +stderr 每次启动最多采集约 64 KiB,超过后继续排空而不保留。 +日志只在内存中保留,退出应用即清除,不进入备份或云同步。 +宿主会遮蔽常见凭据和作用域令牌,但插件作者仍需自行避免写入敏感内容。 + +**停止后台** 保留启用状态和权限,下次调用会启动新进程,进程内计数等状态会重置。 +插件有进行中的请求时会提示忙碌;先结束或关闭该操作,再停止后台。 +禁用、更新、切换版本、锁屏和退出应用也会停止后台。旧进程的延迟消息不会覆盖 +新启动进程的状态。更新插件必须提升版本号,同版本不同内容不能重新安装。 + +## 本地验证 + +```sh +node --test scripts/create-plugin.node-test.mjs +cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml +cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture +pnpm exec vitest run src/components/plugins/PluginDiagnostics.test.tsx --maxWorkers=4 +``` + +`native-counter` 示例已使用 SDK,并升到 1.0.1:执行两次可看到计数递增和日志; +停止后台后再执行,计数从 1 开始。新版安装包为 `temp/plugins/native-counter.nyap`。 +模板编译和打包已在 Windows 验证;其他平台需构建对应二进制并填写 executables。 diff --git a/docs/plugin-system-plan.md b/docs/plugin-system-plan.md new file mode 100644 index 00000000..3afeb477 --- /dev/null +++ b/docs/plugin-system-plan.md @@ -0,0 +1,22 @@ +# NyaTerm plugin system implementation + +The requested plugin system builds on the preceding architecture review. The implementation must provide a usable extension host, local package installation, lifecycle management, permissions, isolated UI, native runtime, documentation and an end-to-end example. + +Implemented as the local tool/panel extension host v1. See the [plugin guide](plugins.md) +and [validation report](plugin-system-validation.md) for the supported APIs, evidence +and remaining desktop release QA limits. + +## Completion requirements + +- A versioned, validated manifest declares panels, commands, menu locations, permissions and optional platform-specific native backends. +- `.nyap` packages can be inspected before installation, safely extracted with strict size/path/checksum validation, installed as immutable versions, enabled/disabled, updated, rolled back and uninstalled. +- Permissions are granted explicitly, enforced in Rust, and checked together with live session scope and application lock state. Native execution is presented as trusted code with OS user permissions. +- Plugin panels integrate with the existing activity bar and panel layouts; plugin commands are discoverable and available in declared context menus. +- Third-party HTML/JS runs in a sandboxed iframe with a restricted CSP and a bridge bound to its own iframe, plugin identity and backend-issued scope token. It cannot invoke arbitrary Tauri commands. +- The host API supports scoped session metadata, recent terminal output, approved command execution, scoped file reads, namespaced settings and declared network origins. Revocation, session closure and window teardown invalidate access. +- Optional sidecars use lazy persistent processes, a versioned initialization handshake, bounded JSON-RPC/binary frames, timeouts, crash cleanup and stop-on-disable/update behavior. +- Updates and removal do not race active requests; plugin state and permissions survive restarts. Plugin layouts survive temporary unavailability. +- A JavaScript SDK, packager, working example plugin and developer documentation cover the installation and authoring workflow. +- Meaningful backend and frontend tests, type checking, relevant linting and a build verify the implementation. A runtime smoke check verifies installation and UI/bridge integration where the environment permits. + +New connection protocols and replacing built-in SSH/SFTP/RDP/VNC are subsequent provider API work described by the review, rather than requirements of this tool/panel extension host. A remote marketplace is also a separate service. Local packages and the extension runtime must be complete independently. diff --git a/docs/plugin-system-validation.md b/docs/plugin-system-validation.md new file mode 100644 index 00000000..4a499de7 --- /dev/null +++ b/docs/plugin-system-validation.md @@ -0,0 +1,85 @@ +# Plugin v1 validation + +Developer tooling and diagnostics were subsequently completed; see +[the development guide](plugin-development.md) and the updated verification below. + +Validated on Windows on 2026-10-03. Scope follows +[the implementation plan](plugin-system-plan.md). + +| Requirement | Implementation and evidence | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Versioned manifest and contributions | Independent runtime validates API/engine versions, paths, permissions, panels and command/menu declarations. Frontend contribution tests verify enabled-version discovery and namespaced IDs. | +| Local package lifecycle | Package/registry tests cover traversal, case collisions, complete checksums, modified content, inspection digest, immutable versions, persisted grants, permission revocation, update, rollback and publisher continuity. | +| Permissions and scope | Rust gateway allowlists capabilities and combines grants with plugin version, window ownership, connected session and lock checks. Approved command/file operations revalidate scope before execution. Permission review is bound to the active version to reject stale dialogs after an update. | +| Isolated UI | Bridge tests cover unrelated/spoofed frames, opaque origin, fixed scope token, arbitrary native command attempts, duplicate IDs, oversized messages and disposal. IPC tests cover top-frame initialization and Tauri binary/channel/fallback serialization. | +| Workspace integration | Unified Plugins management panel, activity panels, floating/multiple panel layouts and terminal/connection menu contributions integrate with existing workspace components. Existing workspace and context-menu tests pass. | +| Native runtime | Real child-process tests exercise handshake identity, persistent reuse, reverse RPC, timeout recovery and crash cleanup. Framed transport tests verify binary round trips and size limits. Activation cancellation and application exit/lock cleanup are implemented. | +| Developer workflow | UI and native examples both build/package. The extracted native package initializes and returns counts 1 then 2 from the same executable process. UI SDK round-trip tests and browser smoke verify the authoring path. | +| Documentation | [Plugin guide](plugins.md) specifies installation, trust, package lifecycle, storage, manifest, UI API, native protocol, limitations and maintenance. | + +Checks completed: + +- `pnpm exec vitest run --maxWorkers=4`: **152 files / 964 tests passed**. + Default high parallelism exceeded the existing AI settings test's five-second + timeout; limiting workers passed the entire suite. Temporary reference projects + under `temp/` are now excluded from test discovery. +- `pnpm build`: frontend type checking, MCP sidecar build and production Vite build + passed. Existing Browserslist age and large-bundle notices remain. +- `pnpm lint`: passed; one existing dependency warning in `CommandSuggestions.tsx` + remains. Plugin frontend files have no lint diagnostics. +- Runtime Cargo tests: **9 unit tests and 2 real-process tests passed**. +- Runtime `cargo clippy --all-targets -- -D warnings`: passed. +- Tauri `cargo check --lib` and `cargo clippy --lib`: passed. Existing project-wide + Clippy warnings remain; plugin-module diagnostics were checked separately. +- Tauri plugin tests: **2 passed**, for atomic lifecycle admission and rendered IPC + script templates. The Windows library-test binary failed in the loader before + running any tests; a temporary copy with a Common Controls v6 activation manifest + ran both successfully. Production application files were not altered for this + workaround. +- Real Chrome smoke using the actual bridge, SDK and Session Toolbox files: + panel loading, selected-session request, theme propagation, opaque message origin, + fixed-scope routing, parent DOM isolation, blocked direct network and absent + child-frame IPC transport all passed. Native host calls were stubbed in this + browser check. + +This validation does not include a manually operated packaged Tauri desktop build +or macOS/Linux WebView testing. The browser smoke uses HTTP asset serving with the +equivalent sandbox/CSP and mocked native commands; it does not exercise Tauri's +custom resource protocol or real terminal execution dialogs. Verify those in +desktop release QA. Native plugins remain trusted OS processes; the current +format has no cryptographic publisher signatures or marketplace. + +## Developer tooling and diagnostics validation + +Completed on Windows, 2026-10-03: + +- Project creation: UI and Rust starters generated successfully. Three Node tests + cover external paths (including spaces), valid SDK references, invalid arguments + and refusal to overwrite existing source. +- Generated UI/Rust packages pass the real package validator. The Rust starter + builds in release mode. Extracted native-counter **1.0.1** and the generated + Rust package both complete their SDK handshake, return counts **1, 2** from a + persistent process, and exit cleanly on stdin EOF. +- Rust SDK: two wire tests cover bounded frames, binary round trips, clean EOF and + truncated input. Runtime: **10 unit tests + 5 process integration tests** pass + with `--features sdk-fixture`. SDK process tests cover JSONL/framed transport, + concurrent and reverse RPC, binary data, structured logs, request cancellation, + cancellation of timed-out reverse host calls, method errors, panic recovery, + crashes, and stderr collected during a failing initialization handshake. +- Diagnostic storage tests cover retention limits, UTF-8 truncation, credential + redaction, stop/restart generations, clearing and removal. The panel distinguishes + UI-only/disabled plugins and backend states; three frontend tests cover stopping, + refresh/copy/clear, untrusted log text and stale responses after locking. +- Frontend full suite: **153 files / 967 tests pass**. `pnpm build` and `pnpm lint` + pass; the existing CommandSuggestions hook warning remains. +- SDK/runtime Clippy with `--all-targets -- -D warnings` passes. Tauri library + Clippy/check passes; there are no plugin-module diagnostics, while existing + project-wide warnings remain. +- `pnpm i18n:check` still reports existing ordering issues in the AI text sections + of all four locales. Plugin sections were formatted independently to preserve + unrelated content. All four locales contain the same diagnostic keys. + +The user reported successful desktop testing of the earlier native-counter +package. The new diagnostics controls have automated frontend coverage; the +updated 1.0.1 package is ready for desktop smoke testing. This does not claim +macOS/Linux validation or a full manual desktop lifecycle test. diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..c9d06896 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,309 @@ +# NyaTerm plugins v1 + +NyaTerm supports locally installed tool and panel extensions. A plugin can add +sandboxed HTML panels, terminal/connection context-menu commands, and an optional +persistent native backend. Built-in connection protocols remain managed by +NyaTerm. Connection/filesystem providers and an online marketplace are future APIs. + +## Install and use + +1. Open the **Plugins** activity panel, choose **Install local package**, and select a `.nyap` file. +2. Review the plugin identity, publisher label and declared permissions. Installation + validates the package and leaves it disabled. +3. Choose **Enable**, select the permissions to grant, and save. A native backend + requires explicit `native` approval. +4. Open its panel or command from the Plugins activity item. Panels also appear in + the activity bar. Declared menu commands appear in terminal/connection menus. + +The terminal selected when a panel opens defines its session scope. Selecting +another terminal recreates that scope. A terminal-menu command is scoped to that +terminal; a saved-connection-menu command has no live terminal scope. It cannot +silently access an unrelated active terminal. Opening a panel from a context menu +keeps that explicit session selection until another open intent replaces it. + +Command execution and file reading each require a separate approval dialog, even +after granting their manifest permissions. Closing the panel or command dialog, +closing its session/window, locking NyaTerm, or changing plugin activation revokes +the associated scope and cancels host requests. Cancellation cannot undo a command +that has already run on the terminal. + +Install a newer package with the same ID to update. Versions are immutable: the +same version cannot be replaced with different contents. Updates and switching +the version selector leave the selected version disabled for review. The old +versions remain available for rollback. Updates, grant changes and uninstall fail +while a plugin request is active; finish or close it, then retry. Disable stops the +native backend. Native processes are also stopped on lock and application exit. + +## Trust and storage + +UI panels run in an iframe with `sandbox="allow-scripts"`, an opaque origin and +a restrictive response CSP. Scripts, styles, images and fonts are local package +assets; direct network access, child frames, form submissions and popups are +blocked. The bridge accepts messages only from its own frame and binds requests +to a Rust-issued plugin/version/window/session scope. Plugins do not receive an +arbitrary Tauri command gateway. On Windows, where Wry injects initialization +scripts into subframes, the native IPC transport is explicitly initialized only +in the top-level document. + +**Native plugins are trusted executables running with your OS user permissions.** +Host permission checks do not sandbox their filesystem, network or subprocess +access. Only enable native packages from a source you trust. SHA-256 checksums +verify consistency and the reviewed package digest prevents a file swap between +inspection and installation; these are not publisher authentication. Publisher +names are self-declared. A changed publisher label cannot overwrite an existing +ID without uninstalling it first, but that continuity check is not a signature. +The local v1 format does not include cryptographic publisher signing. + +Plugin files live in the active runtime's `plugins/` directory (normally +`~/.nyaterm/plugins/`): + +```text +plugins/ + registry.json + vendor.plugin/versions/1.0.0/... + data/vendor.plugin/settings.json +``` + +`storage` holds plaintext, nonsecret JSON preferences, separately for each ID. +Do not store passwords, tokens, terminal commands or terminal output there. +Uninstall removes plugin code and registration; preferences are retained for a +later reinstall. Plugin packages, grants and preferences are excluded from the +existing portable snapshot/backup/cloud-sync payloads. + +## Build the UI example + +For project generation, Rust SDK APIs, status and logs, see the +[development guide](plugin-development.md) and +[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md). + +From the NyaTerm repository (Node/pnpm and Rust required): + +```sh +pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap +``` + +Install the generated file, enable it with the permissions you want to try, select +a live terminal and open **Session Toolbox**. Read output, request a command or +request a UTF-8 file read. The latter two actions show the native host approval UI. +Terminal execution uses the existing execution profile and shell capture engine; +sessions that disable execution retain that restriction. + +The packager includes `ui/nyaterm-sdk.js` automatically. It is reserved and cannot +be overwritten with different bytes. Only `manifest.json`, `ui/`, `assets/` and +`bin/` are collected, so source code/build artifacts outside these directories do +not enter the archive. UI entry assets must be at most 4 MiB each. + +## Manifest + +```json +{ + "contributions": { + "panels": [ + { "id": "overview", "title": "Overview", "entry": "ui/index.html" } + ], + "commands": [ + { + "id": "open-overview", + "title": "Open Overview", + "panel": "overview", + "menus": ["terminal", "connection"] + } + ] + }, + "description": "Tools for a selected terminal", + "engine": ">=1.2.12, <2.0.0", + "id": "example.tools", + "manifestVersion": 1, + "name": "Example Tools", + "permissions": ["session.read", "terminal.read", "storage"], + "publisher": "Example", + "version": "1.0.0" +} +``` + +Unknown fields are rejected. IDs use lowercase ASCII letters, digits, `.`, `_` +and `-`, start with a letter/digit and are at most 128 bytes; `..` is prohibited. +Plugin IDs also contain a dot to separate their namespace. +Versions use canonical SemVer; `engine` uses Rust semver requirements, including +comma-separated comparators. Panel entries are `ui/*.html`. A command references +exactly one panel or a declared `command/*` native method. Panel and command IDs +are unique across the contribution set. Optional menus are `terminal` and +`connection`. Panels use persisted `plugin::` activity IDs and +retain their layout when temporarily disabled or unavailable. + +Limits: 16 panels, 64 commands, 24 permissions including at most 8 exact HTTPS +origins. Package limits: 128 MiB compressed, 256 MiB expanded, 64 MiB per file and +1024 archive entries. Paths must be portable relative paths; traversal, symlinks, +special files, Windows aliases and case collisions are rejected. `checksums.json` +maps every other archive filename, including the manifest, to its SHA-256 hash. +Use the packager to produce this file rather than maintaining hashes by hand. + +## UI SDK and host API + +Load classic scripts from local files. Inline styles are allowed; use a local +bundled JS file rather than a CDN or dynamic imports requiring cross-origin access. + +```html + + +``` + +```js +await NyaTerm.ready; +const session = await NyaTerm.session(); +const { output } = await NyaTerm.terminal.read(100); +document.getElementById("output").textContent = output; +``` + +Use `textContent` when rendering terminal/file/network data. Typings are in +`plugins/sdk/nyaterm.d.ts`. SDK operations return promises and reject on denied +permission, invalid scope, cancellation or runtime errors. + +| SDK method | Host method / permission | Behavior | +| ---------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `session()` | `host/session` / `session.read` | Selected `{id,name,type,connected}` or `null`; no credentials | +| `terminal.read(lines = 100)` | `host/terminal/read` / `terminal.read` | `{output}`; 1–500 recent lines | +| `terminal.execute(command, timeoutMs = 30000)` | `host/terminal/execute` / `terminal.execute` | Individual approval, audit and existing execution engine; 1–120 s execution timeout | +| `filesystem.read(path)` | `host/filesystem/read` / `filesystem.read` | Individual approval; at most 256 KiB UTF-8 text | +| `storage.get(key)` | `host/storage/get` / `storage` | JSON value or `null` | +| `storage.set(key, value)` | `host/storage/set` / `storage` | `null` deletes; otherwise saves nonsecret JSON | +| `network.request(url, options)` | `host/network/request` / `network:https://origin` | GET/POST, declared exact origin, no redirects, 30 s timeout | +| `backend(method, input)` | `ui/*` or declared `command/*` / `native` | Request to persistent backend | + +Execute results contain `output`, `exitCode`, `durationMs`, `timedOut` and +`sourceTruncated`. File reads expose `content` (SSH may return extra file metadata). +Local file paths are confined to the selected session's canonical working +directory. Remote reads require SSH/SFTP; serial and Telnet do not offer file reads. +Storage allows 128 portable-ID keys and 1 MiB serialized JSON per plugin. Network +options are `{method, body, contentType}`; request bodies are capped at 256 KiB and +responses at 1 MiB. Results are `{status, contentType, body}` with text bodies. +URLs cannot contain credentials. HTTPS origins include their nondefault port and +must match exactly; no wildcards, paths or implicit subdomain grants. + +`NyaTerm.context` contains plugin identity/version and CSS theme variables; +`NyaTerm.onContextChange(listener)` returns an unsubscribe function. Theme variables +are applied to the iframe document automatically. This context contains no scope +token. Tokens are managed by the parent bridge. Scopes expire after 30 minutes; +open panels renew at 25 minutes by recreating their document. Preserve needed +nonsecret preferences in plugin storage. + +UI requests are limited to 32 outstanding calls and 1 MiB request parameters. +Host calls have a 180 s envelope including approval; SDK calls time out at 185 s. +An approval dialog times out at 120 s. A native backend request times out at 170 s, +including any reverse host calls, so long commands and approvals share that budget. + +## Native backend protocol + +Build and package a working persistent-process example: + +```sh +pnpm plugin:example:native +pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap +``` + +Install and explicitly grant `native`. Invoke **Increment native counter** from +the Plugins panel twice: its count increases in the same process. Disable/re-enable +and invoke again: activation starts a fresh process. The build script generates +a manifest for the current OS/architecture. Production plugins should bundle the +executables needed for each supported platform: + +```json +{ + "executables": { + "windows-x86_64": "bin/backend.exe", + "linux-x86_64": "bin/backend" + }, + "transport": "stdio-jsonl" +} +``` + +Put that object under `backend`, declare the `native` permission, and declare a +command with `"method": "command/count"`. Accepted platforms are Windows, Linux +and macOS on `x86_64` or `aarch64`. Activation is lazy, per plugin; calls reuse the +process and can run concurrently. stdout is exclusively protocol traffic; write +diagnostics to stderr. Environment inheritance is restricted to PATH, OS/temp and +locale values, plus `NYATERM_PLUGIN_ID`, `NYATERM_PLUGIN_VERSION`, `NYATERM_APP_VERSION`. +The working directory is the immutable installed version directory. + +The host first sends a JSON-RPC 2.0 request: + +```json +{ + "id": 1, + "jsonrpc": "2.0", + "method": "plugin/initialize", + "params": { + "pluginId": "example.tools", + "pluginVersion": "1.0.0", + "appVersion": "1.2.12", + "apiVersion": 1, + "protocolVersion": 1, + "permissions": ["native"] + } +} +``` + +Reply within 10 seconds with matching identity/version/protocol: + +```json +{ + "id": 1, + "jsonrpc": "2.0", + "result": { + "pluginId": "example.tools", + "pluginVersion": "1.0.0", + "protocolVersion": 1 + } +} +``` + +Normal host requests have numeric IDs and params `{scopeToken, input}`. Respond +with the same numeric ID and `result` or `error:{code,message}`. Backends may make +reverse host requests using a **string ID**, one of the host methods in the table, +and params `{scopeToken, input}`. They must propagate the token associated with the +specific call, not keep a global "current session". Host enforcement checks the +token's plugin, live session/window, version, grants and lock state. Other plugins' +tokens are rejected. Backends should handle `$/cancelRequest` notifications for +timed-out numeric host requests. Closing a scope stops waiting for its response; +native code must arrange its own cooperative work cancellation. + +`stdio-jsonl` is one UTF-8 JSON object per newline. `stdio-framed` uses: + +```text +kind: u8 | payload length: u32 little-endian | payload +kind 1: UTF-8 JSON-RPC +kind 2: channel length: u16 little-endian | UTF-8 channel | raw bytes +``` + +Frames are capped at 8 MiB; JSON at 2 MiB; channel names at 128 bytes. The runtime +supports notification/binary subscriptions for future provider integration, but +v1 browser APIs expose request/response calls only. There is no public UI binary +stream or backend event subscription. Do not use those frames to implement a UI +API yet. The runtime bounds queues and pending requests and terminates the child +on protocol failures, failed handshake, disable/update/uninstall, lock or exit. +A crashed backend is activated again on the next call. + +## Maintaining the host + +The independent runtime lives in `src-tauri/crates/nyaterm-plugin-runtime`; Tauri +adapters and permission checks live in `src-tauri/src/core/plugins`. Plugin UI +bridge code and the SDK are separately tested. + +`ipc_transport.js` and `process_ipc_message.js` preserve Tauri 2.11.5 transport +semantics, with a top-frame guard before initializing the invoke key. When +upgrading Tauri, compare these files with its upstream scripts and rerun the IPC +isolation/serialization tests. Do not remove the guard or add `allow-same-origin` +to plugin iframes to work around compatibility issues. + +On Windows, the full Tauri library test executable may need a Common Controls v6 +activation manifest to load its native UI dependencies. An entrypoint-not-found +error before any tests run is a loader failure; check the executable manifest +rather than interpreting it as a failed plugin assertion. + +```sh +pnpm exec vitest run +pnpm build +pnpm lint +cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml +cargo test --manifest-path src-tauri/Cargo.toml --lib core::plugins +``` diff --git a/package.json b/package.json index 62270ea2..96cd22f0 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "build:unlock-rescue": "powershell -ExecutionPolicy Bypass -File scripts/build-unlock-rescue.ps1", "preview": "vite preview", "test": "vitest", + "plugin:create": "node scripts/create-plugin.mjs", + "plugin:pack": "node scripts/package-plugin.mjs", + "plugin:example:native": "node scripts/build-plugin-native-example.mjs", "tauri": "tauri", "lint": "node scripts/check-no-console.mjs && biome lint src/", "format": "biome check --write src/", diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 00000000..f0e46e7d --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,17 @@ +# NyaTerm plugin development + +Read the [plugin guide](../docs/plugins.md) for installation, manifests, +permissions, the UI SDK and native protocol. + +- `sdk/nyaterm.js` and `sdk/nyaterm.d.ts`: browser SDK and typings. +- `examples/session-toolbox`: a sandboxed panel using scoped host capabilities. +- `examples/native-counter`: a persistent Rust command backend. + +```sh +pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap +pnpm plugin:example:native +pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap +``` + +Install the generated packages through the Plugins activity panel and explicitly grant +their requested permissions. Native executables run with OS user permissions. diff --git a/plugins/examples/native-counter/Cargo.lock b/plugins/examples/native-counter/Cargo.lock new file mode 100644 index 00000000..982854ee --- /dev/null +++ b/plugins/examples/native-counter/Cargo.lock @@ -0,0 +1,247 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nyaterm-native-counter" +version = "1.0.1" +dependencies = [ + "nyaterm-plugin-sdk", + "tokio", +] + +[[package]] +name = "nyaterm-plugin-sdk" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures-util", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/examples/native-counter/Cargo.toml b/plugins/examples/native-counter/Cargo.toml new file mode 100644 index 00000000..3d6431b6 --- /dev/null +++ b/plugins/examples/native-counter/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "nyaterm-native-counter" +version = "1.0.1" +edition = "2024" + +[dependencies] +nyaterm-plugin-sdk = { path = "../../sdk/rust/nyaterm-plugin-sdk" } +tokio = { version = "1", features = ["rt", "macros"] } diff --git a/plugins/examples/native-counter/src/main.rs b/plugins/examples/native-counter/src/main.rs new file mode 100644 index 00000000..3515f218 --- /dev/null +++ b/plugins/examples/native-counter/src/main.rs @@ -0,0 +1,19 @@ +//! A persistent native command using the NyaTerm SDK. +use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait, json}; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Default)] +struct Counter { count: AtomicU64 } + +#[async_trait] +impl Plugin for Counter { + async fn call(&self, context: Context, method: &str, _input: Value) -> Result { + if method != "command/count" { return Err(RpcError::method_not_found(method)); } + let count = self.count.fetch_add(1, Ordering::Relaxed) + 1; + context.log("info", &format!("Counter incremented to {count}")).await?; + Ok(json!({"count":count,"message":"This process is reused until the plugin stops."})) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { Server::from_env()?.serve(Counter::default()).await } diff --git a/plugins/examples/session-toolbox/manifest.json b/plugins/examples/session-toolbox/manifest.json new file mode 100644 index 00000000..49994b8f --- /dev/null +++ b/plugins/examples/session-toolbox/manifest.json @@ -0,0 +1,29 @@ +{ + "contributions": { + "panels": [ + { "id": "toolbox", "title": "Session Toolbox", "entry": "ui/index.html" } + ], + "commands": [ + { + "id": "open-toolbox", + "title": "Open Session Toolbox", + "panel": "toolbox", + "menus": ["terminal", "connection"] + } + ] + }, + "description": "Inspect the selected terminal, read recent output and run a command with explicit approval.", + "engine": ">=1.2.12, <2.0.0", + "id": "nyaterm.session-toolbox", + "manifestVersion": 1, + "name": "Session Toolbox", + "permissions": [ + "session.read", + "terminal.read", + "terminal.execute", + "filesystem.read", + "storage" + ], + "publisher": "NyaTerm example", + "version": "1.0.0" +} diff --git a/plugins/examples/session-toolbox/ui/index.html b/plugins/examples/session-toolbox/ui/index.html new file mode 100644 index 00000000..8d0493ab --- /dev/null +++ b/plugins/examples/session-toolbox/ui/index.html @@ -0,0 +1,86 @@ + + + + + + Session Toolbox + + + + + +

Session Toolbox

+

Waiting for NyaTerm…

+

Operations apply only to this view’s selected session.

+ + + + +

NyaTerm asks for approval before executing.

+ + + + +

+  
+
diff --git a/plugins/examples/session-toolbox/ui/toolbox.js b/plugins/examples/session-toolbox/ui/toolbox.js
new file mode 100644
index 00000000..70c538b4
--- /dev/null
+++ b/plugins/examples/session-toolbox/ui/toolbox.js
@@ -0,0 +1,46 @@
+"use strict";
+
+const output = document.getElementById("output");
+const error = document.getElementById("error");
+const buttons = [...document.querySelectorAll("button")];
+async function run(operation) {
+  error.textContent = "";
+  for (const button of buttons) button.disabled = true;
+  try {
+    const result = await operation();
+    output.textContent =
+      typeof result === "string" ? result : JSON.stringify(result, null, 2);
+  } catch (failure) {
+    error.textContent = failure.message;
+  } finally {
+    for (const button of buttons) button.disabled = false;
+  }
+}
+document.getElementById("read").addEventListener("click", () =>
+  run(async () => {
+    await NyaTerm.storage.set("read-lines", 100);
+    return NyaTerm.terminal.read(100);
+  }),
+);
+document.getElementById("execute").addEventListener("click", () =>
+  run(async () => {
+    const command = document.getElementById("command").value;
+    return NyaTerm.terminal.execute(command);
+  }),
+);
+document
+  .getElementById("file")
+  .addEventListener("click", () =>
+    run(() => NyaTerm.filesystem.read(document.getElementById("path").value)),
+  );
+void NyaTerm.ready.then(() =>
+  run(async () => {
+    const session = await NyaTerm.session();
+    document.getElementById("session").textContent = session
+      ? `${session.name} (${session.type})`
+      : "Select a terminal session and reopen this panel.";
+    const lines = (await NyaTerm.storage.get("read-lines")) || 100;
+    document.getElementById("read").textContent = `Read ${lines} lines`;
+    return "Ready";
+  }),
+);
diff --git a/plugins/sdk/nyaterm.d.ts b/plugins/sdk/nyaterm.d.ts
new file mode 100644
index 00000000..55e6f61e
--- /dev/null
+++ b/plugins/sdk/nyaterm.d.ts
@@ -0,0 +1,48 @@
+/** Include nyaterm-sdk.js as a classic script. Await NyaTerm.ready before host calls. */
+type NyaTermJson =
+  | null
+  | boolean
+  | number
+  | string
+  | NyaTermJson[]
+  | { [key: string]: NyaTermJson };
+interface NyaTermPluginContext {
+  pluginId: string;
+  version: string;
+  theme: Record;
+}
+interface NyaTermPluginSdk {
+  readonly ready: Promise;
+  readonly context: NyaTermPluginContext | null;
+  onContextChange(
+    listener: (context: NyaTermPluginContext) => void,
+  ): () => void;
+  call(method: string, params?: NyaTermJson): Promise;
+  session(): Promise<{
+    id: string;
+    name: string;
+    type: string;
+    connected: boolean;
+  } | null>;
+  terminal: {
+    read(lines?: number): Promise<{ output: string }>;
+    execute(command: string, timeoutMs?: number): Promise;
+  };
+  filesystem: { read(path: string): Promise<{ content: string }> };
+  storage: {
+    get(key: string): Promise;
+    set(key: string, value: NyaTermJson): Promise;
+  };
+  network: {
+    request(
+      url: string,
+      options?: {
+        method?: "GET" | "POST";
+        body?: string;
+        contentType?: string;
+      },
+    ): Promise<{ status: number; contentType: string; body: string }>;
+  };
+  backend(method: string, input?: NyaTermJson): Promise;
+}
+declare const NyaTerm: NyaTermPluginSdk;
diff --git a/plugins/sdk/nyaterm.js b/plugins/sdk/nyaterm.js
new file mode 100644
index 00000000..7a99897d
--- /dev/null
+++ b/plugins/sdk/nyaterm.js
@@ -0,0 +1,85 @@
+/* NyaTerm Plugin UI SDK v1. Include this as a classic script before plugin code. */
+(() => {
+  "use strict";
+  const pending = new Map();
+  const contextListeners = new Set();
+  let context = null;
+  let nextId = 0;
+  let resolveReady;
+  const ready = new Promise((resolve) => {
+    resolveReady = resolve;
+  });
+  function call(method, params = null) {
+    if (pending.size >= 32)
+      return Promise.reject(new Error("Too many pending requests"));
+    const id = String(++nextId);
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => {
+        pending.delete(id);
+        reject(new Error("Plugin host request timed out"));
+      }, 185000);
+      pending.set(id, { resolve, reject, timer });
+      window.parent.postMessage(
+        { type: "nyaterm-plugin-request", id, method, params },
+        "*",
+      );
+    });
+  }
+  window.addEventListener("message", (event) => {
+    if (event.source !== window.parent) return;
+    const message = event.data;
+    if (!message || typeof message !== "object") return;
+    if (message.type === "nyaterm-plugin-context") {
+      context = message.context;
+      for (const [key, value] of Object.entries(context.theme || {})) {
+        document.documentElement.style.setProperty(key, value);
+      }
+      resolveReady(context);
+      for (const listener of contextListeners) listener(context);
+    } else if (message.type === "nyaterm-plugin-response") {
+      const request = pending.get(message.id);
+      if (!request) return;
+      pending.delete(message.id);
+      clearTimeout(request.timer);
+      if (message.error) request.reject(new Error(message.error));
+      else request.resolve(message.result);
+    }
+  });
+  window.addEventListener("pagehide", () => {
+    for (const request of pending.values()) {
+      clearTimeout(request.timer);
+      request.reject(new Error("Plugin view closed"));
+    }
+    pending.clear();
+  });
+  window.NyaTerm = Object.freeze({
+    ready,
+    call,
+    get context() {
+      return context;
+    },
+    onContextChange(listener) {
+      contextListeners.add(listener);
+      return () => contextListeners.delete(listener);
+    },
+    session: () => call("host/session"),
+    terminal: Object.freeze({
+      read: (lines = 100) => call("host/terminal/read", { lines }),
+      execute: (command, timeoutMs = 30000) =>
+        call("host/terminal/execute", { command, timeoutMs }),
+    }),
+    filesystem: Object.freeze({
+      read: (path) => call("host/filesystem/read", { path }),
+    }),
+    storage: Object.freeze({
+      get: (key) => call("host/storage/get", { key }),
+      set: (key, value) => call("host/storage/set", { key, value }),
+    }),
+    network: Object.freeze({
+      request: (url, options = {}) =>
+        call("host/network/request", { ...options, url }),
+    }),
+    backend: (method, input = null) => call(method, input),
+  });
+  window.parent.postMessage({ type: "nyaterm-plugin-ready" }, "*");
+})();
diff --git a/plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.lock b/plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.lock
new file mode 100644
index 00000000..c9a88499
--- /dev/null
+++ b/plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.lock
@@ -0,0 +1,239 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "nyaterm-plugin-sdk"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "futures-util",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "pin-project-lite",
+ "tokio-macros",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml b/plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
new file mode 100644
index 00000000..2a463871
--- /dev/null
+++ b/plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "nyaterm-plugin-sdk"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.94"
+description = "Native plugin SDK for NyaTerm's scoped JSON-RPC sidecar protocol"
+license = "MIT OR Apache-2.0"
+
+[dependencies]
+async-trait = "0.1"
+futures-util = "0.3"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "2"
+tokio = { version = "1", features = ["io-util", "io-std", "sync", "time", "rt", "macros"] }
+tokio-util = { version = "0.7", features = ["rt"] }
+
+[dev-dependencies]
+tokio = { version = "1", features = ["rt-multi-thread"] }
diff --git a/plugins/sdk/rust/nyaterm-plugin-sdk/README.md b/plugins/sdk/rust/nyaterm-plugin-sdk/README.md
new file mode 100644
index 00000000..93343d86
--- /dev/null
+++ b/plugins/sdk/rust/nyaterm-plugin-sdk/README.md
@@ -0,0 +1,59 @@
+# NyaTerm native plugin SDK
+
+This standalone crate implements the native v1 protocol without depending on
+Tauri. Generate a working project with:
+
+```sh
+pnpm plugin:create example.hello temp/plugins/hello --template rust
+node temp/plugins/hello/build.mjs
+pnpm plugin:pack temp/plugins/hello temp/plugins/hello.nyap
+```
+
+Implement `Plugin::call(Context, method, input)` and launch
+`Server::from_env()?.serve(plugin).await`. The host supplies plugin identity,
+version and transport in the environment; the SDK validates the initialization
+handshake against those values and API/protocol v1. `Plugin::initialize` can
+perform additional application checks. JSONL and framed transports use the same
+API. Only framed transport supports `Context::send_binary` and `Plugin::binary`.
+
+`Context::host()` exposes `session`, `terminal_read`, `terminal_execute`,
+`file_read`, `storage_get`, `storage_set`, and generic `call`/`call_with_timeout`.
+The generic API accepts host methods such as `host/network/request` with the same
+input documented in the UI SDK. Each client carries its originating request's
+scope; it cannot construct another plugin/session scope. Manifest grants and host
+approval remain necessary.
+
+Use `RpcError::invalid`, `method_not_found`, or `new` for errors. Returning an
+error ends that invocation; it does not stop the backend. Handler panics with
+Rust's unwind mode return a generic RPC error; abort panics and process exits
+terminate the backend. Do not log request inputs, terminal output or secrets.
+`Context::log("info", "Task finished").await` emits a structured diagnostic.
+stdout is reserved for protocol traffic; stderr is also collected by the host.
+
+The SDK permits 64 active handlers and 16 outstanding reverse calls. Frames are
+bounded to 2 MiB JSON / 8 MiB binary, with 128-byte binary channels. Initialization
+has a 10-second deadline, handlers and default host calls 180 seconds; the host
+may cancel sooner (NyaTerm backend invocations currently allow 170 seconds).
+For shorter reverse deadlines use `call_with_timeout`.
+
+Cancellation drops the async handler and cancels its host client. Dropped/timed-out
+reverse calls notify the host to cancel outstanding work. Cloned contexts expire
+when their original request finishes. Long work should yield and check
+`context.is_cancelled()` or select on `context.cancelled()`. Avoid blocking the
+Tokio executor. Cancellation does not undo an already executed terminal command
+or automatically stop plugin-owned threads/subprocesses.
+
+Native execution is an OS process with the user's privileges. This SDK supplies
+the protocol and scoped host client; it does not create an OS sandbox. The crate
+is currently consumed as a local path dependency and is not published on crates.io.
+
+Verification:
+
+```sh
+cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
+cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture
+```
+
+The latter launches an SDK executable through the real host in both transports,
+including concurrent/reverse calls, logs, binary frames, cancellation, errors,
+panic recovery, crashes and startup failure diagnostics.
diff --git a/plugins/sdk/rust/nyaterm-plugin-sdk/src/lib.rs b/plugins/sdk/rust/nyaterm-plugin-sdk/src/lib.rs
new file mode 100644
index 00000000..1ede6326
--- /dev/null
+++ b/plugins/sdk/rust/nyaterm-plugin-sdk/src/lib.rs
@@ -0,0 +1,480 @@
+//! Scoped native plugin server. stdout is reserved for protocol traffic.
+mod wire;
+
+pub use async_trait::async_trait;
+use futures_util::FutureExt;
+use serde::{Deserialize, Serialize};
+pub use serde_json::{Value, json};
+use std::collections::HashMap;
+use std::sync::{
+    Arc, Mutex,
+    atomic::{AtomicU64, Ordering},
+};
+use std::time::Duration;
+use tokio::io::{AsyncRead, AsyncWrite, BufReader};
+use tokio::sync::{mpsc, oneshot};
+use tokio::task::JoinSet;
+use tokio_util::sync::CancellationToken;
+use wire::Frame;
+
+pub type Result = std::result::Result;
+
+#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
+#[error("{message}")]
+pub struct RpcError {
+    pub code: i32,
+    pub message: String,
+}
+impl RpcError {
+    pub fn new(code: i32, message: impl Into) -> Self {
+        Self {
+            code,
+            message: message.into(),
+        }
+    }
+    pub fn invalid(message: impl Into) -> Self {
+        Self::new(-32602, message)
+    }
+    pub fn method_not_found(method: &str) -> Self {
+        Self::new(-32601, format!("Unknown method: {method}"))
+    }
+    pub fn cancelled() -> Self {
+        Self::new(-32800, "Request cancelled")
+    }
+}
+impl From for RpcError {
+    fn from(error: std::io::Error) -> Self {
+        Self::new(-32000, error.to_string())
+    }
+}
+impl From for RpcError {
+    fn from(error: serde_json::Error) -> Self {
+        Self::invalid(error.to_string())
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Transport {
+    Jsonl,
+    Framed,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Initialization {
+    pub plugin_id: String,
+    pub plugin_version: String,
+    pub app_version: String,
+    pub api_version: u32,
+    pub protocol_version: u32,
+    pub permissions: Vec,
+}
+
+#[async_trait]
+pub trait Plugin: Send + Sync + 'static {
+    async fn initialize(&self, _info: Initialization) -> Result<()> {
+        Ok(())
+    }
+    async fn call(&self, context: Context, method: &str, input: Value) -> Result;
+    async fn binary(&self, _channel: String, _data: Vec) -> Result<()> {
+        Ok(())
+    }
+}
+
+struct Shared {
+    writer: mpsc::Sender,
+    pending: Mutex>>>,
+    active: Mutex>,
+    next_id: AtomicU64,
+    stop: CancellationToken,
+    transport: Transport,
+}
+
+/// A host client is tied to the scope of one host-owned request.
+#[derive(Clone)]
+pub struct HostClient {
+    shared: Arc,
+    scope: String,
+    cancellation: CancellationToken,
+}
+struct Pending {
+    shared: Arc,
+    id: String,
+}
+struct Active {
+    shared: Arc,
+    id: u64,
+}
+impl Drop for Active {
+    fn drop(&mut self) {
+        if let Some(token) = self.shared.active.lock().unwrap().remove(&self.id) {
+            token.cancel();
+        }
+    }
+}
+impl Drop for Pending {
+    fn drop(&mut self) {
+        if self
+            .shared
+            .pending
+            .lock()
+            .unwrap()
+            .remove(&self.id)
+            .is_some()
+        {
+            let frame = Frame::Json(
+                json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":self.id}}),
+            );
+            if let Err(mpsc::error::TrySendError::Full(frame)) = self.shared.writer.try_send(frame)
+            {
+                let writer = self.shared.writer.clone();
+                tokio::spawn(async move {
+                    let _ = tokio::time::timeout(Duration::from_secs(1), writer.send(frame)).await;
+                });
+            }
+        }
+    }
+}
+
+impl HostClient {
+    pub async fn call(&self, method: &str, input: Value) -> Result {
+        self.call_with_timeout(method, input, Duration::from_secs(180))
+            .await
+    }
+    pub async fn call_with_timeout(
+        &self,
+        method: &str,
+        input: Value,
+        timeout: Duration,
+    ) -> Result {
+        if self.cancellation.is_cancelled() || self.shared.stop.is_cancelled() {
+            return Err(RpcError::cancelled());
+        }
+        if !method.starts_with("host/")
+            || method.len() > 128
+            || serde_json::to_vec(&input)?.len() > 1024 * 1024
+        {
+            return Err(RpcError::invalid("Invalid host request"));
+        }
+        let id = format!(
+            "sdk-{}",
+            self.shared.next_id.fetch_add(1, Ordering::Relaxed)
+        );
+        let (sender, receiver) = oneshot::channel();
+        {
+            let mut pending = self.shared.pending.lock().unwrap();
+            if pending.len() >= 16 {
+                return Err(RpcError::new(-32000, "Too many pending host calls"));
+            }
+            pending.insert(id.clone(), sender);
+        }
+        let _pending = Pending {
+            shared: self.shared.clone(),
+            id: id.clone(),
+        };
+        let operation = async {
+            self.shared.writer.send(Frame::Json(json!({"jsonrpc":"2.0","id":id,"method":method,"params":{"scopeToken":self.scope,"input":input}}))).await.map_err(|_|RpcError::new(-32000,"Protocol writer closed"))?;
+            receiver
+                .await
+                .map_err(|_| RpcError::new(-32000, "Host response channel closed"))?
+        };
+        tokio::select! {
+            _=self.shared.stop.cancelled()=>Err(RpcError::cancelled()),
+            _=self.cancellation.cancelled()=>Err(RpcError::cancelled()),
+            result=tokio::time::timeout(timeout,operation)=>result.unwrap_or_else(|_|Err(RpcError::new(-32000,"Host call timed out"))),
+        }
+    }
+    pub async fn session(&self) -> Result {
+        self.call("host/session", Value::Null).await
+    }
+    pub async fn terminal_read(&self, lines: u16) -> Result {
+        self.call("host/terminal/read", json!({"lines":lines}))
+            .await
+    }
+    pub async fn terminal_execute(&self, command: &str, timeout_ms: u64) -> Result {
+        self.call(
+            "host/terminal/execute",
+            json!({"command":command,"timeoutMs":timeout_ms}),
+        )
+        .await
+    }
+    pub async fn file_read(&self, path: &str) -> Result {
+        self.call("host/filesystem/read", json!({"path":path}))
+            .await
+    }
+    pub async fn storage_get(&self, key: &str) -> Result {
+        self.call("host/storage/get", json!({"key":key})).await
+    }
+    pub async fn storage_set(&self, key: &str, value: Value) -> Result {
+        self.call("host/storage/set", json!({"key":key,"value":value}))
+            .await
+    }
+}
+
+#[derive(Clone)]
+pub struct Context {
+    host: HostClient,
+}
+impl Context {
+    pub fn host(&self) -> &HostClient {
+        &self.host
+    }
+    pub fn is_cancelled(&self) -> bool {
+        self.host.cancellation.is_cancelled() || self.host.shared.stop.is_cancelled()
+    }
+    pub async fn cancelled(&self) {
+        tokio::select! {_=self.host.cancellation.cancelled()=>{},_=self.host.shared.stop.cancelled()=>{}}
+    }
+    pub async fn log(&self, level: &str, message: &str) -> Result<()> {
+        if !["debug", "info", "warn", "error"].contains(&level) || message.len() > 2048 {
+            return Err(RpcError::invalid("Invalid log entry"));
+        }
+        self.send(Frame::Json(json!({"jsonrpc":"2.0","method":"event/log","params":{"level":level,"message":message}}))).await
+    }
+    pub async fn send_binary(&self, channel: &str, data: Vec) -> Result<()> {
+        if self.host.shared.transport != Transport::Framed
+            || channel.is_empty()
+            || channel.len() > 128
+            || channel.len() + data.len() + 2 > wire::MAX_FRAME
+        {
+            return Err(RpcError::invalid("Invalid binary frame"));
+        }
+        self.send(Frame::Binary(channel.to_owned(), data)).await
+    }
+    async fn send(&self, frame: Frame) -> Result<()> {
+        tokio::select! {
+            _=self.cancelled()=>Err(RpcError::cancelled()),
+            result=self.host.shared.writer.send(frame)=>result.map_err(|_|RpcError::new(-32000,"Protocol writer closed")),
+        }
+    }
+}
+
+pub struct Server {
+    id: String,
+    version: String,
+    transport: Transport,
+}
+impl Server {
+    pub fn new(id: impl Into, version: impl Into, transport: Transport) -> Self {
+        Self {
+            id: id.into(),
+            version: version.into(),
+            transport,
+        }
+    }
+    pub fn from_env() -> Result {
+        let get = |key| {
+            std::env::var(key).map_err(|_| {
+                RpcError::invalid(format!(
+                    "Missing {key}; launch this executable from NyaTerm"
+                ))
+            })
+        };
+        let transport = match std::env::var("NYATERM_PLUGIN_TRANSPORT").as_deref() {
+            Ok("stdio-framed") => Transport::Framed,
+            Ok("stdio-jsonl") | Err(_) => Transport::Jsonl,
+            _ => return Err(RpcError::invalid("Unsupported transport")),
+        };
+        Ok(Self::new(
+            get("NYATERM_PLUGIN_ID")?,
+            get("NYATERM_PLUGIN_VERSION")?,
+            transport,
+        ))
+    }
+    pub async fn serve(self, plugin: impl Plugin) -> Result<()> {
+        self.serve_io(plugin, tokio::io::stdin(), tokio::io::stdout())
+            .await
+    }
+    pub async fn serve_io(
+        self,
+        plugin: impl Plugin,
+        reader: impl AsyncRead + Unpin + Send + 'static,
+        writer: impl AsyncWrite + Unpin + Send + 'static,
+    ) -> Result<()> {
+        let (sender, mut receiver) = mpsc::channel(8);
+        let shared = Arc::new(Shared {
+            writer: sender,
+            pending: Mutex::new(HashMap::new()),
+            active: Mutex::new(HashMap::new()),
+            next_id: AtomicU64::new(1),
+            stop: CancellationToken::new(),
+            transport: self.transport,
+        });
+        let transport = self.transport;
+        let mut writer_task = tokio::spawn(async move {
+            let mut writer = writer;
+            while let Some(frame) = receiver.recv().await {
+                wire::write(&mut writer, transport, frame).await?;
+            }
+            Ok::<(), RpcError>(())
+        });
+        let plugin = Arc::new(plugin);
+        let mut reader = BufReader::new(reader);
+        let mut tasks = JoinSet::new();
+        let result = tokio::select! {
+            result = self.run(plugin, &mut reader, shared.clone(), &mut tasks) => result,
+            result = &mut writer_task => result.unwrap_or_else(|_| Err(RpcError::new(-32000, "Protocol writer panicked"))),
+        };
+        shared.stop.cancel();
+        tasks.abort_all();
+        while tasks.join_next().await.is_some() {}
+        for (_, pending) in shared.pending.lock().unwrap().drain() {
+            let _ = pending.send(Err(RpcError::cancelled()));
+        }
+        if !writer_task.is_finished() {
+            writer_task.abort();
+            let _ = writer_task.await;
+        }
+        result
+    }
+    async fn run(
+        &self,
+        plugin: Arc,
+        reader: &mut (impl AsyncRead + Unpin),
+        shared: Arc,
+        tasks: &mut JoinSet>,
+    ) -> Result<()> {
+        let mut initialized = false;
+        loop {
+            while let Some(result) = tasks.try_join_next() {
+                result.map_err(|_| RpcError::new(-32000, "Plugin handler panicked"))??;
+            }
+            let Some(frame) = wire::read(reader, self.transport).await? else {
+                return Ok(());
+            };
+            let value = match frame {
+                Frame::Binary(channel, data) => {
+                    if !initialized {
+                        return Err(RpcError::invalid("Initialize before sending binary frames"));
+                    }
+                    if tasks.len() >= 64 {
+                        return Err(RpcError::invalid("Too many binary handlers"));
+                    }
+                    let plugin = plugin.clone();
+                    tasks.spawn(async move {
+                        tokio::time::timeout(Duration::from_secs(180), plugin.binary(channel, data))
+                            .await
+                            .map_err(|_| RpcError::new(-32000, "Binary handler timed out"))?
+                    });
+                    continue;
+                }
+                Frame::Json(value) => value,
+            };
+            if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
+                return Err(RpcError::invalid("Invalid JSON-RPC version"));
+            }
+            let Some(method) = value.get("method").and_then(Value::as_str) else {
+                let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
+                    RpcError::invalid("Reverse host response IDs must be strings")
+                })?;
+                if let Some(sender) = shared.pending.lock().unwrap().remove(id) {
+                    let result = if let Some(error) = value.get("error") {
+                        Err(serde_json::from_value(error.clone())?)
+                    } else {
+                        value
+                            .get("result")
+                            .cloned()
+                            .ok_or_else(|| RpcError::invalid("Host response has no result"))
+                    };
+                    let _ = sender.send(result);
+                }
+                continue;
+            };
+            if method == "$/cancelRequest" {
+                if let Some(id) = value.pointer("/params/id").and_then(Value::as_u64)
+                    && let Some(token) = shared.active.lock().unwrap().get(&id)
+                {
+                    token.cancel();
+                }
+                continue;
+            }
+            let id = value
+                .get("id")
+                .and_then(Value::as_u64)
+                .ok_or_else(|| RpcError::invalid("Host request IDs must be numeric"))?;
+            if method == "plugin/initialize" {
+                if initialized {
+                    return Err(RpcError::invalid("Plugin is already initialized"));
+                }
+                let info: Initialization =
+                    serde_json::from_value(value.get("params").cloned().unwrap_or(Value::Null))?;
+                if info.plugin_id != self.id
+                    || info.plugin_version != self.version
+                    || info.api_version != 1
+                    || info.protocol_version != 1
+                {
+                    return Err(RpcError::invalid(
+                        "Incompatible initialization identity or API version",
+                    ));
+                }
+                tokio::time::timeout(Duration::from_secs(10), plugin.initialize(info))
+                    .await
+                    .map_err(|_| RpcError::new(-32000, "Initialization timed out"))??;
+                initialized = true;
+                shared.writer.send(Frame::Json(json!({"jsonrpc":"2.0","id":id,"result":{"pluginId":self.id,"pluginVersion":self.version,"protocolVersion":1}})))
+                    .await.map_err(|_| RpcError::new(-32000, "Protocol writer closed"))?;
+                continue;
+            }
+            if !initialized || method.is_empty() || method.len() > 128 {
+                return Err(RpcError::invalid(
+                    "Plugin is not initialized or method is invalid",
+                ));
+            }
+            let scope = value
+                .pointer("/params/scopeToken")
+                .and_then(Value::as_str)
+                .filter(|s| !s.is_empty() && s.len() <= 256)
+                .ok_or_else(|| RpcError::invalid("Request needs its scope token"))?
+                .to_owned();
+            let input = value
+                .pointer("/params/input")
+                .cloned()
+                .unwrap_or(Value::Null);
+            let cancellation = shared.stop.child_token();
+            {
+                let mut active = shared.active.lock().unwrap();
+                if active.len() >= 64 || active.contains_key(&id) {
+                    return Err(RpcError::invalid(
+                        "Too many requests or duplicate request ID",
+                    ));
+                }
+                active.insert(id, cancellation.clone());
+            }
+            let context = Context {
+                host: HostClient {
+                    shared: shared.clone(),
+                    scope,
+                    cancellation: cancellation.clone(),
+                },
+            };
+            let plugin = plugin.clone();
+            let shared = shared.clone();
+            let method = method.to_owned();
+            tasks.spawn(async move {
+                let _active = Active {
+                    shared: shared.clone(),
+                    id,
+                };
+                let call = std::panic::AssertUnwindSafe(plugin.call(context, &method, input))
+                    .catch_unwind();
+                let result = tokio::select! {
+                    _ = cancellation.cancelled() => Err(RpcError::cancelled()),
+                    result = tokio::time::timeout(Duration::from_secs(180), call) => match result {
+                        Ok(Ok(result)) => result,
+                        Ok(Err(_)) => Err(RpcError::new(-32000, "Plugin handler panicked")),
+                        Err(_) => Err(RpcError::new(-32000, "Plugin handler timed out")),
+                    },
+                };
+                let response = match result {
+                    Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}),
+                    Err(error) => json!({"jsonrpc":"2.0","id":id,"error":error}),
+                };
+                shared
+                    .writer
+                    .send(Frame::Json(response))
+                    .await
+                    .map_err(|_| RpcError::new(-32000, "Protocol writer closed"))
+            });
+        }
+    }
+}
diff --git a/plugins/sdk/rust/nyaterm-plugin-sdk/src/wire.rs b/plugins/sdk/rust/nyaterm-plugin-sdk/src/wire.rs
new file mode 100644
index 00000000..f46bca4b
--- /dev/null
+++ b/plugins/sdk/rust/nyaterm-plugin-sdk/src/wire.rs
@@ -0,0 +1,143 @@
+use crate::{Result, RpcError, Transport};
+use serde_json::Value;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+
+pub(crate) const MAX_JSON: usize = 2 * 1024 * 1024;
+pub(crate) const MAX_FRAME: usize = 8 * 1024 * 1024;
+pub(crate) enum Frame {
+    Json(Value),
+    Binary(String, Vec),
+}
+
+pub(crate) async fn read(
+    reader: &mut (impl AsyncRead + Unpin),
+    transport: Transport,
+) -> Result> {
+    let mut first = [0_u8];
+    if reader.read(&mut first).await? == 0 {
+        return Ok(None);
+    }
+    if transport == Transport::Jsonl {
+        let mut bytes = Vec::new();
+        let mut byte = first[0];
+        loop {
+            if byte == b'\n' {
+                break;
+            }
+            if bytes.len() == MAX_JSON {
+                return Err(RpcError::invalid("JSON line exceeds 2 MiB"));
+            }
+            bytes.push(byte);
+            byte = reader.read_u8().await?;
+        }
+        return Ok(Some(Frame::Json(serde_json::from_slice(&bytes)?)));
+    }
+    let kind = first[0];
+    let length = reader.read_u32_le().await? as usize;
+    if length > MAX_FRAME || (kind == 1 && length > MAX_JSON) {
+        return Err(RpcError::invalid("Frame is too large"));
+    }
+    let mut bytes = vec![0; length];
+    reader.read_exact(&mut bytes).await?;
+    match kind {
+        1 => Ok(Some(Frame::Json(serde_json::from_slice(&bytes)?))),
+        2 if bytes.len() >= 2 => {
+            let length = u16::from_le_bytes([bytes[0], bytes[1]]) as usize;
+            if length == 0 || length > 128 || length + 2 > bytes.len() {
+                return Err(RpcError::invalid("Invalid binary channel"));
+            }
+            let channel = std::str::from_utf8(&bytes[2..length + 2])
+                .map_err(|_| RpcError::invalid("Invalid binary channel encoding"))?
+                .to_owned();
+            Ok(Some(Frame::Binary(channel, bytes[length + 2..].to_vec())))
+        }
+        _ => Err(RpcError::invalid("Invalid frame kind")),
+    }
+}
+
+pub(crate) async fn write(
+    writer: &mut (impl AsyncWrite + Unpin),
+    transport: Transport,
+    frame: Frame,
+) -> Result<()> {
+    let (kind, bytes) = match frame {
+        Frame::Json(value) => {
+            let bytes = serde_json::to_vec(&value)?;
+            if bytes.len() > MAX_JSON {
+                return Err(RpcError::invalid("JSON frame exceeds 2 MiB"));
+            }
+            (1, bytes)
+        }
+        Frame::Binary(channel, data) => {
+            if transport != Transport::Framed
+                || channel.is_empty()
+                || channel.len() > 128
+                || channel.len() + data.len() + 2 > MAX_FRAME
+            {
+                return Err(RpcError::invalid("Invalid binary frame"));
+            }
+            let mut bytes = (channel.len() as u16).to_le_bytes().to_vec();
+            bytes.extend_from_slice(channel.as_bytes());
+            bytes.extend_from_slice(&data);
+            (2, bytes)
+        }
+    };
+    if transport == Transport::Framed {
+        writer.write_u8(kind).await?;
+        writer.write_u32_le(bytes.len() as u32).await?;
+    }
+    writer.write_all(&bytes).await?;
+    if transport == Transport::Jsonl {
+        writer.write_u8(b'\n').await?;
+    }
+    writer.flush().await?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[tokio::test]
+    async fn distinguishes_clean_eof_from_truncated_frames() {
+        assert!(
+            read(
+                &mut std::io::Cursor::new(Vec::::new()),
+                Transport::Jsonl
+            )
+            .await
+            .unwrap()
+            .is_none()
+        );
+        for (transport, bytes) in [
+            (Transport::Jsonl, b"{\"x\":1}".to_vec()),
+            (Transport::Framed, vec![1, 2, 0]),
+        ] {
+            assert!(
+                read(&mut std::io::Cursor::new(bytes), transport)
+                    .await
+                    .is_err()
+            );
+        }
+    }
+    #[tokio::test]
+    async fn rejects_frame_size_before_reading_payload_and_roundtrips_binary() {
+        let mut bytes = vec![1];
+        bytes.extend_from_slice(&(MAX_JSON as u32 + 1).to_le_bytes());
+        let error = read(&mut std::io::Cursor::new(bytes), Transport::Framed)
+            .await
+            .err()
+            .unwrap();
+        assert!(error.message.contains("too large"));
+        let mut bytes = Vec::new();
+        write(
+            &mut bytes,
+            Transport::Framed,
+            Frame::Binary("test".into(), vec![0, 255]),
+        )
+        .await
+        .unwrap();
+        assert!(
+            matches!(read(&mut std::io::Cursor::new(bytes),Transport::Framed).await.unwrap(),Some(Frame::Binary(channel,data)) if channel=="test" && data==[0,255])
+        );
+    }
+}
diff --git a/plugins/templates/rust/build.mjs b/plugins/templates/rust/build.mjs
new file mode 100644
index 00000000..d0bde24b
--- /dev/null
+++ b/plugins/templates/rust/build.mjs
@@ -0,0 +1,41 @@
+import { spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("./", import.meta.url));
+const os = { win32: "windows", darwin: "macos", linux: "linux" }[
+  process.platform
+];
+const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
+if (!os || !arch)
+  throw new Error(
+    "Unsupported platform; build and add your executable manually",
+  );
+const result = spawnSync(
+  "cargo",
+  [
+    "build",
+    "--release",
+    "--manifest-path",
+    path.join(root, "Cargo.toml"),
+    "--target-dir",
+    path.join(root, "target"),
+  ],
+  { cwd: root, stdio: "inherit", windowsHide: true },
+);
+if (result.error) throw result.error;
+if (result.status !== 0) process.exit(result.status ?? 1);
+const executable = `nyaterm-plugin-starter${process.platform === "win32" ? ".exe" : ""}`;
+const directory = path.join(root, "bin", `${os}-${arch}`);
+mkdirSync(directory, { recursive: true });
+copyFileSync(
+  path.join(root, "target/release", executable),
+  path.join(directory, executable),
+);
+const manifestPath = path.join(root, "manifest.json");
+const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+manifest.backend.executables[`${os}-${arch}`] =
+  `bin/${os}-${arch}/${executable}`;
+writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
+process.stdout.write("Built native backend; the plugin is ready to package.\n");
diff --git a/plugins/templates/rust/main.rs b/plugins/templates/rust/main.rs
new file mode 100644
index 00000000..fc23b892
--- /dev/null
+++ b/plugins/templates/rust/main.rs
@@ -0,0 +1,22 @@
+use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait, json};
+use std::sync::atomic::{AtomicU64, Ordering};
+
+#[derive(Default)]
+struct Hello { count: AtomicU64 }
+
+#[async_trait]
+impl Plugin for Hello {
+    async fn call(&self, context: Context, method: &str, _input: Value) -> Result {
+        match method {
+            "ui/hello" | "command/hello" => {
+                let count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
+                context.log("info", "Hello command completed").await?;
+                Ok(json!({"message": "Hello from Rust", "count": count}))
+            }
+            _ => Err(RpcError::method_not_found(method)),
+        }
+    }
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> Result<()> { Server::from_env()?.serve(Hello::default()).await }
diff --git a/plugins/templates/ui/index.html b/plugins/templates/ui/index.html
new file mode 100644
index 00000000..03886e1d
--- /dev/null
+++ b/plugins/templates/ui/index.html
@@ -0,0 +1,32 @@
+
+
+  
+    
+    
+    NyaTerm plugin starter
+    
+  
+  
+    

Hello, NyaTerm

+ +

+    
+    
+  
+
diff --git a/scripts/build-plugin-native-example.mjs b/scripts/build-plugin-native-example.mjs
new file mode 100644
index 00000000..66cff26a
--- /dev/null
+++ b/scripts/build-plugin-native-example.mjs
@@ -0,0 +1,64 @@
+import { spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+const example = path.join(root, "plugins/examples/native-counter");
+const result = spawnSync(
+  "cargo",
+  ["build", "--manifest-path", path.join(example, "Cargo.toml")],
+  { cwd: root, stdio: "inherit", windowsHide: true },
+);
+if (result.error) throw result.error;
+if (result.status !== 0) process.exit(result.status ?? 1);
+const os = { win32: "windows", darwin: "macos", linux: "linux" }[
+  process.platform
+];
+const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
+if (!os || !arch) throw new Error("Unsupported native example platform");
+const executable = `nyaterm-native-counter${process.platform === "win32" ? ".exe" : ""}`;
+const directory = path.join(root, "temp/plugins/native-counter");
+mkdirSync(path.join(directory, "bin"), { recursive: true });
+copyFileSync(
+  path.join(example, "target/debug", executable),
+  path.join(directory, "bin", executable),
+);
+const { version } = JSON.parse(
+  readFileSync(path.join(root, "package.json"), "utf8"),
+);
+writeFileSync(
+  path.join(directory, "manifest.json"),
+  JSON.stringify(
+    {
+      manifestVersion: 1,
+      id: "nyaterm.native-counter",
+      name: "Native Counter",
+      version: "1.0.1",
+      description:
+        "Demonstrates a persistent native command. Each invocation increments the process counter.",
+      publisher: "NyaTerm example",
+      engine: `>=${version}, <2.0.0`,
+      permissions: ["native"],
+      backend: {
+        transport: "stdio-jsonl",
+        executables: { [`${os}-${arch}`]: `bin/${executable}` },
+      },
+      contributions: {
+        commands: [
+          {
+            id: "count",
+            title: "Increment native counter",
+            method: "command/count",
+            menus: ["terminal", "connection"],
+          },
+        ],
+      },
+    },
+    null,
+    2,
+  ),
+);
+process.stdout.write(
+  `Native example prepared in ${directory}\nPackage with: pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap\n`,
+);
diff --git a/scripts/create-plugin.mjs b/scripts/create-plugin.mjs
new file mode 100644
index 00000000..99b4417b
--- /dev/null
+++ b/scripts/create-plugin.mjs
@@ -0,0 +1,131 @@
+import {
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  renameSync,
+  rmSync,
+  writeFileSync,
+} from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = fileURLToPath(new URL("../", import.meta.url));
+const args = process.argv.slice(2);
+const [id, destination, option, template = "ui"] = args;
+if (
+  args.length < 2 ||
+  args.length > 4 ||
+  (option && option !== "--template") ||
+  (option && args.length !== 4) ||
+  !["ui", "rust"].includes(template)
+) {
+  process.stderr.write(
+    "Usage: pnpm plugin:create   [--template ui|rust]\n",
+  );
+  process.exit(1);
+}
+if (
+  !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id) ||
+  !id.includes(".") ||
+  id.includes("..")
+) {
+  throw new Error(
+    "Use a namespaced lowercase plugin ID, for example example.hello",
+  );
+}
+const target = path.resolve(destination);
+if (existsSync(target))
+  throw new Error(`Destination already exists: ${target}`);
+const { version } = JSON.parse(
+  readFileSync(path.join(root, "package.json"), "utf8"),
+);
+const sdkPath = path
+  .relative(target, path.join(root, "plugins/sdk/rust/nyaterm-plugin-sdk"))
+  .split(path.sep)
+  .join("/");
+const manifest = {
+  manifestVersion: 1,
+  id,
+  name: id,
+  version: "1.0.0",
+  description: "A NyaTerm plugin starter",
+  publisher: id.split(".")[0],
+  engine: `>=${version}, <2.0.0`,
+  permissions: template === "rust" ? ["native"] : ["session.read"],
+  contributions: {
+    panels: [{ id: "main", title: id, entry: "ui/index.html" }],
+    commands: [
+      {
+        id: "hello",
+        title: `Open ${id}`,
+        panel: "main",
+        menus: ["terminal", "connection"],
+      },
+    ],
+  },
+};
+if (template === "rust") {
+  manifest.backend = { transport: "stdio-jsonl", executables: {} };
+  manifest.contributions.commands.push({
+    id: "run",
+    title: "Run native hello",
+    method: "command/hello",
+    menus: ["terminal", "connection"],
+  });
+}
+mkdirSync(path.dirname(target), { recursive: true });
+const stage = mkdtempSync(path.join(path.dirname(target), ".nyaterm-plugin-"));
+try {
+  const put = (file, content) => {
+    const output = path.join(stage, file);
+    mkdirSync(path.dirname(output), { recursive: true });
+    writeFileSync(output, content);
+  };
+  put("manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
+  put(".gitignore", "target/\nbin/\n*.nyap\n");
+  put(
+    "ui/index.html",
+    readFileSync(path.join(root, "plugins/templates/ui/index.html"), "utf8"),
+  );
+  put(
+    "ui/main.js",
+    template === "rust"
+      ? 'document.getElementById("run").onclick = async () => {\n  const output = document.getElementById("output");\n  try { await NyaTerm.ready; output.textContent = JSON.stringify(await NyaTerm.backend("ui/hello"), null, 2); }\n  catch (error) { output.textContent = error.message; }\n};\n'
+      : 'document.getElementById("run").onclick = async () => {\n  const output = document.getElementById("output");\n  try { await NyaTerm.ready; output.textContent = JSON.stringify(await NyaTerm.session(), null, 2); }\n  catch (error) { output.textContent = error.message; }\n};\n',
+  );
+  put(
+    "ui/nyaterm-sdk.js",
+    readFileSync(path.join(root, "plugins/sdk/nyaterm.js")),
+  );
+  put(
+    "ui/nyaterm-sdk.d.ts",
+    readFileSync(path.join(root, "plugins/sdk/nyaterm.d.ts")),
+  );
+  if (template === "rust") {
+    put(
+      "Cargo.toml",
+      `[package]\nname = "nyaterm-plugin-starter"\nversion = "1.0.0"\nedition = "2024"\nrust-version = "1.94"\n\n[dependencies]\nnyaterm-plugin-sdk = { path = ${JSON.stringify(sdkPath)} }\ntokio = { version = "1", features = ["rt", "macros"] }\n`,
+    );
+    put(
+      "src/main.rs",
+      readFileSync(path.join(root, "plugins/templates/rust/main.rs")),
+    );
+    put(
+      "build.mjs",
+      readFileSync(path.join(root, "plugins/templates/rust/build.mjs")),
+    );
+  }
+  const packer = path.join(root, "scripts/package-plugin.mjs");
+  put(
+    "README.md",
+    `# ${id}\n\nGenerated from the NyaTerm ${template} template.\n\n${template === "rust" ? "1. Install Rust 1.94 or newer, then run `node build.mjs` here. This builds the SDK backend and fills the current platform executable in manifest.json.\n2." : "1."} Package from this directory:\n\n\`\`\`sh\nnode ${JSON.stringify(packer)} . ../${id}.nyap\n\`\`\`\n\nInstall the .nyap from NyaTerm's Plugins panel, review permissions, enable it and open its panel. ${template === "rust" ? "The native command reuses the process; check status/logs in Plugins. Stop the backend to reset its counter. Never print protocol data to stdout: use Context::log or stderr. The Rust SDK is a relative local dependency; distribute this source with the SDK or change its path when moving the project. For framed transport change backend.transport to stdio-framed; the SDK selects the transport from the host environment. Add other-platform binaries under bin/ and their executable entries before packing." : "Select a connected terminal before inspecting its session. The UI runs in an isolated iframe; use the SDK instead of Tauri APIs or direct file/network access."}\n\nEdit manifest.json to add permissions and contributions. UI scripts and styles must be local; use textContent for host/plugin data. Packaging includes only manifest.json, ui/, assets/ and bin/; target and source are excluded. The packager injects the current UI SDK. To update an installed plugin, bump manifest.version (and Cargo version for Rust) because released versions are immutable.\n`,
+  );
+  renameSync(stage, target);
+} catch (error) {
+  rmSync(stage, { recursive: true, force: true });
+  throw error;
+}
+process.stdout.write(
+  `Created ${template} plugin ${id} at ${target}\nSee README.md for build and packaging instructions.\n`,
+);
diff --git a/scripts/create-plugin.node-test.mjs b/scripts/create-plugin.node-test.mjs
new file mode 100644
index 00000000..c2fd5390
--- /dev/null
+++ b/scripts/create-plugin.node-test.mjs
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+const script = fileURLToPath(new URL("./create-plugin.mjs", import.meta.url));
+function withDirectory(fn) {
+  const directory = mkdtempSync(path.join(os.tmpdir(), "nyaterm scaffold "));
+  try {
+    fn(directory);
+  } finally {
+    rmSync(directory, { recursive: true, force: true });
+  }
+}
+test("generates UI source and refuses to overwrite an existing project", () =>
+  withDirectory((directory) => {
+    const target = path.join(directory, "ui");
+    assert.equal(
+      spawnSync(process.execPath, [script, "example.hello", target]).status,
+      0,
+    );
+    const original = readFileSync(path.join(target, "manifest.json"), "utf8");
+    assert.equal(JSON.parse(original).permissions[0], "session.read");
+    assert.ok(existsSync(path.join(target, "ui/nyaterm-sdk.js")));
+    assert.notEqual(
+      spawnSync(process.execPath, [script, "example.changed", target]).status,
+      0,
+    );
+    assert.equal(
+      readFileSync(path.join(target, "manifest.json"), "utf8"),
+      original,
+    );
+  }));
+test("generates Rust source with a usable SDK path outside the repository", () =>
+  withDirectory((directory) => {
+    const target = path.join(directory, "rust");
+    assert.equal(
+      spawnSync(process.execPath, [
+        script,
+        "example.hello",
+        target,
+        "--template",
+        "rust",
+      ]).status,
+      0,
+    );
+    const cargo = readFileSync(path.join(target, "Cargo.toml"), "utf8");
+    const sdk = JSON.parse(cargo.match(/path = (".*")/)[1]);
+    assert.ok(existsSync(path.resolve(target, sdk, "Cargo.toml")));
+    assert.ok(
+      readFileSync(path.join(target, "src/main.rs"), "utf8").includes(
+        "Server::from_env",
+      ),
+    );
+    assert.ok(existsSync(path.join(target, "build.mjs")));
+  }));
+test("rejects invalid IDs and template arguments without creating files", () =>
+  withDirectory((directory) => {
+    const target = path.join(directory, "invalid");
+    for (const args of [
+      ["../escape", target],
+      ["no-namespace", target],
+      ["example.test", target, "--template", "unknown"],
+      ["example.test", target, "--template"],
+    ]) {
+      assert.notEqual(spawnSync(process.execPath, [script, ...args]).status, 0);
+      assert.equal(existsSync(target), false);
+    }
+  }));
diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs
new file mode 100644
index 00000000..9c80fe69
--- /dev/null
+++ b/scripts/package-plugin.mjs
@@ -0,0 +1,34 @@
+import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const args = process.argv.slice(2);
+if (args.length !== 2) {
+  process.stderr.write(
+    "Usage: pnpm plugin:pack  \n",
+  );
+  process.exit(1);
+}
+const version = JSON.parse(
+  readFileSync(path.join(root, "package.json"), "utf8"),
+).version;
+const result = spawnSync(
+  "cargo",
+  [
+    "run",
+    "--manifest-path",
+    path.join(root, "src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml"),
+    "--bin",
+    "nyaterm-plugin",
+    "--",
+    "pack",
+    path.resolve(args[0]),
+    path.resolve(args[1]),
+    version,
+  ],
+  { cwd: root, stdio: "inherit", windowsHide: true },
+);
+if (result.error) process.stderr.write(`${result.error.message}\n`);
+process.exit(result.status ?? 1);
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 5b9534e3..bc7f13e2 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -4942,6 +4942,7 @@ dependencies = [
  "nucleo-matcher",
  "nyaterm-mcp-protocol",
  "nyaterm-otp",
+ "nyaterm-plugin-runtime",
  "open",
  "opendal",
  "pbkdf2 0.12.2",
@@ -5008,6 +5009,26 @@ dependencies = [
 name = "nyaterm-otp"
 version = "0.1.0"
 
+[[package]]
+name = "nyaterm-plugin-runtime"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "hex",
+ "regex",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "tempfile",
+ "thiserror 2.0.20",
+ "tokio",
+ "tokio-util",
+ "url",
+ "uuid",
+ "zip 8.6.0",
+]
+
 [[package]]
 name = "objc2"
 version = "0.6.4"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index f7ad6acd..2fa23237 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -87,6 +87,7 @@ tokio = { version = "1", features = ["test-util"] }
 [dependencies]
 nyaterm-otp = { path = "crates/otp" }
 nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" }
+nyaterm-plugin-runtime = { path = "crates/nyaterm-plugin-runtime" }
 tauri = { version = "2", features = ["tray-icon", "protocol-asset"] }
 tauri-plugin-opener = "2"
 serde = { version = "1", features = ["derive"] }
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock
new file mode 100644
index 00000000..9fd2671b
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock
@@ -0,0 +1,964 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "zlib-rs",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "nyaterm-plugin-runtime"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "hex",
+ "nyaterm-plugin-sdk",
+ "regex",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "url",
+ "uuid",
+ "zip",
+]
+
+[[package]]
+name = "nyaterm-plugin-sdk"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "futures-util",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
+dependencies = [
+ "getrandom",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "typed-path",
+ "zopfli",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml
new file mode 100644
index 00000000..2aa41175
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "nyaterm-plugin-runtime"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.94"
+
+[dependencies]
+async-trait = "0.1"
+hex = "0.4"
+regex = "1"
+semver = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = "0.10"
+tempfile = "3"
+thiserror = "2"
+tokio = { version = "1", features = ["process", "io-util", "sync", "time", "rt", "macros"] }
+tokio-util = { version = "0.7", features = ["rt"] }
+url = "2"
+uuid = { version = "1", features = ["v4"] }
+zip = { version = "8", default-features = false, features = ["deflate"] }
+
+[dev-dependencies]
+tokio = { version = "1", features = ["rt-multi-thread"] }
+
+[features]
+sdk-fixture = ["dep:nyaterm-plugin-sdk"]
+
+[dependencies.nyaterm-plugin-sdk]
+path = "../../../plugins/sdk/rust/nyaterm-plugin-sdk"
+optional = true
+
+[[bin]]
+name = "sdk-fixture"
+path = "src/bin/sdk-fixture.rs"
+required-features = ["sdk-fixture"]
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs
new file mode 100644
index 00000000..7c2c2ed9
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs
@@ -0,0 +1,105 @@
+//! Package authoring CLI. Run through `pnpm plugin:pack` or directly with Cargo.
+use nyaterm_plugin_runtime::manifest::{Manifest, validate_path};
+use nyaterm_plugin_runtime::package::prepare;
+use sha2::{Digest, Sha256};
+use std::collections::BTreeMap;
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+use zip::write::SimpleFileOptions;
+
+fn main() {
+    if let Err(error) = run() {
+        eprintln!("{error}");
+        std::process::exit(1);
+    }
+}
+
+fn run() -> Result<(), Box> {
+    let args = std::env::args().skip(1).collect::>();
+    match args.first().map(String::as_str) {
+        Some("pack") if args.len() == 4 => {
+            let source = PathBuf::from(&args[1]).canonicalize()?;
+            let output = PathBuf::from(&args[2]);
+            let manifest = Manifest::parse(&std::fs::read(source.join("manifest.json"))?, &args[3])?;
+            let mut files = BTreeMap::new();
+            files.insert("manifest.json".into(), std::fs::read(source.join("manifest.json"))?);
+            for directory in ["ui", "assets", "bin"] {
+                if source.join(directory).exists() { collect(&source, &source.join(directory), &mut files)?; }
+            }
+            let sdk = include_bytes!("../../../../../plugins/sdk/nyaterm.js").to_vec();
+            if files.get("ui/nyaterm-sdk.js").is_some_and(|bytes| bytes != &sdk) { return Err("ui/nyaterm-sdk.js is reserved for the host SDK".into()); }
+            files.insert("ui/nyaterm-sdk.js".into(), sdk);
+            if files.len() > 1023 || files.values().map(Vec::len).sum::() > 256 * 1024 * 1024 { return Err("Plugin package exceeds file count or expanded size limit".into()); }
+            let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
+            std::fs::create_dir_all(parent)?;
+            let mut staging = tempfile::NamedTempFile::new_in(parent)?;
+            let checksums = files.iter().map(|(name, bytes)| (name.clone(), hex::encode(Sha256::digest(bytes)))).collect::>();
+            {
+                let mut archive = zip::ZipWriter::new(staging.as_file_mut());
+                for (name, bytes) in files {
+                    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
+                        .unix_permissions(if name.starts_with("bin/") { 0o755 } else { 0o644 });
+                    archive.start_file(name, options)?;
+                    archive.write_all(&bytes)?;
+                }
+                archive.start_file("checksums.json", SimpleFileOptions::default())?;
+                archive.write_all(&serde_json::to_vec_pretty(&checksums)?)?;
+                archive.finish()?;
+            }
+            // Run the real installer checks before publishing the archive.
+            let staging_parent = tempfile::tempdir()?;
+            let validated = prepare(staging.path(), staging_parent.path(), &args[3])?;
+            staging.as_file().sync_all()?;
+            staging.persist(&output).map_err(|e| e.error)?;
+            println!("Packaged {} {} -> {}\nSHA-256: {}", manifest.id, manifest.version, output.display(), validated.preview.digest);
+        }
+        Some("inspect") if args.len() == 3 => {
+            let staging = tempfile::tempdir()?;
+            let package = prepare(Path::new(&args[1]), staging.path(), &args[2])?;
+            println!("{}", serde_json::to_string_pretty(&package.preview)?);
+        }
+        _ => return Err("Usage: nyaterm-plugin pack   \n       nyaterm-plugin inspect  ".into()),
+    }
+    Ok(())
+}
+
+fn collect(
+    root: &Path,
+    path: &Path,
+    files: &mut BTreeMap>,
+) -> Result<(), Box> {
+    let metadata = std::fs::symlink_metadata(path)?;
+    if metadata.file_type().is_symlink() {
+        return Err("Plugin source cannot contain symlinks".into());
+    }
+    if metadata.is_dir() {
+        for entry in std::fs::read_dir(path)? {
+            collect(root, &entry?.path(), files)?;
+        }
+    } else if metadata.is_file() {
+        let relative = path
+            .strip_prefix(root)?
+            .to_str()
+            .ok_or("Non-UTF-8 plugin path")?
+            .replace('\\', "/");
+        validate_path(&relative)?;
+        if files.len() >= 1023 || metadata.len() > 64 * 1024 * 1024 {
+            return Err("Plugin source is too large".into());
+        }
+        if files.values().map(Vec::len).sum::() + metadata.len() as usize > 256 * 1024 * 1024
+        {
+            return Err("Plugin source exceeds the expanded package limit".into());
+        }
+        let mut bytes = Vec::new();
+        std::fs::File::open(path)?
+            .take(64 * 1024 * 1024 + 1)
+            .read_to_end(&mut bytes)?;
+        if bytes.len() > 64 * 1024 * 1024 {
+            return Err("Plugin file is too large".into());
+        }
+        files.insert(relative, bytes);
+    } else {
+        return Err("Plugin source cannot contain special files".into());
+    }
+    Ok(())
+}
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/bin/plugin-fixture.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/plugin-fixture.rs
new file mode 100644
index 00000000..f5203c68
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/plugin-fixture.rs
@@ -0,0 +1,40 @@
+//! A small protocol peer used by process-level runtime tests and as a native example.
+use serde_json::{Value, json};
+use std::io::{BufRead, Write};
+
+fn main() {
+    let stdin = std::io::stdin();
+    let mut lines = stdin.lock().lines();
+    let mut stdout = std::io::stdout().lock();
+    while let Some(Ok(line)) = lines.next() {
+        let request: Value = serde_json::from_str(&line).unwrap();
+        let Some(method) = request.get("method").and_then(Value::as_str) else {
+            continue;
+        };
+        let result = match method {
+            "plugin/initialize" => {
+                let id = std::env::var("NYATERM_PLUGIN_ID").unwrap();
+                json!({"pluginId":id,"pluginVersion":if id == "bad.handshake" { "9.9.9".to_string() } else { std::env::var("NYATERM_PLUGIN_VERSION").unwrap() },"protocolVersion":1})
+            }
+            "ui/echo" | "command/hello" => request.get("params").cloned().unwrap_or(Value::Null),
+            "ui/timeout" => continue,
+            "ui/crash" => std::process::exit(7),
+            "ui/host" => {
+                writeln!(stdout, "{}", json!({"jsonrpc":"2.0","id":"host-1","method":"host/session","params":{"scopeToken":"fixture-scope"}})).unwrap();
+                stdout.flush().unwrap();
+                let response: Value =
+                    serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
+                response.get("result").cloned().unwrap_or(Value::Null)
+            }
+            "$/cancelRequest" => continue,
+            _ => Value::Null,
+        };
+        writeln!(
+            stdout,
+            "{}",
+            json!({"jsonrpc":"2.0","id":request["id"],"result":result})
+        )
+        .unwrap();
+        stdout.flush().unwrap();
+    }
+}
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/bin/sdk-fixture.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/sdk-fixture.rs
new file mode 100644
index 00000000..54c19b91
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/sdk-fixture.rs
@@ -0,0 +1,77 @@
+use nyaterm_plugin_sdk::{
+    Context, Initialization, Plugin, Result, RpcError, Server, Value, async_trait, json,
+};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Duration;
+
+#[derive(Default)]
+struct Fixture {
+    saved: std::sync::Mutex>,
+    count: AtomicUsize,
+    completed: AtomicUsize,
+    binary: AtomicUsize,
+}
+#[async_trait]
+impl Plugin for Fixture {
+    async fn initialize(&self, info: Initialization) -> Result<()> {
+        eprintln!("SDK initialization started");
+        if info.plugin_id == "bad.sdk" {
+            return Err(RpcError::invalid("Fixture handshake failure"));
+        }
+        Ok(())
+    }
+    async fn call(&self, context: Context, method: &str, input: Value) -> Result {
+        match method {
+            "ui/save-context" => {
+                *self.saved.lock().unwrap() = Some(context);
+                Ok(Value::Null)
+            }
+            "ui/reuse-context" => {
+                let context = self
+                    .saved
+                    .lock()
+                    .unwrap()
+                    .clone()
+                    .ok_or_else(|| RpcError::invalid("No saved context"))?;
+                context.host().session().await
+            }
+            "ui/echo" => Ok(input),
+            "ui/count" => Ok(json!(self.count.fetch_add(1, Ordering::Relaxed) + 1)),
+            "ui/host" => context.host().session().await,
+            "ui/log" => {
+                context.log("error", "Fixture diagnostic error").await?;
+                Ok(Value::Null)
+            }
+            "ui/slow" => {
+                tokio::time::sleep(Duration::from_secs(1)).await;
+                self.completed.fetch_add(1, Ordering::Relaxed);
+                Ok(Value::Null)
+            }
+            "ui/completed" => Ok(json!(self.completed.load(Ordering::Relaxed))),
+            "ui/host-timeout" => {
+                context
+                    .host()
+                    .call_with_timeout("host/slow", Value::Null, Duration::from_millis(20))
+                    .await
+            }
+            "ui/binary" => {
+                context.send_binary("fixture", vec![1, 2, 3]).await?;
+                Ok(json!(self.binary.load(Ordering::Relaxed)))
+            }
+            "ui/panic" => panic!("fixture panic"),
+            "ui/crash" => std::process::exit(9),
+            _ => Err(RpcError::method_not_found(method)),
+        }
+    }
+    async fn binary(&self, channel: String, data: Vec) -> Result<()> {
+        if channel != "fixture" || data != [3, 2, 1] {
+            return Err(RpcError::invalid("Unexpected binary input"));
+        }
+        self.binary.fetch_add(1, Ordering::Relaxed);
+        Ok(())
+    }
+}
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> Result<()> {
+    Server::from_env()?.serve(Fixture::default()).await
+}
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/diagnostics.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/diagnostics.rs
new file mode 100644
index 00000000..291d54e3
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/diagnostics.rs
@@ -0,0 +1,288 @@
+//! Bounded, memory-only diagnostics. Tickets discard events from replaced activations.
+use serde::Serialize;
+use std::collections::{HashMap, VecDeque};
+use std::sync::{Arc, LazyLock, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+const MAX_ENTRIES: usize = 100;
+const MAX_BYTES: usize = 64 * 1024;
+const MAX_MESSAGE: usize = 2048;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Status {
+    Idle,
+    Starting,
+    Running,
+    Stopped,
+    Error,
+}
+
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LogEntry {
+    pub timestamp: u64,
+    pub version: String,
+    pub level: String,
+    pub source: String,
+    pub message: String,
+}
+
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Snapshot {
+    pub status: Status,
+    pub version: String,
+    pub last_error: Option,
+    pub logs: VecDeque,
+}
+struct Record {
+    generation: u64,
+    snapshot: Snapshot,
+}
+#[derive(Default)]
+struct State {
+    next: u64,
+    records: HashMap,
+}
+#[derive(Clone, Default)]
+pub struct Diagnostics {
+    state: Arc>,
+}
+#[derive(Clone)]
+pub struct Ticket {
+    id: String,
+    generation: u64,
+}
+
+impl Diagnostics {
+    pub fn begin(&self, id: &str, version: &str) -> Ticket {
+        let mut state = self
+            .state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.next += 1;
+        let generation = state.next;
+        let record = state.records.entry(id.into()).or_insert_with(|| Record {
+            generation,
+            snapshot: Snapshot {
+                status: Status::Idle,
+                version: version.into(),
+                last_error: None,
+                logs: VecDeque::new(),
+            },
+        });
+        record.generation = generation;
+        record.snapshot.status = Status::Starting;
+        record.snapshot.version = version.into();
+        record.snapshot.last_error = None;
+        append(
+            &mut record.snapshot,
+            "info",
+            "host",
+            "Starting native backend",
+        );
+        Ticket {
+            id: id.into(),
+            generation,
+        }
+    }
+    pub fn update(
+        &self,
+        ticket: &Ticket,
+        status: Option,
+        level: &str,
+        source: &str,
+        message: &str,
+    ) {
+        let mut state = self
+            .state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        if let Some(record) = state
+            .records
+            .get_mut(&ticket.id)
+            .filter(|r| r.generation == ticket.generation)
+        {
+            // Protocol failures remain errors when the process subsequently reports its exit.
+            if let Some(status) = status
+                && !(status == Status::Running && record.snapshot.status != Status::Starting)
+            {
+                record.snapshot.status = status;
+            }
+            append(&mut record.snapshot, level, source, message);
+        }
+    }
+    pub fn ticket(&self, id: &str) -> Option {
+        let state = self
+            .state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.records.get(id).map(|r| Ticket {
+            id: id.into(),
+            generation: r.generation,
+        })
+    }
+    pub fn stop(&self, id: &str) {
+        let mut state = self
+            .state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        state.next += 1;
+        let generation = state.next;
+        if let Some(record) = state.records.get_mut(id) {
+            record.generation = generation;
+            record.snapshot.status = Status::Stopped;
+            append(
+                &mut record.snapshot,
+                "info",
+                "host",
+                "Native backend stopped",
+            );
+        }
+    }
+    pub fn snapshot(&self, id: &str, version: &str) -> Snapshot {
+        let state = self
+            .state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        state
+            .records
+            .get(id)
+            .map(|r| {
+                let mut snapshot = r.snapshot.clone();
+                if snapshot.version != version {
+                    snapshot.version = version.into();
+                    snapshot.status = Status::Stopped;
+                    snapshot.last_error = None;
+                }
+                snapshot
+            })
+            .unwrap_or_else(|| Snapshot {
+                status: Status::Idle,
+                version: version.into(),
+                last_error: None,
+                logs: VecDeque::new(),
+            })
+    }
+    pub fn clear(&self, id: &str) {
+        let mut state = self
+            .state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        if let Some(record) = state.records.get_mut(id) {
+            record.snapshot.logs.clear();
+            record.snapshot.last_error = None;
+        }
+    }
+    pub fn remove(&self, id: &str) {
+        self.state
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
+            .records
+            .remove(id);
+    }
+}
+
+fn append(snapshot: &mut Snapshot, level: &str, source: &str, message: &str) {
+    // Scope tokens are UUIDs. Also mask common credential assignments, even in stderr.
+    static UUID: LazyLock = LazyLock::new(|| {
+        regex::Regex::new(r"(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+            .unwrap()
+    });
+    static SECRET: LazyLock = LazyLock::new(|| {
+        regex::Regex::new(r#"(?i)(scopeToken|password|passwd|token|api[_-]?key|secret|authorization)[\s"']*[:=][\s"']*[^\s,;"'}]+"#).unwrap()
+    });
+    let message = SECRET.replace_all(message, "$1=[redacted]");
+    let message = UUID.replace_all(&message, "[scope]");
+    let mut message: String = message
+        .chars()
+        .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
+        .collect();
+    if message.len() > MAX_MESSAGE {
+        let mut end = MAX_MESSAGE - 3;
+        while !message.is_char_boundary(end) {
+            end -= 1;
+        }
+        message.truncate(end);
+        message.push_str("...");
+    }
+    let level = if ["debug", "info", "warn", "error"].contains(&level) {
+        level
+    } else {
+        "info"
+    };
+    if level == "error" {
+        snapshot.last_error = Some(message.clone());
+    }
+    snapshot.logs.push_back(LogEntry {
+        timestamp: SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap_or_default()
+            .as_millis() as u64,
+        version: snapshot.version.clone(),
+        level: level.into(),
+        source: source.chars().take(64).collect(),
+        message,
+    });
+    while snapshot.logs.len() > MAX_ENTRIES
+        || snapshot
+            .logs
+            .iter()
+            .map(|entry| entry.message.len() + entry.source.len() + entry.version.len())
+            .sum::()
+            > MAX_BYTES
+    {
+        snapshot.logs.pop_front();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[test]
+    fn bounds_redacts_and_discards_old_activations() {
+        let store = Diagnostics::default();
+        let old = store.begin("example.test", "1.0.0");
+        store.update(
+            &old,
+            Some(Status::Error),
+            "error",
+            "stderr",
+            "password=hunter2 scopeToken=12345678-1234-1234-1234-123456789abc",
+        );
+        let snapshot = store.snapshot("example.test", "1.0.0");
+        assert!(!snapshot.last_error.unwrap().contains("hunter2"));
+        for _ in 0..110 {
+            store.update(&old, None, "info", "sdk", &"测".repeat(2000));
+        }
+        let snapshot = store.snapshot("example.test", "1.0.0");
+        assert!(snapshot.logs.len() <= 100);
+        assert!(
+            snapshot
+                .logs
+                .iter()
+                .map(|e| e.message.len() + e.source.len() + e.version.len())
+                .sum::()
+                <= MAX_BYTES
+        );
+        store.stop("example.test");
+        store.update(&old, Some(Status::Error), "error", "host", "late crash");
+        assert_eq!(
+            store.snapshot("example.test", "1.0.0").status,
+            Status::Stopped
+        );
+        let new = store.begin("example.test", "2.0.0");
+        store.update(&new, Some(Status::Running), "info", "host", "Started");
+        store.update(&old, Some(Status::Error), "error", "host", "late crash");
+        assert_eq!(
+            store.snapshot("example.test", "2.0.0").status,
+            Status::Running
+        );
+        store.clear("example.test");
+        assert!(store.snapshot("example.test", "2.0.0").logs.is_empty());
+        store.remove("example.test");
+        store.update(&new, Some(Status::Error), "error", "host", "late crash");
+        assert_eq!(store.snapshot("example.test", "2.0.0").status, Status::Idle);
+    }
+}
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs
new file mode 100644
index 00000000..cba041a1
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs
@@ -0,0 +1,26 @@
+//! Tauri-independent installation, manifest validation and sidecar transport.
+pub mod diagnostics;
+pub mod manifest;
+pub mod package;
+pub mod registry;
+pub mod sidecar;
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    #[error("{0}")]
+    Invalid(String),
+    #[error("{0}")]
+    Runtime(String),
+    #[error(transparent)]
+    Io(#[from] std::io::Error),
+    #[error(transparent)]
+    Json(#[from] serde_json::Error),
+    #[error(transparent)]
+    Zip(#[from] zip::result::ZipError),
+}
+
+pub type Result = std::result::Result;
+
+pub(crate) fn invalid(message: impl Into) -> Error {
+    Error::Invalid(message.into())
+}
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/manifest.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/manifest.rs
new file mode 100644
index 00000000..9c1ab777
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/manifest.rs
@@ -0,0 +1,312 @@
+use crate::{Result, invalid};
+use serde::{Deserialize, Serialize};
+use std::collections::{HashMap, HashSet};
+
+pub const API_VERSION: u32 = 1;
+pub const MAX_MANIFEST_BYTES: usize = 128 * 1024;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Manifest {
+    pub manifest_version: u32,
+    pub id: String,
+    pub name: String,
+    pub version: String,
+    pub description: String,
+    pub publisher: String,
+    pub engine: String,
+    #[serde(default)]
+    pub permissions: Vec,
+    #[serde(default)]
+    pub contributions: Contributions,
+    pub backend: Option,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Contributions {
+    #[serde(default)]
+    pub panels: Vec,
+    #[serde(default)]
+    pub commands: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Panel {
+    pub id: String,
+    pub title: String,
+    pub entry: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Command {
+    pub id: String,
+    pub title: String,
+    pub panel: Option,
+    pub method: Option,
+    #[serde(default)]
+    pub menus: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum MenuLocation {
+    Terminal,
+    Connection,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Backend {
+    pub transport: Transport,
+    /// Keys such as windows-x86_64, linux-aarch64 and macos-aarch64.
+    pub executables: HashMap,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum Transport {
+    StdioJsonl,
+    StdioFramed,
+}
+
+pub fn valid_id(value: &str) -> bool {
+    !value.is_empty()
+        && value.len() <= 128
+        && value
+            .bytes()
+            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b".-_".contains(&b))
+        && value.as_bytes()[0].is_ascii_alphanumeric()
+        && !value.contains("..")
+}
+
+/// Portable archive paths; rejects Windows aliases even when installed on Unix.
+pub fn validate_path(value: &str) -> Result<()> {
+    if value.is_empty() || value.len() > 240 || value.contains(['\\', ':', '\0']) {
+        return Err(invalid("Invalid plugin asset path"));
+    }
+    for component in value.split('/') {
+        if component.is_empty()
+            || component == "."
+            || component == ".."
+            || component.ends_with(['.', ' '])
+            || component.chars().any(char::is_control)
+            || component.contains(['<', '>', '"', '|', '?', '*'])
+        {
+            return Err(invalid("Invalid plugin asset path"));
+        }
+        let stem = component
+            .split('.')
+            .next()
+            .unwrap_or_default()
+            .to_ascii_uppercase();
+        if [
+            "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
+            "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
+        ]
+        .contains(&stem.as_str())
+        {
+            return Err(invalid("Reserved plugin asset path"));
+        }
+    }
+    Ok(())
+}
+
+pub fn network_origin(permission: &str) -> Option<&str> {
+    permission.strip_prefix("network:")
+}
+
+pub fn validate_permission(permission: &str) -> Result<()> {
+    if [
+        "session.read",
+        "terminal.read",
+        "terminal.execute",
+        "filesystem.read",
+        "storage",
+        "native",
+    ]
+    .contains(&permission)
+    {
+        return Ok(());
+    }
+    if let Some(origin) = network_origin(permission) {
+        let url = url::Url::parse(origin).map_err(|_| invalid("Invalid network permission"))?;
+        if url.scheme() == "https"
+            && url.host_str().is_some()
+            && url.username().is_empty()
+            && url.password().is_none()
+            && url.path() == "/"
+            && url.query().is_none()
+            && url.fragment().is_none()
+            && url.origin().ascii_serialization() == origin
+        {
+            return Ok(());
+        }
+    }
+    Err(invalid(format!(
+        "Unknown or invalid permission: {permission}"
+    )))
+}
+
+impl Manifest {
+    pub fn parse(bytes: &[u8], app_version: &str) -> Result {
+        if bytes.len() > MAX_MANIFEST_BYTES {
+            return Err(invalid("Plugin manifest is too large"));
+        }
+        let manifest: Self = serde_json::from_slice(bytes)?;
+        manifest.validate(app_version)?;
+        Ok(manifest)
+    }
+
+    pub fn validate(&self, app_version: &str) -> Result<()> {
+        if self.manifest_version != API_VERSION || !valid_id(&self.id) || !self.id.contains('.') {
+            return Err(invalid("Unsupported manifest version or invalid plugin ID"));
+        }
+        for (name, value, limit) in [
+            ("name", &self.name, 120),
+            ("description", &self.description, 2000),
+            ("publisher", &self.publisher, 120),
+        ] {
+            if value.trim().is_empty() || value.len() > limit || value.chars().any(|c| c == '\0') {
+                return Err(invalid(format!("Invalid plugin {name}")));
+            }
+        }
+        let version =
+            semver::Version::parse(&self.version).map_err(|_| invalid("Invalid plugin version"))?;
+        if version.to_string() != self.version || self.version.len() > 80 {
+            return Err(invalid("Invalid plugin version"));
+        }
+        let requirement = semver::VersionReq::parse(&self.engine)
+            .map_err(|_| invalid("Invalid engine requirement"))?;
+        let app =
+            semver::Version::parse(app_version).map_err(|_| invalid("Invalid app version"))?;
+        if !requirement.matches(&app) {
+            return Err(invalid("Plugin is incompatible with this NyaTerm version"));
+        }
+        if self.permissions.len() > 24
+            || self.contributions.panels.len() > 16
+            || self.contributions.commands.len() > 64
+        {
+            return Err(invalid("Too many plugin permissions or contributions"));
+        }
+        let mut permissions = HashSet::new();
+        for permission in &self.permissions {
+            validate_permission(permission)?;
+            if !permissions.insert(permission) {
+                return Err(invalid("Duplicate permission"));
+            }
+        }
+        if self
+            .permissions
+            .iter()
+            .filter(|p| network_origin(p).is_some())
+            .count()
+            > 8
+        {
+            return Err(invalid("Too many network origins"));
+        }
+        let mut ids = HashSet::new();
+        for panel in &self.contributions.panels {
+            if !valid_id(&panel.id)
+                || panel.title.trim().is_empty()
+                || panel.title.len() > 120
+                || !ids.insert(&panel.id)
+            {
+                return Err(invalid("Invalid or duplicate panel"));
+            }
+            validate_path(&panel.entry)?;
+            if !panel.entry.starts_with("ui/") || !panel.entry.ends_with(".html") {
+                return Err(invalid("Panel entry must be HTML"));
+            }
+        }
+        for command in &self.contributions.commands {
+            if !valid_id(&command.id)
+                || command.title.trim().is_empty()
+                || command.title.len() > 120
+                || !ids.insert(&command.id)
+            {
+                return Err(invalid("Invalid or duplicate command"));
+            }
+            match (&command.panel, &command.method) {
+                (Some(panel), None) if self.contributions.panels.iter().any(|p| &p.id == panel) => {
+                }
+                (None, Some(method))
+                    if self.backend.is_some()
+                        && method.starts_with("command/")
+                        && method.len() <= 128 => {}
+                _ => return Err(invalid("Command must reference a panel or backend method")),
+            }
+            if command.menus.len() > 2 {
+                return Err(invalid("Too many command menu locations"));
+            }
+        }
+        if let Some(backend) = &self.backend {
+            if !self.permissions.iter().any(|p| p == "native")
+                || backend.executables.is_empty()
+                || backend.executables.len() > 8
+            {
+                return Err(invalid(
+                    "Native backend requires the native permission and executables",
+                ));
+            }
+            for (platform, path) in &backend.executables {
+                if ![
+                    "windows-x86_64",
+                    "windows-aarch64",
+                    "linux-x86_64",
+                    "linux-aarch64",
+                    "macos-x86_64",
+                    "macos-aarch64",
+                ]
+                .contains(&platform.as_str())
+                {
+                    return Err(invalid("Unsupported backend platform"));
+                }
+                validate_path(path)?;
+                if !path.starts_with("bin/") {
+                    return Err(invalid("Backend executable must be under bin/"));
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[test]
+    fn rejects_nonportable_and_traversing_paths() {
+        for path in [
+            "../secret",
+            "/absolute",
+            "C:/x",
+            "ui\\x",
+            "ui/a:stream",
+            "ui/CON.html",
+            "ui/a.",
+            "ui/a ",
+            "ui//a",
+            "ui/./a",
+        ] {
+            assert!(validate_path(path).is_err(), "{path}");
+        }
+        assert!(validate_path("ui/index.html").is_ok());
+    }
+    #[test]
+    fn network_permissions_are_exact_https_origins() {
+        for value in [
+            "network:*",
+            "network:http://example.com",
+            "network:https://a.com/path",
+            "network:https://u@a.com",
+            "network:https://a.com/",
+            "network:https://a.com?x",
+        ] {
+            assert!(validate_permission(value).is_err(), "{value}");
+        }
+        assert!(validate_permission("network:https://example.com:8443").is_ok());
+    }
+}
diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs
new file mode 100644
index 00000000..3929c6a8
--- /dev/null
+++ b/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs
@@ -0,0 +1,220 @@
+use crate::manifest::{Manifest, validate_path};
+use crate::{Result, invalid};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::collections::{BTreeMap, HashSet};
+use std::fs::File;
+use std::io::Read;
+use std::path::{Path, PathBuf};
+
+pub const MAX_PACKAGE_BYTES: u64 = 128 * 1024 * 1024;
+const MAX_EXPANDED_BYTES: u64 = 256 * 1024 * 1024;
+const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
+const MAX_FILES: usize = 1024;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PackagePreview {
+    pub manifest: Manifest,
+    pub digest: String,
+    pub expanded_bytes: u64,
+}
+
+pub struct PreparedPackage {
+    pub preview: PackagePreview,
+    pub directory: tempfile::TempDir,
+}
+
+/// Inspect and extract into a private staging directory. Nothing becomes active here.
+pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result {
+    let mut file = File::open(path)?;
+    if file.metadata()?.len() > MAX_PACKAGE_BYTES {
+        return Err(invalid("Plugin package is too large"));
+    }
+    let mut digest = Sha256::new();
+    let mut buffer = [0_u8; 64 * 1024];
+    loop {
+        let read = file.read(&mut buffer)?;
+        if read == 0 {
+            break;
+        }
+        digest.update(&buffer[..read]);
+    }
+    use std::io::{Seek, SeekFrom};
+    file.seek(SeekFrom::Start(0))?;
+    let package_digest = hex::encode(digest.finalize());
+    let mut archive = zip::ZipArchive::new(file)?;
+    if archive.len() > MAX_FILES {
+        return Err(invalid("Too many plugin package entries"));
+    }
+    std::fs::create_dir_all(staging_parent)?;
+    let directory = tempfile::Builder::new()
+        .prefix(".staging-")
+        .tempdir_in(staging_parent)?;
+    let mut names = HashSet::new();
+    let mut hashes = BTreeMap::new();
+    let mut expanded_bytes = 0_u64;
+    for index in 0..archive.len() {
+        let mut entry = archive.by_index(index)?;
+        let name = entry.name().to_owned();
+        let is_dir = entry.is_dir();
+        let portable_name = if is_dir {
+            name.trim_end_matches('/')
+        } else {
+            &name
+        };
+        validate_path(portable_name)?;
+        if !names.insert(portable_name.to_ascii_lowercase()) {
+            return Err(invalid("Duplicate or case-colliding package path"));
+        }
+        if let Some(mode) = entry.unix_mode() {
+            let kind = mode & 0o170_000;
+            if kind != 0 && kind != 0o100_000 && !(is_dir && kind == 0o040_000) {
+                return Err(invalid(
+                    "Plugin packages cannot contain symlinks or special files",
+                ));
+            }
+        }
+        let target = directory.path().join(portable_name);
+        if is_dir {
+            std::fs::create_dir_all(&target)?;
+            continue;
+        }
+        expanded_bytes = expanded_bytes
+            .checked_add(entry.size())
+            .ok_or_else(|| invalid("Package size overflow"))?;
+        if entry.size() > MAX_FILE_BYTES || expanded_bytes > MAX_EXPANDED_BYTES {
+            return Err(invalid("Expanded plugin package is too large"));
+        }
+        let mut bytes = Vec::new();
+        let declared_size = entry.size();
+        entry
+            .by_ref()
+            .take(MAX_FILE_BYTES + 1)
+            .read_to_end(&mut bytes)?;
+        if bytes.len() as u64 != declared_size || bytes.len() as u64 > MAX_FILE_BYTES {
+            return Err(invalid("Plugin entry size mismatch"));
+        }
+        if name == "checksums.json" && bytes.len() > 256 * 1024 {
+            return Err(invalid("Checksums document is too large"));
+        }
+        if name != "checksums.json" {
+            hashes.insert(name.clone(), hex::encode(Sha256::digest(&bytes)));
+        }
+        if let Some(parent) = target.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        std::fs::write(&target, bytes)?;
+    }
+    let checksums: BTreeMap =
+        serde_json::from_slice(&std::fs::read(directory.path().join("checksums.json"))?)?;
+    if checksums != hashes {
+        return Err(invalid(
+            "Plugin package checksums do not match its complete contents",
+        ));
+    }
+    let manifest = Manifest::parse(
+        &std::fs::read(directory.path().join("manifest.json"))?,
+        app_version,
+    )?;
+    for panel in &manifest.contributions.panels {
+        checked_file(directory.path(), &panel.entry)?;
+    }
+    if let Some(backend) = &manifest.backend {
+        for executable in backend.executables.values() {
+            let path = checked_file(directory.path(), executable)?;
+            #[cfg(unix)]
+            {
+                use std::os::unix::fs::PermissionsExt;
+                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
+            }
+            #[cfg(not(unix))]
+            let _ = path;
+        }
+    }
+    Ok(PreparedPackage {
+        preview: PackagePreview {
+            manifest,
+            digest: package_digest,
+            expanded_bytes,
+        },
+        directory,
+    })
+}
+
+/// Resolve installed assets without following a symlink outside the package.
+pub fn checked_file(root: &Path, relative: &str) -> Result {
+    validate_path(relative)?;
+    let root = root.canonicalize()?;
+    let path = root.join(relative).canonicalize()?;
+    if !path.starts_with(&root) || !path.is_file() {
+        return Err(invalid(
+            "Plugin asset is outside its package or is not a file",
+        ));
+    }
+    Ok(path)
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+    use super::*;
+    use std::io::Write;
+    use zip::write::SimpleFileOptions;
+
+    pub fn package(path: &Path, extra: &[(&str, &[u8])]) {
+        let manifest = br#"{"manifestVersion":1,"id":"example.tools","name":"Tools","version":"1.0.0","description":"Test tools","publisher":"Example","engine":">=1.0.0","contributions":{"panels":[{"id":"overview","title":"Overview","entry":"ui/index.html"}]}}"#;
+        package_manifest(path, manifest, extra);
+    }
+
+    pub fn package_manifest(path: &Path, manifest: &[u8], extra: &[(&str, &[u8])]) {
+        let mut entries = vec![
+            ("manifest.json", manifest),
+            ("ui/index.html", b"

Hello

".as_slice()), + ]; + entries.extend_from_slice(extra); + let checksums = entries + .iter() + .map(|(name, bytes)| (name.to_string(), hex::encode(Sha256::digest(bytes)))) + .collect::>(); + let mut zip = zip::ZipWriter::new(File::create(path).unwrap()); + for (name, bytes) in entries { + zip.start_file(name, SimpleFileOptions::default()).unwrap(); + zip.write_all(bytes).unwrap(); + } + zip.start_file("checksums.json", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(&serde_json::to_vec(&checksums).unwrap()) + .unwrap(); + zip.finish().unwrap(); + } + + #[test] + fn verifies_complete_package_and_rejects_traversal_and_case_collisions() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("test.nyap"); + package(&path, &[]); + let prepared = prepare(&path, temp.path(), "1.2.12").unwrap(); + assert_eq!(prepared.preview.manifest.id, "example.tools"); + assert!(prepared.directory.path().join("ui/index.html").is_file()); + package(&path, &[("../escape", b"bad")]); + assert!(prepare(&path, temp.path(), "1.2.12").is_err()); + assert!(!temp.path().parent().unwrap().join("escape").exists()); + package(&path, &[("UI/INDEX.HTML", b"bad")]); + assert!(prepare(&path, temp.path(), "1.2.12").is_err()); + } + + #[test] + fn rejects_modified_content() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("test.nyap"); + package(&path, &[]); + let mut zip = + zip::ZipWriter::new_append(File::options().read(true).write(true).open(&path).unwrap()) + .unwrap(); + zip.start_file("unchecked.txt", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(b"not in checksums").unwrap(); + zip.finish().unwrap(); + assert!(prepare(&path, temp.path(), "1.2.12").is_err()); + } +} diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs new file mode 100644 index 00000000..58de5407 --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs @@ -0,0 +1,396 @@ +use crate::manifest::{Manifest, valid_id}; +use crate::package::{PreparedPackage, checked_file}; +use crate::{Result, invalid}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledVersion { + pub manifest: Manifest, + pub digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledPlugin { + pub id: String, + pub active_version: String, + pub enabled: bool, + pub granted_permissions: Vec, + pub versions: BTreeMap, +} + +impl InstalledPlugin { + pub fn active(&self) -> Result<&InstalledVersion> { + self.versions + .get(&self.active_version) + .ok_or_else(|| invalid("Missing active plugin version")) + } + pub fn require(&self, permission: &str) -> Result<()> { + if self.enabled + && self + .active()? + .manifest + .permissions + .iter() + .any(|p| p == permission) + && self.granted_permissions.iter().any(|p| p == permission) + { + Ok(()) + } else { + Err(invalid(format!("Plugin permission denied: {permission}"))) + } + } +} + +/// Mutation is serialized by the caller, including outstanding runtime leases. +pub struct Registry { + root: PathBuf, + app_version: String, + installed: BTreeMap, +} + +pub fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| invalid("Missing storage directory"))?; + std::fs::create_dir_all(parent)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(&serde_json::to_vec_pretty(value)?)?; + temporary.as_file().sync_all()?; + temporary.persist(path).map_err(|e| e.error)?; + Ok(()) +} + +impl Registry { + pub fn open(root: PathBuf, app_version: String) -> Result { + std::fs::create_dir_all(&root)?; + let index = root.join("registry.json"); + let installed: BTreeMap = if index.exists() { + if std::fs::metadata(&index)?.len() > 8 * 1024 * 1024 { + return Err(invalid("Plugin registry is too large")); + } + serde_json::from_slice(&std::fs::read(index)?)? + } else { + BTreeMap::new() + }; + for (id, plugin) in &installed { + if !valid_id(id) + || plugin.id != *id + || plugin.versions.is_empty() + || plugin.versions.len() > 32 + { + return Err(invalid("Invalid plugin registry")); + } + plugin.active()?; + for (version, record) in &plugin.versions { + crate::manifest::validate_path(version)?; + if record.manifest.id != *id || record.manifest.version != *version { + return Err(invalid("Invalid plugin version record")); + } + } + } + Ok(Self { + root, + app_version, + installed, + }) + } + + pub fn root(&self) -> &Path { + &self.root + } + pub fn app_version(&self) -> &str { + &self.app_version + } + pub fn list(&self) -> Vec { + self.installed.values().cloned().collect() + } + pub fn get(&self, id: &str) -> Result<&InstalledPlugin> { + self.installed + .get(id) + .ok_or_else(|| invalid("Plugin is not installed")) + } + pub fn active_path(&self, id: &str) -> Result { + let plugin = self.get(id)?; + let path = self + .root + .join(id) + .join("versions") + .join(&plugin.active_version); + let root = self.root.canonicalize()?; + let resolved = path.canonicalize()?; + if !resolved.starts_with(root) { + return Err(invalid("Plugin installation is outside its root")); + } + Ok(resolved) + } + pub fn asset(&self, id: &str, relative: &str) -> Result { + checked_file(&self.active_path(id)?, relative) + } + + fn save(&mut self, next: BTreeMap) -> Result<()> { + if serde_json::to_vec_pretty(&next)?.len() > 8 * 1024 * 1024 { + return Err(invalid("Plugin registry quota exceeded")); + } + atomic_json(&self.root.join("registry.json"), &next)?; + self.installed = next; + Ok(()) + } + + pub fn install( + &mut self, + package: PreparedPackage, + expected_digest: &str, + ) -> Result { + if package.preview.digest != expected_digest { + return Err(invalid("Package changed since it was inspected")); + } + package.preview.manifest.validate(&self.app_version)?; + let record = InstalledVersion { + manifest: package.preview.manifest.clone(), + digest: package.preview.digest.clone(), + }; + let id = record.manifest.id.clone(); + let version = record.manifest.version.clone(); + crate::manifest::validate_path(&version)?; + let mut next = self.installed.clone(); + if next.len() >= 128 && !next.contains_key(&id) { + return Err(invalid("Too many installed plugins")); + } + let plugin = next.entry(id.clone()).or_insert_with(|| InstalledPlugin { + id: id.clone(), + active_version: version.clone(), + enabled: false, + granted_permissions: Vec::new(), + versions: BTreeMap::new(), + }); + if let Ok(active) = plugin.active() + && active.manifest.publisher != record.manifest.publisher + { + return Err(invalid( + "Plugin publisher changed; uninstall before installing from a new publisher", + )); + } + let mut moved_target = None; + if let Some(existing) = plugin.versions.get(&version) { + if existing.digest != record.digest { + return Err(invalid( + "An installed version cannot be replaced by different content", + )); + } + } else { + if plugin.versions.len() >= 32 { + return Err(invalid("Too many installed plugin versions")); + } + let versions_dir = self.root.join(&id).join("versions"); + std::fs::create_dir_all(&versions_dir)?; + let target = versions_dir.join(&version); + if target.exists() { + return Err(invalid( + "Unregistered plugin version directory already exists", + )); + } + std::fs::rename(package.directory.path(), &target)?; + moved_target = Some(target); + plugin.versions.insert(version.clone(), record); + } + plugin.active_version = version; + // New versions are always reviewed before reactivation, including permission changes. + plugin.enabled = false; + plugin.granted_permissions.retain(|p| { + plugin.versions[&plugin.active_version] + .manifest + .permissions + .contains(p) + }); + let installed = plugin.clone(); + if let Err(error) = self.save(next) { + if let Some(target) = moved_target { + let _ = std::fs::remove_dir_all(target); + } + return Err(error); + } + Ok(installed) + } + + pub fn configure(&mut self, id: &str, enabled: bool, permissions: Vec) -> Result<()> { + let mut next = self.installed.clone(); + let plugin = next + .get_mut(id) + .ok_or_else(|| invalid("Plugin is not installed"))?; + let manifest = &plugin.active()?.manifest; + if enabled { + manifest.validate(&self.app_version)?; + } + let unique = permissions.iter().collect::>(); + if unique.len() != permissions.len() + || permissions + .iter() + .any(|p| !manifest.permissions.contains(p)) + { + return Err(invalid("Grant contains undeclared permissions")); + } + if enabled && manifest.backend.is_some() && !permissions.iter().any(|p| p == "native") { + return Err(invalid( + "Native backend requires explicit native execution approval", + )); + } + plugin.enabled = enabled; + plugin.granted_permissions = permissions; + self.save(next) + } + + pub fn activate_version(&mut self, id: &str, version: &str) -> Result<()> { + let mut next = self.installed.clone(); + let plugin = next + .get_mut(id) + .ok_or_else(|| invalid("Plugin is not installed"))?; + let record = plugin + .versions + .get(version) + .ok_or_else(|| invalid("Plugin version is not installed"))?; + record.manifest.validate(&self.app_version)?; + plugin.active_version = version.to_string(); + plugin.enabled = false; + plugin + .granted_permissions + .retain(|p| record.manifest.permissions.contains(p)); + self.save(next) + } + + pub fn uninstall(&mut self, id: &str) -> Result<()> { + self.get(id)?; + let mut next = self.installed.clone(); + next.remove(id); + // Rename before committing, so a reinstall cannot observe stale version directories. + let directory = self.root.join(id); + let trash = self.root.join(format!(".removed-{}", uuid::Uuid::new_v4())); + if directory.exists() { + std::fs::rename(&directory, &trash)?; + } + if let Err(error) = self.save(next) { + if trash.exists() { + let _ = std::fs::rename(&trash, &directory); + } + return Err(error); + } + if trash.exists() { + std::fs::remove_dir_all(trash)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{prepare, tests::package}; + + #[test] + fn updates_rollback_and_revocation_require_fresh_activation() { + use crate::package::tests::package_manifest; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("plugins"); + let path = temp.path().join("test.nyap"); + let mut registry = Registry::open(root.clone(), "1.2.12".into()).unwrap(); + let mut manifest = serde_json::json!({"manifestVersion":1,"id":"example.tools","name":"Tools","version":"1.0.0","description":"test","publisher":"Example","engine":">=1.0.0","permissions":["session.read","storage"]}); + let install = |registry: &mut Registry, manifest: &serde_json::Value| { + package_manifest(&path, &serde_json::to_vec(manifest).unwrap(), &[]); + let prepared = prepare(&path, &root, "1.2.12").unwrap(); + let digest = prepared.preview.digest.clone(); + registry.install(prepared, &digest) + }; + install(&mut registry, &manifest).unwrap(); + registry + .configure( + "example.tools", + true, + vec!["session.read".into(), "storage".into()], + ) + .unwrap(); + assert!( + registry + .get("example.tools") + .unwrap() + .require("storage") + .is_ok() + ); + registry + .configure("example.tools", true, vec!["session.read".into()]) + .unwrap(); + assert!( + registry + .get("example.tools") + .unwrap() + .require("storage") + .is_err() + ); + manifest["version"] = serde_json::json!("1.1.0"); + manifest["permissions"] = serde_json::json!(["storage"]); + let update = install(&mut registry, &manifest).unwrap(); + assert!(!update.enabled); + assert!(update.granted_permissions.is_empty()); + assert_eq!(update.versions.len(), 2); + registry + .configure("example.tools", true, vec!["storage".into()]) + .unwrap(); + registry.activate_version("example.tools", "1.0.0").unwrap(); + let rollback = registry.get("example.tools").unwrap(); + assert!(!rollback.enabled); + assert!(rollback.require("storage").is_err()); + manifest["version"] = serde_json::json!("1.2.0"); + manifest["publisher"] = serde_json::json!("Imposter"); + assert!(install(&mut registry, &manifest).is_err()); + assert_eq!( + registry.get("example.tools").unwrap().active_version, + "1.0.0" + ); + let restored = Registry::open(root, "1.2.12".into()).unwrap(); + assert!(!restored.get("example.tools").unwrap().enabled); + } + + #[test] + fn installation_is_disabled_until_granted_and_survives_restart() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("test.nyap"); + package(&path, &[]); + let root = temp.path().join("plugins"); + let mut registry = Registry::open(root.clone(), "1.2.12".into()).unwrap(); + let prepared = prepare(&path, &root, "1.2.12").unwrap(); + let digest = prepared.preview.digest.clone(); + assert!(!registry.install(prepared, &digest).unwrap().enabled); + assert!( + registry + .configure("example.tools", true, vec!["native".into()]) + .is_err() + ); + registry.configure("example.tools", true, vec![]).unwrap(); + let mut restored = Registry::open(root, "1.2.12".into()).unwrap(); + assert!(restored.get("example.tools").unwrap().enabled); + assert!(restored.asset("example.tools", "../registry.json").is_err()); + restored.uninstall("example.tools").unwrap(); + assert!(restored.list().is_empty()); + } + + #[test] + fn inspection_digest_and_immutable_version_prevent_package_swaps() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("test.nyap"); + package(&path, &[]); + let root = temp.path().join("plugins"); + let mut registry = Registry::open(root.clone(), "1.2.12".into()).unwrap(); + let prepared = prepare(&path, &root, "1.2.12").unwrap(); + assert!(registry.install(prepared, "wrong digest").is_err()); + let prepared = prepare(&path, &root, "1.2.12").unwrap(); + let digest = prepared.preview.digest.clone(); + registry.install(prepared, &digest).unwrap(); + package(&path, &[("extra.txt", b"new content")]); + let prepared = prepare(&path, &root, "1.2.12").unwrap(); + let digest = prepared.preview.digest.clone(); + assert!(registry.install(prepared, &digest).is_err()); + } +} diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/sidecar.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/sidecar.rs new file mode 100644 index 00000000..95509ec5 --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/sidecar.rs @@ -0,0 +1,599 @@ +use crate::manifest::{API_VERSION, Manifest, Transport}; +use crate::package::checked_file; +use crate::{Error, Result, invalid}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, Semaphore, broadcast, mpsc, oneshot, watch}; +use tokio_util::sync::CancellationToken; + +pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +const MAX_JSON_BYTES: usize = 2 * 1024 * 1024; +const MAX_PENDING: usize = 64; + +#[derive(Debug, Clone)] +pub enum BackendEvent { + Notification { method: String, params: Value }, + Binary { channel: String, data: Vec }, + Stderr(String), + ProtocolError(String), + Stopped(String), +} + +#[async_trait::async_trait] +pub trait HostHandler: Send + Sync + 'static { + async fn call(&self, method: &str, params: Value) -> Result; +} + +struct Shared { + host_calls: Mutex>, + pending: Mutex>>>, + next_id: AtomicU64, + running: AtomicBool, + stop: watch::Sender, + writer: mpsc::Sender, + events: broadcast::Sender, + observer: Option>, +} + +impl Shared { + fn publish(&self, event: BackendEvent) { + if let Some(observer) = &self.observer { + observer(event.clone()); + } + let _ = self.events.send(event); + } +} + +pub struct Sidecar { + shared: Arc, + transport: Transport, +} + +enum WireFrame { + Json(Value), + Binary(String, Vec), +} + +impl Drop for Sidecar { + fn drop(&mut self) { + let _ = self.shared.stop.send(true); + } +} + +impl Sidecar { + pub async fn spawn( + root: &Path, + manifest: &Manifest, + app_version: &str, + granted_permissions: &[String], + handler: Arc, + ) -> Result { + Self::spawn_observed( + root, + manifest, + app_version, + granted_permissions, + handler, + None, + ) + .await + } + + pub async fn spawn_observed( + root: &Path, + manifest: &Manifest, + app_version: &str, + granted_permissions: &[String], + handler: Arc, + observer: Option>, + ) -> Result { + let backend = manifest + .backend + .as_ref() + .ok_or_else(|| invalid("Plugin has no native backend"))?; + if !granted_permissions.iter().any(|p| p == "native") { + return Err(invalid("Native execution has not been approved")); + } + let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let relative = backend + .executables + .get(&platform) + .ok_or_else(|| invalid(format!("Plugin backend is unavailable for {platform}")))?; + let executable = checked_file(root, relative)?; + let mut command = tokio::process::Command::new(executable); + command + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Avoid handing ambient AI/MCP/cloud credentials to native backends. + command.env_clear(); + for key in [ + "PATH", + "SystemRoot", + "WINDIR", + "TEMP", + "TMP", + "LANG", + "LC_ALL", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command + .env("NYATERM_PLUGIN_ID", &manifest.id) + .env("NYATERM_PLUGIN_VERSION", &manifest.version) + .env("NYATERM_APP_VERSION", app_version) + .env( + "NYATERM_PLUGIN_TRANSPORT", + match backend.transport { + Transport::StdioJsonl => "stdio-jsonl", + Transport::StdioFramed => "stdio-framed", + }, + ); + #[cfg(windows)] + command.creation_flags(0x0800_0000); + let mut child = command.spawn()?; + let stdin = child + .stdin + .take() + .ok_or_else(|| invalid("Missing plugin stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| invalid("Missing plugin stdout"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| invalid("Missing plugin stderr"))?; + let (writer, receiver) = mpsc::channel(8); + let (events, _) = broadcast::channel(64); + let (stop, mut stop_receiver) = watch::channel(false); + let shared = Arc::new(Shared { + host_calls: Mutex::new(HashMap::new()), + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + running: AtomicBool::new(true), + stop, + writer, + events, + observer, + }); + let transport = backend.transport; + let writer_shared = shared.clone(); + tokio::spawn(async move { + let mut stop = writer_shared.stop.subscribe(); + tokio::select! { + _ = wait_stop(&mut stop) => {}, + result = writer_loop(stdin, receiver, transport) => { + if let Err(error) = result { writer_shared.publish(BackendEvent::ProtocolError(error.to_string())); } + } + } + let _ = writer_shared.stop.send(true); + }); + let reader_shared = shared.clone(); + tokio::spawn(async move { + let mut stop = reader_shared.stop.subscribe(); + tokio::select! { + _ = wait_stop(&mut stop) => {}, + result = reader_loop(stdout, transport, reader_shared.clone(), handler) => { + if let Err(error) = result { reader_shared.publish(BackendEvent::ProtocolError(error.to_string())); } + } + } + let _ = reader_shared.stop.send(true); + }); + let stderr_shared = shared.clone(); + tokio::spawn(async move { + let mut stderr = stderr; + let mut buffer = [0_u8; 4096]; + let mut stop = stderr_shared.stop.subscribe(); + let mut reported = 0_usize; + let mut stopping = false; + let mut deadline = tokio::time::Instant::now(); + loop { + // Preserve final stderr after a protocol failure/process exit, with a + // deadline in case a descendant keeps the pipe open. + let result = tokio::select! { + _ = wait_stop(&mut stop), if !stopping => { + stopping = true; + deadline = tokio::time::Instant::now() + Duration::from_secs(1); + continue; + } + _ = tokio::time::sleep_until(deadline), if stopping => break, + result = stderr.read(&mut buffer) => result, + }; + match result { + Ok(0) | Err(_) => break, + Ok(read) => { + // Drain all stderr, but bound log retention and event volume per activation. + if reported < 64 * 1024 { + stderr_shared.publish(BackendEvent::Stderr( + String::from_utf8_lossy(&buffer[..read]).into_owned(), + )); + reported += read; + } + } + } + } + }); + let process_shared = shared.clone(); + tokio::spawn(async move { + let status = tokio::select! { + result = child.wait() => result.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string()), + _ = wait_stop(&mut stop_receiver) => { + let _ = child.start_kill(); + let _ = child.wait().await; + "Plugin stopped".to_string() + } + }; + process_shared.running.store(false, Ordering::Release); + let _ = process_shared.stop.send(true); + for (_, pending) in process_shared.pending.lock().await.drain() { + let _ = pending.send(Err(Error::Runtime(format!( + "Plugin process closed: {status}" + )))); + } + process_shared.publish(BackendEvent::Stopped(status)); + }); + let sidecar = Self { shared, transport }; + let response = sidecar + .request( + "plugin/initialize", + json!({ + "pluginId": manifest.id, "pluginVersion": manifest.version, + "appVersion": app_version, "apiVersion": API_VERSION, + "protocolVersion": 1, "permissions": granted_permissions, + }), + Duration::from_secs(10), + ) + .await?; + if response.get("pluginId").and_then(Value::as_str) != Some(manifest.id.as_str()) + || response.get("pluginVersion").and_then(Value::as_str) + != Some(manifest.version.as_str()) + || response.get("protocolVersion").and_then(Value::as_u64) != Some(1) + { + return Err(invalid( + "Plugin initialization handshake does not match its manifest", + )); + } + Ok(sidecar) + } + + pub fn is_running(&self) -> bool { + self.shared.running.load(Ordering::Acquire) && !*self.shared.stop.borrow() + } + pub fn subscribe(&self) -> broadcast::Receiver { + self.shared.events.subscribe() + } + pub fn stop(&self) { + let _ = self.shared.stop.send(true); + } + + pub async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result { + if !self.is_running() { + return Err(Error::Runtime("Plugin process is not running".into())); + } + let id = self.shared.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = oneshot::channel(); + { + let mut pending = self.shared.pending.lock().await; + if pending.len() >= MAX_PENDING { + return Err(Error::Runtime("Too many pending plugin requests".into())); + } + pending.insert(id, sender); + } + let shared = self.shared.clone(); + // The guard also removes abandoned requests when the caller is cancelled. + struct PendingGuard { + shared: Arc, + id: u64, + } + impl Drop for PendingGuard { + fn drop(&mut self) { + let shared = self.shared.clone(); + let id = self.id; + tokio::spawn(async move { + if shared.pending.lock().await.remove(&id).is_some() { + let _ = shared.writer.try_send(WireFrame::Json( + json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":id}}), + )); + } + }); + } + } + let _guard = PendingGuard { + shared: shared.clone(), + id, + }; + let operation = async { + shared + .writer + .send(WireFrame::Json( + json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}), + )) + .await + .map_err(|_| Error::Runtime("Plugin writer closed".into()))?; + receiver + .await + .map_err(|_| Error::Runtime("Plugin response channel closed".into()))? + }; + match tokio::time::timeout(timeout, operation).await { + Ok(result) => result, + Err(_) => { + shared.pending.lock().await.remove(&id); + let _ = shared.writer.try_send(WireFrame::Json( + json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":id}}), + )); + Err(Error::Runtime("Plugin request timed out".into())) + } + } + } + + pub async fn send_binary(&self, channel: String, data: Vec) -> Result<()> { + if self.transport != Transport::StdioFramed { + return Err(invalid("Binary frames require stdio-framed")); + } + if channel.is_empty() + || channel.len() > 128 + || data.len() + channel.len() + 2 > MAX_FRAME_BYTES + { + return Err(invalid("Invalid binary plugin frame")); + } + if !self.is_running() { + return Err(Error::Runtime("Plugin process is not running".into())); + } + tokio::time::timeout( + Duration::from_secs(10), + self.shared.writer.send(WireFrame::Binary(channel, data)), + ) + .await + .map_err(|_| Error::Runtime("Plugin binary writer timed out".into()))? + .map_err(|_| Error::Runtime("Plugin writer closed".into())) + } +} + +async fn writer_loop( + mut writer: impl AsyncWrite + Unpin, + mut receiver: mpsc::Receiver, + transport: Transport, +) -> Result<()> { + while let Some(frame) = receiver.recv().await { + let (kind, payload) = match frame { + WireFrame::Json(value) => { + let bytes = serde_json::to_vec(&value)?; + if bytes.len() > MAX_JSON_BYTES { + return Err(invalid("Plugin JSON frame is too large")); + } + (1_u8, bytes) + } + WireFrame::Binary(channel, data) => { + if transport != Transport::StdioFramed { + return Err(invalid("Binary frames require stdio-framed")); + } + let mut payload = Vec::with_capacity(2 + channel.len() + data.len()); + payload.extend_from_slice(&(channel.len() as u16).to_le_bytes()); + payload.extend_from_slice(channel.as_bytes()); + payload.extend_from_slice(&data); + (2, payload) + } + }; + if transport == Transport::StdioFramed { + writer.write_u8(kind).await?; + writer.write_u32_le(payload.len() as u32).await?; + } + writer.write_all(&payload).await?; + if transport == Transport::StdioJsonl { + writer.write_u8(b'\n').await?; + } + writer.flush().await?; + } + Ok(()) +} + +async fn read_frame( + reader: &mut (impl AsyncRead + Unpin), + transport: Transport, +) -> Result { + if transport == Transport::StdioFramed { + let kind = reader.read_u8().await?; + let length = reader.read_u32_le().await? as usize; + if length > MAX_FRAME_BYTES || (kind == 1 && length > MAX_JSON_BYTES) { + return Err(invalid("Plugin frame is too large")); + } + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).await?; + return match kind { + 1 => Ok(WireFrame::Json(serde_json::from_slice(&bytes)?)), + 2 if bytes.len() >= 2 => { + let channel_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize; + if channel_len == 0 || channel_len > 128 || channel_len + 2 > bytes.len() { + return Err(invalid("Invalid binary channel")); + } + let channel = std::str::from_utf8(&bytes[2..2 + channel_len]) + .map_err(|_| invalid("Invalid binary channel encoding"))? + .to_string(); + Ok(WireFrame::Binary( + channel, + bytes[2 + channel_len..].to_vec(), + )) + } + _ => Err(invalid("Invalid plugin frame kind")), + }; + } + // A capped byte loop over BufReader avoids allocating unbounded JSONL lines. + let mut bytes = Vec::new(); + loop { + let byte = reader.read_u8().await?; + if byte == b'\n' { + break; + } + if bytes.len() >= MAX_JSON_BYTES { + return Err(invalid("Plugin JSON line is too large")); + } + bytes.push(byte); + } + Ok(WireFrame::Json(serde_json::from_slice(&bytes)?)) +} + +async fn reader_loop( + reader: impl AsyncRead + Unpin, + transport: Transport, + shared: Arc, + handler: Arc, +) -> Result<()> { + let mut reader = BufReader::new(reader); + let host_slots = Arc::new(Semaphore::new(16)); + loop { + match read_frame(&mut reader, transport).await? { + WireFrame::Binary(channel, data) => { + shared.publish(BackendEvent::Binary { channel, data }); + } + WireFrame::Json(value) => { + if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(invalid("Invalid plugin JSON-RPC version")); + } + if let Some(method) = value.get("method").and_then(Value::as_str) { + if method == "$/cancelRequest" { + if let Some(id) = value.pointer("/params/id").and_then(Value::as_str) + && let Some(token) = shared.host_calls.lock().await.get(id) + { + token.cancel(); + } + continue; + } + if let Some(id) = value.get("id") { + let id = id + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 128) + .ok_or_else(|| invalid("Plugin-owned request IDs must be strings"))? + .to_string(); + let permit = host_slots + .clone() + .try_acquire_owned() + .map_err(|_| invalid("Too many concurrent host requests"))?; + let method = method.to_string(); + let params = value.get("params").cloned().unwrap_or(Value::Null); + let handler = handler.clone(); + let shared = shared.clone(); + let cancellation = CancellationToken::new(); + { + let mut calls = shared.host_calls.lock().await; + if calls.len() >= 16 || calls.contains_key(&id) { + return Err(invalid("Too many host calls or duplicate ID")); + } + calls.insert(id.clone(), cancellation.clone()); + } + tokio::spawn(async move { + let _permit = permit; + let mut stop = shared.stop.subscribe(); + let result = tokio::select! { + _ = wait_stop(&mut stop) => None, + _ = cancellation.cancelled() => None, + result = tokio::time::timeout(Duration::from_secs(180), handler.call(&method, params)) => Some(result.unwrap_or_else(|_| Err(Error::Runtime("Host request timed out".into())))), + }; + shared.host_calls.lock().await.remove(&id); + let Some(result) = result else { + return; + }; + let response = match result { + Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}), + Err(error) => { + json!({"jsonrpc":"2.0","id":id,"error":{"code":-32000,"message":error.to_string()}}) + } + }; + let _ = shared.writer.send(WireFrame::Json(response)).await; + }); + } else if method.starts_with("event/") && method.len() <= 128 { + shared.publish(BackendEvent::Notification { + method: method.to_string(), + params: value.get("params").cloned().unwrap_or(Value::Null), + }); + } + } else { + let id = value + .get("id") + .and_then(Value::as_u64) + .ok_or_else(|| invalid("Host-owned response IDs must be numbers"))?; + if let Some(sender) = shared.pending.lock().await.remove(&id) { + let result = if let Some(error) = value.get("error") { + Err(Error::Runtime( + error + .get("message") + .and_then(Value::as_str) + .unwrap_or("Plugin RPC failed") + .to_string(), + )) + } else { + value + .get("result") + .cloned() + .ok_or_else(|| invalid("Plugin response has no result")) + }; + let _ = sender.send(result); + } + } + } + } + } +} + +async fn wait_stop(receiver: &mut watch::Receiver) { + loop { + if *receiver.borrow_and_update() { + return; + } + if receiver.changed().await.is_err() { + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn framed_transport_roundtrips_json_and_binary_without_base64() { + let (writer, mut reader) = tokio::io::duplex(1024); + let (send, recv) = mpsc::channel(2); + let task = tokio::spawn(writer_loop(writer, recv, Transport::StdioFramed)); + send.send(WireFrame::Json( + json!({"jsonrpc":"2.0","id":1,"result":true}), + )) + .await + .unwrap(); + send.send(WireFrame::Binary("stdout/s1".into(), vec![0, 255, 10])) + .await + .unwrap(); + drop(send); + assert!( + matches!(read_frame(&mut reader, Transport::StdioFramed).await.unwrap(), WireFrame::Json(v) if v["result"] == true) + ); + assert!( + matches!(read_frame(&mut reader, Transport::StdioFramed).await.unwrap(), WireFrame::Binary(c, d) if c == "stdout/s1" && d == [0, 255, 10]) + ); + task.await.unwrap().unwrap(); + } + #[tokio::test] + async fn rejects_oversized_frame_before_allocating_payload() { + let bytes = [1, 255, 255, 255, 127]; + assert!( + read_frame(&mut bytes.as_slice(), Transport::StdioFramed) + .await + .is_err() + ); + let bytes = [2, 3, 0, 0, 0, 100, 0, 0]; + assert!( + read_frame(&mut bytes.as_slice(), Transport::StdioFramed) + .await + .is_err() + ); + } +} diff --git a/src-tauri/crates/nyaterm-plugin-runtime/tests/sdk_interop.rs b/src-tauri/crates/nyaterm-plugin-runtime/tests/sdk_interop.rs new file mode 100644 index 00000000..3e725f57 --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/tests/sdk_interop.rs @@ -0,0 +1,215 @@ +#![cfg(feature = "sdk-fixture")] +use nyaterm_plugin_runtime::{ + Result, + diagnostics::{Diagnostics, Status}, + manifest::Manifest, + sidecar::{BackendEvent, HostHandler, Sidecar}, +}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +#[derive(Default)] +struct Host { + completed: std::sync::atomic::AtomicUsize, +} +#[async_trait::async_trait] +impl HostHandler for Host { + async fn call(&self, method: &str, params: Value) -> Result { + assert_eq!(params["scopeToken"], "fixture-scope"); + if method == "host/slow" { + tokio::time::sleep(Duration::from_millis(100)).await; + self.completed + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Ok(json!({"name":"selected-session"})) + } +} +fn fixture(transport: &str, id: &str) -> (tempfile::TempDir, Manifest) { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("bin")).unwrap(); + let executable = if cfg!(windows) { + "bin/sdk.exe" + } else { + "bin/sdk" + }; + std::fs::copy( + env!("CARGO_BIN_EXE_sdk-fixture"), + root.path().join(executable), + ) + .unwrap(); + let manifest=Manifest::parse(&serde_json::to_vec(&json!({"manifestVersion":1,"id":id,"name":"SDK fixture","version":"1.0.0","description":"SDK test","publisher":"Example","engine":">=1.0.0","permissions":["native"],"backend":{"transport":transport,"executables":{format!("{}-{}",std::env::consts::OS,std::env::consts::ARCH):executable}}})).unwrap(),"1.2.12").unwrap(); + (root, manifest) +} +async fn call(sidecar: &Sidecar, method: &str, input: Value) -> Result { + sidecar + .request( + method, + json!({"scopeToken":"fixture-scope","input":input}), + Duration::from_secs(5), + ) + .await +} +async fn exercise(transport: &str) { + let (root, manifest) = fixture(transport, "example.sdk"); + let events = Arc::new(Mutex::new(Vec::new())); + let observed = events.clone(); + let host = Arc::new(Host::default()); + let sidecar = Sidecar::spawn_observed( + root.path(), + &manifest, + "1.2.12", + &["native".into()], + host.clone(), + Some(Arc::new(move |event| { + observed.lock().unwrap().push(event); + })), + ) + .await + .unwrap(); + assert_eq!( + call(&sidecar, "ui/count", Value::Null).await.unwrap(), + json!(1) + ); + assert_eq!( + call(&sidecar, "ui/count", Value::Null).await.unwrap(), + json!(2) + ); + assert_eq!( + call(&sidecar, "ui/host", Value::Null).await.unwrap()["name"], + "selected-session" + ); + let (host_output, echo) = tokio::join!( + call(&sidecar, "ui/host", Value::Null), + call(&sidecar, "ui/echo", json!("concurrent")) + ); + assert_eq!(host_output.unwrap()["name"], "selected-session"); + assert_eq!(echo.unwrap(), json!("concurrent")); + call(&sidecar, "ui/save-context", Value::Null) + .await + .unwrap(); + assert!( + call(&sidecar, "ui/reuse-context", Value::Null) + .await + .unwrap_err() + .to_string() + .contains("cancelled") + ); + assert!( + call(&sidecar, "ui/unknown", Value::Null) + .await + .unwrap_err() + .to_string() + .contains("Unknown method") + ); + assert!( + call(&sidecar, "ui/host-timeout", Value::Null) + .await + .unwrap_err() + .to_string() + .contains("timed out") + ); + assert!( + call(&sidecar, "ui/panic", Value::Null) + .await + .unwrap_err() + .to_string() + .contains("panicked") + ); + call(&sidecar, "ui/log", Value::Null).await.unwrap(); + assert!( + sidecar + .request( + "ui/slow", + json!({"scopeToken":"fixture-scope"}), + Duration::from_millis(20) + ) + .await + .is_err() + ); + tokio::time::sleep(Duration::from_millis(1100)).await; + assert_eq!( + call(&sidecar, "ui/completed", Value::Null).await.unwrap(), + json!(0) + ); + assert_eq!(host.completed.load(std::sync::atomic::Ordering::Relaxed), 0); + // Cancelled reverse calls and handlers cannot finish work or corrupt new calls. + assert_eq!( + call(&sidecar, "ui/echo", json!("alive")).await.unwrap(), + json!("alive") + ); + if transport == "stdio-framed" { + sidecar + .send_binary("fixture".into(), vec![3, 2, 1]) + .await + .unwrap(); + for _ in 0..20 { + if call(&sidecar, "ui/binary", Value::Null).await.unwrap() == json!(1) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(events.lock().unwrap().iter().any(|e|matches!(e,BackendEvent::Binary{channel,data} if channel=="fixture" && data==&[1,2,3]))); + } + assert!( + events + .lock() + .unwrap() + .iter() + .any(|e| matches!(e,BackendEvent::Notification{method,..} if method=="event/log")) + ); + assert!( + events.lock().unwrap().iter().any( + |e| matches!(e,BackendEvent::Stderr(message) if message.contains("initialization")) + ) + ); + assert!(call(&sidecar, "ui/crash", Value::Null).await.is_err()); + assert!(!sidecar.is_running()); +} +#[tokio::test] +async fn sdk_jsonl_interoperates() { + exercise("stdio-jsonl").await; +} +#[tokio::test] +async fn sdk_framed_interoperates() { + exercise("stdio-framed").await; +} + +#[tokio::test] +async fn observes_startup_errors_before_handshake_completes() { + let (root, manifest) = fixture("stdio-jsonl", "bad.sdk"); + let store = Diagnostics::default(); + let ticket = store.begin("bad.sdk", "1.0.0"); + let observer = store.clone(); + let event_ticket = ticket.clone(); + let result = Sidecar::spawn_observed( + root.path(), + &manifest, + "1.2.12", + &["native".into()], + Arc::new(Host::default()), + Some(Arc::new(move |event| { + if let BackendEvent::Stderr(message) | BackendEvent::ProtocolError(message) = event { + observer.update(&event_ticket, None, "error", "startup", &message); + } + })), + ) + .await; + assert!(result.is_err()); + store.update( + &ticket, + Some(Status::Error), + "error", + "host", + "Handshake failed", + ); + tokio::time::sleep(Duration::from_millis(100)).await; + let snapshot = store.snapshot("bad.sdk", "1.0.0"); + assert_eq!(snapshot.status, Status::Error); + assert!( + snapshot + .logs + .iter() + .any(|entry| entry.message.contains("SDK initialization started")) + ); +} diff --git a/src-tauri/crates/nyaterm-plugin-runtime/tests/sidecar_process.rs b/src-tauri/crates/nyaterm-plugin-runtime/tests/sidecar_process.rs new file mode 100644 index 00000000..2f379515 --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/tests/sidecar_process.rs @@ -0,0 +1,117 @@ +use nyaterm_plugin_runtime::{ + Result, + manifest::Manifest, + sidecar::{HostHandler, Sidecar}, +}; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; + +struct Host; +#[async_trait::async_trait] +impl HostHandler for Host { + async fn call(&self, method: &str, params: Value) -> Result { + assert_eq!(method, "host/session"); + assert_eq!(params["scopeToken"], "fixture-scope"); + Ok(json!({"name":"selected-session"})) + } +} + +fn fixture(id: &str) -> (tempfile::TempDir, Manifest) { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("bin")).unwrap(); + let executable = if cfg!(windows) { + "bin/fixture.exe" + } else { + "bin/fixture" + }; + std::fs::copy( + env!("CARGO_BIN_EXE_plugin-fixture"), + root.path().join(executable), + ) + .unwrap(); + let manifest = Manifest::parse(&serde_json::to_vec(&json!({ + "manifestVersion":1,"id":id,"name":"Fixture","version":"1.0.0","description":"Native fixture","publisher":"Example","engine":">=1.0.0", + "permissions":["native"],"backend":{"transport":"stdio-jsonl","executables":{format!("{}-{}",std::env::consts::OS,std::env::consts::ARCH):executable}}, + })).unwrap(), "1.2.12").unwrap(); + (root, manifest) +} + +#[tokio::test] +async fn reuses_process_supports_reverse_rpc_and_recovers_after_request_timeout() { + let (root, manifest) = fixture("example.fixture"); + assert!( + Sidecar::spawn(root.path(), &manifest, "1.2.12", &[], Arc::new(Host)) + .await + .is_err() + ); + let sidecar = Sidecar::spawn( + root.path(), + &manifest, + "1.2.12", + &["native".into()], + Arc::new(Host), + ) + .await + .unwrap(); + let output = sidecar + .request("ui/echo", json!({"hello":true}), Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(output, json!({"hello":true})); + assert_eq!( + sidecar + .request("ui/host", Value::Null, Duration::from_secs(5)) + .await + .unwrap()["name"], + "selected-session" + ); + assert!( + sidecar + .request("ui/timeout", Value::Null, Duration::from_millis(30)) + .await + .is_err() + ); + assert_eq!( + sidecar + .request("ui/echo", json!("still alive"), Duration::from_secs(5)) + .await + .unwrap(), + json!("still alive") + ); + sidecar.stop(); + assert!(!sidecar.is_running()); +} + +#[tokio::test] +async fn rejects_identity_mismatch_and_resolves_calls_on_process_crash() { + let (root, manifest) = fixture("bad.handshake"); + assert!( + Sidecar::spawn( + root.path(), + &manifest, + "1.2.12", + &["native".into()], + Arc::new(Host) + ) + .await + .is_err() + ); + let (root, manifest) = fixture("example.fixture"); + let sidecar = Sidecar::spawn( + root.path(), + &manifest, + "1.2.12", + &["native".into()], + Arc::new(Host), + ) + .await + .unwrap(); + assert!( + sidecar + .request("ui/crash", Value::Null, Duration::from_secs(5)) + .await + .is_err() + ); + assert!(!sidecar.is_running()); +} diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 221c6eaa..f23deb53 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -425,6 +425,18 @@ pub fn quit_application(app: &tauri::AppHandle) { } pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) { + if matches!(event, tauri::WindowEvent::Destroyed) { + if let Some(manager) = window + .app_handle() + .try_state::>() + { + let manager = manager.inner().clone(); + let label = window.label().to_string(); + tauri::async_runtime::spawn(async move { + manager.revoke_window(&label).await; + }); + } + } if crate::window_state::is_main_window_label(window.label()) { match event { tauri::WindowEvent::Moved(_) diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 6f090b82..c440cfe9 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -407,6 +407,12 @@ pub fn set_app_lock_state( AppLockStateChangedPayload { locked }, ); if locked { + if let Some(manager) = app.try_state::>() { + let manager = manager.inner().clone(); + tauri::async_runtime::spawn(async move { + manager.revoke_all().await; + }); + } if let Some(manager) = app.try_state::>() { let manager = manager.inner().clone(); tauri::async_runtime::spawn(async move { diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 37751023..a20ef319 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -17,6 +17,7 @@ pub mod macos_menu; pub mod mcp; pub mod note; pub mod otp; +pub mod plugins; pub mod process; pub mod proxy; pub mod rdp; diff --git a/src-tauri/src/cmd/plugins.rs b/src-tauri/src/cmd/plugins.rs new file mode 100644 index 00000000..02f36abd --- /dev/null +++ b/src-tauri/src/cmd/plugins.rs @@ -0,0 +1,162 @@ +use crate::core::plugins::{PluginManager, PluginScope}; +use crate::error::AppResult; +use nyaterm_plugin_runtime::package::PackagePreview; +use nyaterm_plugin_runtime::registry::InstalledPlugin; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::Arc; + +#[tauri::command] +pub async fn list_plugins( + manager: tauri::State<'_, Arc>, +) -> AppResult> { + manager.list().await +} + +#[tauri::command] +pub async fn inspect_plugin_package( + manager: tauri::State<'_, Arc>, + path: String, +) -> AppResult { + manager.inspect(PathBuf::from(path)).await +} + +#[tauri::command] +pub async fn install_plugin_package( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + path: String, + digest: String, +) -> AppResult { + manager.install(&app, PathBuf::from(path), digest).await +} + +#[tauri::command] +pub async fn configure_plugin( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + plugin_id: String, + expected_version: String, + enabled: bool, + granted_permissions: Vec, +) -> AppResult<()> { + manager + .configure( + &app, + &plugin_id, + &expected_version, + enabled, + granted_permissions, + ) + .await +} + +#[tauri::command] +pub async fn activate_plugin_version( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + plugin_id: String, + version: String, +) -> AppResult<()> { + manager.activate_version(&app, &plugin_id, &version).await +} + +#[tauri::command] +pub async fn uninstall_plugin( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + plugin_id: String, +) -> AppResult<()> { + manager.uninstall(&app, &plugin_id).await +} + +#[tauri::command] +pub async fn create_plugin_scope( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + plugin_id: String, + session_id: Option, +) -> AppResult { + manager + .create_scope(&app, window.label(), &plugin_id, session_id) + .await +} + +#[tauri::command] +pub async fn close_plugin_scope( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + token: String, +) -> AppResult<()> { + manager.close_scope(window.label(), &token).await +} + +#[tauri::command] +pub async fn plugin_host_call( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + token: String, + method: String, + params: Value, +) -> AppResult { + manager + .inner() + .host_call(&app, Some(window.label()), &token, &method, params) + .await +} + +#[tauri::command] +pub async fn plugin_backend_call( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + token: String, + method: String, + params: Value, +) -> AppResult { + manager + .inner() + .backend_call(&app, window.label(), &token, &method, params) + .await +} + +#[tauri::command] +pub async fn respond_plugin_approval( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + request_id: String, + approved: bool, +) -> AppResult<()> { + manager + .respond_approval(window.label(), &request_id, approved) + .await +} + +#[tauri::command] +pub async fn get_plugin_diagnostics( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + plugin_id: String, +) -> AppResult { + manager.diagnostic_snapshot(&app, &plugin_id).await +} + +#[tauri::command] +pub async fn clear_plugin_logs( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + plugin_id: String, +) -> AppResult<()> { + manager.clear_logs(&app, &plugin_id).await +} + +#[tauri::command] +pub async fn stop_plugin_backend( + app: tauri::AppHandle, + manager: tauri::State<'_, Arc>, + plugin_id: String, +) -> AppResult<()> { + manager.stop_backend(&app, &plugin_id).await +} diff --git a/src-tauri/src/config/ui.rs b/src-tauri/src/config/ui.rs index 0fc93e9e..16b0f491 100644 --- a/src-tauri/src/config/ui.rs +++ b/src-tauri/src/config/ui.rs @@ -214,7 +214,11 @@ fn default_left_top() -> Vec { } fn default_left_bottom() -> Vec { - vec!["syncBackupHistory".to_string(), "settings".to_string()] + vec![ + "syncBackupHistory".to_string(), + "plugins".to_string(), + "settings".to_string(), + ] } fn default_right_top() -> Vec { diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index e0d054c9..81af2d4c 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -16,6 +16,7 @@ pub mod monitoring; pub mod network; pub mod note_export; mod output; +pub mod plugins; pub mod portable_snapshot; mod quick_commands; pub mod rdp; diff --git a/src-tauri/src/core/plugins/gateway.rs b/src-tauri/src/core/plugins/gateway.rs new file mode 100644 index 00000000..f757b2e4 --- /dev/null +++ b/src-tauri/src/core/plugins/gateway.rs @@ -0,0 +1,372 @@ +use super::{Approval, PluginManager, Scope, plugin_error, require_session}; +use crate::core::SessionManager; +use crate::core::capabilities::{ + TerminalExecuteRequest, assess_command_risk, execute_terminal_command, +}; +use crate::error::{AppError, AppResult}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tauri::{Emitter, Manager}; +use tokio::io::AsyncReadExt; +use tokio::sync::oneshot; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ApprovalRequest { + request_id: String, + plugin_id: String, + plugin_name: String, + capability: String, + session_name: String, + summary: String, + risk: crate::config::RiskLevel, +} + +struct ApprovalGuard { + manager: std::sync::Weak, + window: tauri::WebviewWindow, + id: String, +} +impl Drop for ApprovalGuard { + fn drop(&mut self) { + let manager = self.manager.clone(); + let id = self.id.clone(); + let window = self.window.clone(); + tauri::async_runtime::spawn(async move { + if let Some(manager) = manager.upgrade() { + manager.approvals.lock().await.remove(&id); + } + let _ = window.emit("plugin-approval-closed", &id); + }); + } +} + +impl PluginManager { + pub async fn host_call( + self: &Arc, + app: &tauri::AppHandle, + caller_window: Option<&str>, + token: &str, + method: &str, + params: Value, + ) -> AppResult { + if serde_json::to_vec(¶ms)?.len() > 1024 * 1024 { + return Err(AppError::Config("Plugin request is too large".into())); + } + let scope = self.scope(app, token, caller_window).await?; + let _lease = self.lifecycle.acquire(&scope.plugin_id, false)?; + self.scope(app, token, caller_window).await?; + let plugin = self.plugin(&scope.plugin_id).await?; + let permission = match method { + "host/session" => "session.read", + "host/terminal/read" => "terminal.read", + "host/terminal/execute" => "terminal.execute", + "host/filesystem/read" => "filesystem.read", + "host/storage/get" | "host/storage/set" => "storage", + "host/network/request" => "", + _ => return Err(AppError::Config("Unknown plugin host capability".into())), + }; + if !permission.is_empty() { + plugin.require(permission).map_err(plugin_error)?; + } + let operation = async { + let manager = app.state::>().inner().clone(); + match method { + "host/session" => match &scope.session_id { + Some(id) => { + let info = require_session(app, &scope.window_label, id).await?; + Ok( + json!({"id":info.id,"name":info.name,"type":info.session_type,"connected":info.connected}), + ) + } + None => Ok(Value::Null), + }, + "host/terminal/read" => { + let id = session_id(&scope)?; + let lines = params + .get("lines") + .and_then(Value::as_u64) + .unwrap_or(100) + .clamp(1, 500) as usize; + Ok(json!({"output":manager.recent_output(id, lines)})) + } + "host/terminal/execute" => { + let id = session_id(&scope)?; + let command = text(¶ms, "command", 64 * 1024)?; + let risk = assess_command_risk(command); + self.approve( + app, + &scope, + token, + "terminal.execute", + &crate::core::ai::redact_sensitive_text(command), + risk.level.clone(), + ) + .await?; + self.scope(app, token, caller_window).await?; + let result = execute_terminal_command( + manager, + TerminalExecuteRequest { + session_id: id.into(), + command: command.into(), + timeout_ms: params + .get("timeoutMs") + .and_then(Value::as_u64) + .unwrap_or(30_000) + .clamp(1000, 120_000), + }, + None, + scope.cancellation.child_token(), + ) + .await; + let request = serde_json::from_value( + json!({"action":"plugin_terminal_execute","source":"plugin","client":scope.plugin_id, + "capability":"terminal.execute","sessionId":id,"generatedCommand":crate::core::ai::redact_sensitive_text(command), + "riskLevel":risk.level,"executed":result.is_ok(),"success":result.is_ok(),"approvalDecision":"allow_once"}), + )?; + let _ = crate::core::ai::append_ai_audit(app, request); + serde_json::to_value(result?).map_err(Into::into) + } + "host/filesystem/read" => { + let id = session_id(&scope)?; + let path = text(¶ms, "path", 4096)?; + self.approve( + app, + &scope, + token, + "filesystem.read", + path, + crate::config::RiskLevel::Medium, + ) + .await?; + self.scope(app, token, caller_window).await?; + let info = manager.session_info(id).await?; + if info.session_type == crate::core::SessionType::Local { + let cwd = manager.session_cwd(id).await?.ok_or_else(|| { + AppError::Config("Local session has no usable working directory".into()) + })?; + let cwd = std::path::PathBuf::from(cwd).canonicalize()?; + let path = cwd.join(path).canonicalize()?; + if !path.starts_with(&cwd) || !path.is_file() { + return Err(AppError::Config("Local plugin file reads must stay inside the session working directory".into())); + } + let file = tokio::fs::File::open(path).await?; + let mut bytes = Vec::new(); + file.take(256 * 1024 + 1).read_to_end(&mut bytes).await?; + if bytes.len() > 256 * 1024 { + return Err(AppError::Config( + "Plugin file read exceeds 256 KiB".into(), + )); + } + Ok( + json!({"content":String::from_utf8(bytes).map_err(|_| AppError::Config("Plugin file read requires UTF-8 text".into()))?}), + ) + } else { + let file = crate::core::capabilities::sftp::read_text( + manager, + id, + path, + 256 * 1024, + ) + .await?; + serde_json::to_value(file).map_err(Into::into) + } + } + "host/storage/get" | "host/storage/set" => { + let key = text(¶ms, "key", 128)?; + if !nyaterm_plugin_runtime::manifest::valid_id(key) { + return Err(AppError::Config("Invalid plugin storage key".into())); + } + let _lock = self.storage_lock.lock().await; + let path = self + .root + .join("data") + .join(&scope.plugin_id) + .join("settings.json"); + let mut values: BTreeMap = if path.is_file() { + if std::fs::metadata(&path)?.len() > 1024 * 1024 { + return Err(AppError::Config("Plugin storage is too large".into())); + } + serde_json::from_slice(&std::fs::read(&path)?)? + } else { + BTreeMap::new() + }; + if method == "host/storage/get" { + return Ok(values.get(key).cloned().unwrap_or(Value::Null)); + } + let value = params.get("value").cloned().unwrap_or(Value::Null); + if value.is_null() { + values.remove(key); + } else { + values.insert(key.into(), value); + } + if values.len() > 128 || serde_json::to_vec(&values)?.len() > 1024 * 1024 { + return Err(AppError::Config("Plugin storage quota exceeded".into())); + } + nyaterm_plugin_runtime::registry::atomic_json(&path, &values) + .map_err(plugin_error)?; + Ok(Value::Null) + } + "host/network/request" => { + let url = reqwest::Url::parse(text(¶ms, "url", 4096)?) + .map_err(|e| AppError::Config(e.to_string()))?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + { + return Err(AppError::Config( + "Plugin network requests require HTTPS without URL credentials".into(), + )); + } + let permission = format!("network:{}", url.origin().ascii_serialization()); + plugin.require(&permission).map_err(plugin_error)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| AppError::Config(e.to_string()))?; + let method = params + .get("method") + .and_then(Value::as_str) + .unwrap_or("GET"); + let mut request = match method { + "GET" => client.get(url), + "POST" => client.post(url), + _ => { + return Err(AppError::Config( + "Plugin network requests support GET and POST".into(), + )); + } + }; + if let Some(body) = params.get("body").and_then(Value::as_str) { + if body.len() > 256 * 1024 { + return Err(AppError::Config( + "Plugin request body is too large".into(), + )); + } + request = request.body(body.to_string()); + } + if let Some(content_type) = params.get("contentType").and_then(Value::as_str) { + if content_type.len() > 128 || content_type.contains(['\r', '\n']) { + return Err(AppError::Config("Invalid plugin content type".into())); + } + request = request.header(reqwest::header::CONTENT_TYPE, content_type); + } + let mut response = request + .send() + .await + .map_err(|e| AppError::Config(e.to_string()))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .unwrap_or_default() + .to_string(); + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| AppError::Config(e.to_string()))? + { + if body.len() + chunk.len() > 1024 * 1024 { + return Err(AppError::Config( + "Plugin network response exceeds 1 MiB".into(), + )); + } + body.extend_from_slice(&chunk); + } + Ok( + json!({"status":status,"contentType":content_type,"body":String::from_utf8_lossy(&body)}), + ) + } + _ => unreachable!(), + } + }; + let result = tokio::select! { + () = scope.cancellation.cancelled() => Err(AppError::Cancelled("Plugin scope was revoked".into())), + result = tokio::time::timeout(Duration::from_mins(3), operation) => result.map_err(|_| AppError::Cancelled("Plugin host request timed out".into()))?, + }; + self.scope(app, token, caller_window).await?; + result + } + + async fn approve( + self: &Arc, + app: &tauri::AppHandle, + scope: &Scope, + token: &str, + capability: &str, + summary: &str, + risk: crate::config::RiskLevel, + ) -> AppResult<()> { + self.scope(app, token, None).await?; + let plugin = self.plugin(&scope.plugin_id).await?; + let request_id = uuid::Uuid::new_v4().to_string(); + let info = require_session(app, &scope.window_label, session_id(scope)?).await?; + let window = app + .get_webview_window(&scope.window_label) + .ok_or_else(|| AppError::Config("Plugin owner window is unavailable".into()))?; + let (sender, receiver) = oneshot::channel(); + let mut approvals = self.approvals.lock().await; + if approvals.len() >= 32 { + return Err(AppError::Config("Too many pending plugin approvals".into())); + } + approvals.insert( + request_id.clone(), + Approval { + window_label: scope.window_label.clone(), + responder: sender, + }, + ); + drop(approvals); + let _guard = ApprovalGuard { + manager: Arc::downgrade(self), + window: window.clone(), + id: request_id.clone(), + }; + let event = ApprovalRequest { + request_id: request_id.clone(), + plugin_id: scope.plugin_id.clone(), + plugin_name: plugin.active().map_err(plugin_error)?.manifest.name.clone(), + capability: capability.into(), + session_name: info.name, + summary: summary.into(), + risk, + }; + if let Err(error) = window.emit("plugin-approval-request", &event) { + self.approvals.lock().await.remove(&request_id); + return Err(AppError::Config(error.to_string())); + } + let response = tokio::select! { + () = scope.cancellation.cancelled() => false, + result = tokio::time::timeout(Duration::from_mins(2), receiver) => result.is_ok_and(|r| r.unwrap_or(false)), + }; + self.approvals.lock().await.remove(&request_id); + let _ = window.emit("plugin-approval-closed", &request_id); + if response { + Ok(()) + } else { + Err(AppError::Cancelled( + "Plugin operation was not approved".into(), + )) + } + } +} + +fn session_id(scope: &Scope) -> AppResult<&str> { + scope.session_id.as_deref().ok_or_else(|| { + AppError::Config("Select an active terminal session for this plugin operation".into()) + }) +} + +fn text<'a>(value: &'a Value, key: &str, limit: usize) -> AppResult<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty() && text.len() <= limit && !text.contains('\0')) + .ok_or_else(|| AppError::Config(format!("Invalid plugin parameter: {key}"))) +} diff --git a/src-tauri/src/core/plugins/ipc_transport.js b/src-tauri/src/core/plugins/ipc_transport.js new file mode 100644 index 00000000..800b58fa --- /dev/null +++ b/src-tauri/src/core/plugins/ipc_transport.js @@ -0,0 +1,95 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +;(function () { + // Wry on Windows injects initialization scripts into subframes too. + // Never initialize the native IPC transport or its key in plugin frames. + if (window.top !== window) return + + /** + * A runtime generated key to ensure an IPC call comes from an initialized frame. + * + * This is declared outside the `window.__TAURI_INVOKE__` definition to prevent + * the key from being leaked by `window.__TAURI_INVOKE__.toString()`. + */ + const __TAURI_INVOKE_KEY__ = __TEMPLATE_invoke_key__ + + const processIpcMessage = __RAW_process_ipc_message_fn__ + const osName = __TEMPLATE_os_name__ + const fetchChannelDataCommand = __TEMPLATE_fetch_channel_data_command__ + let customProtocolIpcFailed = false + + // on Android we never use it because Android does not have support to reading the request body + const canUseCustomProtocol = osName !== 'android' + + function sendIpcMessage(message) { + const { cmd, callback, error, payload, options } = message + + if ( + !customProtocolIpcFailed + && (canUseCustomProtocol || cmd === fetchChannelDataCommand) + ) { + const { contentType, data } = processIpcMessage(payload) + + const headers = new Headers((options && options.headers) || {}) + headers.set('Content-Type', contentType) + headers.set('Tauri-Callback', callback) + headers.set('Tauri-Error', error) + headers.set('Tauri-Invoke-Key', __TAURI_INVOKE_KEY__) + + fetch(window.__TAURI_INTERNALS__.convertFileSrc(cmd, 'ipc'), { + method: 'POST', + body: data, + headers + }) + .then((response) => { + const callbackId = + response.headers.get('Tauri-Response') === 'ok' ? callback : error + // we need to split here because on Android the content-type gets duplicated + switch ((response.headers.get('content-type') || '').split(',')[0]) { + case 'application/json': + return response.json().then((r) => [callbackId, r]) + case 'text/plain': + return response.text().then((r) => [callbackId, r]) + default: + return response.arrayBuffer().then((r) => [callbackId, r]) + } + }) + .then( + ([callbackId, data]) => { + window.__TAURI_INTERNALS__.runCallback(callbackId, data) + }, + (e) => { + console.warn( + 'IPC custom protocol failed, Tauri will now use the postMessage interface instead', + e + ) + // failed to use the custom protocol IPC (either the webview blocked a custom protocol or it was a CSP error) + // so we need to fallback to the postMessage interface + customProtocolIpcFailed = true + sendIpcMessage(message) + } + ) + } else { + // otherwise use the postMessage interface + const { data } = processIpcMessage({ + cmd, + callback, + error, + options: { + ...options, + customProtocolIpcBlocked: customProtocolIpcFailed + }, + payload, + __TAURI_INVOKE_KEY__ + }) + // `window.ipc.postMessage` came from `tauri-runtime-wry` > `wry` [`with_ipc_handler`](https://github.com/tauri-apps/wry/blob/a0403b9e2f1ff9d73be7dce1184f058afcaa1d82/src/lib.rs#L1130) + window.ipc.postMessage(data) + } + } + + Object.defineProperty(window.__TAURI_INTERNALS__, 'postMessage', { + value: sendIpcMessage + }) +})() diff --git a/src-tauri/src/core/plugins/lifecycle.rs b/src-tauri/src/core/plugins/lifecycle.rs new file mode 100644 index 00000000..f9c64807 --- /dev/null +++ b/src-tauri/src/core/plugins/lifecycle.rs @@ -0,0 +1,84 @@ +use crate::error::{AppError, AppResult}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct Activity { + requests: usize, + changing: bool, +} + +#[derive(Default, Clone)] +pub(super) struct Lifecycle { + state: Arc>>, +} + +pub(super) struct Lease { + lifecycle: Lifecycle, + id: String, + mutation: bool, +} + +impl Lifecycle { + pub fn acquire(&self, id: &str, mutation: bool) -> AppResult { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let activity = state.entry(id.into()).or_default(); + if activity.changing || (mutation && activity.requests > 0) { + return Err(AppError::Config( + "Plugin is busy; wait for its active operations to finish.".into(), + )); + } + if mutation { + activity.changing = true; + } else { + activity.requests += 1; + } + Ok(Lease { + lifecycle: self.clone(), + id: id.into(), + mutation, + }) + } +} + +impl Drop for Lease { + fn drop(&mut self) { + let mut state = self + .lifecycle + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(activity) = state.get_mut(&self.id) { + if self.mutation { + activity.changing = false; + } else { + activity.requests = activity.requests.saturating_sub(1); + } + if !activity.changing && activity.requests == 0 { + state.remove(&self.id); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn mutations_and_requests_are_admitted_atomically_per_plugin() { + let lifecycle = Lifecycle::default(); + let request = lifecycle.acquire("a", false).unwrap(); + assert!(lifecycle.acquire("a", true).is_err()); + let other = lifecycle.acquire("b", true).unwrap(); + assert!(lifecycle.acquire("b", false).is_err()); + drop(request); + let update = lifecycle.acquire("a", true).unwrap(); + assert!(lifecycle.acquire("a", false).is_err()); + drop(update); + drop(other); + assert!(lifecycle.acquire("a", false).is_ok()); + } +} diff --git a/src-tauri/src/core/plugins/mod.rs b/src-tauri/src/core/plugins/mod.rs new file mode 100644 index 00000000..a6110dcb --- /dev/null +++ b/src-tauri/src/core/plugins/mod.rs @@ -0,0 +1,700 @@ +mod gateway; +mod lifecycle; +mod protocol; + +use crate::cmd::app::AppLockState; +use crate::core::SessionManager; +use crate::error::{AppError, AppResult}; +use lifecycle::Lifecycle; +use nyaterm_plugin_runtime::diagnostics::{Diagnostics, Snapshot, Status}; +use nyaterm_plugin_runtime::package::{PackagePreview, prepare}; +use nyaterm_plugin_runtime::registry::{InstalledPlugin, Registry}; +use nyaterm_plugin_runtime::sidecar::{BackendEvent, HostHandler, Sidecar}; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; +use std::time::{Duration, Instant}; +use tauri::{Emitter, Manager}; +use tokio::sync::{Mutex, RwLock, oneshot}; +use tokio_util::sync::CancellationToken; + +pub use protocol::serve_asset; + +/// Preserve Tauri's IPC transport semantics, but initialize it only in the +/// trusted top-level document. Wry's Windows initialization ignores main-only. +pub fn guarded_invoke_script() -> String { + include_str!("ipc_transport.js") + .replace("__TEMPLATE_invoke_key__", "__INVOKE_KEY__") + .replace( + "__TEMPLATE_os_name__", + &serde_json::to_string(std::env::consts::OS).unwrap(), + ) + .replace( + "__TEMPLATE_fetch_channel_data_command__", + "\"plugin:__TAURI_CHANNEL__|fetch\"", + ) + .replace( + "__RAW_process_ipc_message_fn__", + include_str!("process_ipc_message.js"), + ) +} + +#[cfg(test)] +mod ipc_tests { + #[test] + fn guarded_script_resolves_transport_templates() { + let script = super::guarded_invoke_script(); + assert!(!script.contains("__TEMPLATE_")); + assert!(!script.contains("__RAW_")); + assert!(script.contains("const __TAURI_INVOKE_KEY__ = __INVOKE_KEY__")); + assert!( + script.find("if (window.top !== window) return").unwrap() + < script.find("const __TAURI_INVOKE_KEY__").unwrap() + ); + } +} + +#[allow(clippy::needless_pass_by_value)] // Consuming signature is used directly by Result::map_err. +pub(super) fn plugin_error(error: nyaterm_plugin_runtime::Error) -> AppError { + AppError::Config(error.to_string()) +} + +#[derive(Clone)] +pub(super) struct Scope { + plugin_id: String, + version: String, + window_label: String, + session_id: Option, + expires: Instant, + cancellation: CancellationToken, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginScope { + pub token: String, + pub plugin_id: String, + pub version: String, +} + +pub(super) struct Approval { + window_label: String, + responder: oneshot::Sender, +} + +type BackendSlot = Arc>>>; + +pub struct PluginManager { + root: PathBuf, + app_version: String, + registry: RwLock>, + startup_error: Option, + scopes: Mutex>, + approvals: Mutex>, + lifecycle: Lifecycle, + backends: Mutex>, + storage_lock: Mutex<()>, + diagnostics: Diagnostics, +} + +impl PluginManager { + pub fn new(root: PathBuf, app_version: String) -> Arc { + let (registry, startup_error) = match Registry::open(root.clone(), app_version.clone()) { + Ok(registry) => (Some(registry), None), + Err(error) => (None, Some(error.to_string())), + }; + Arc::new(Self { + root, + app_version, + registry: RwLock::new(registry), + startup_error, + scopes: Mutex::new(HashMap::new()), + approvals: Mutex::new(HashMap::new()), + lifecycle: Lifecycle::default(), + backends: Mutex::new(HashMap::new()), + storage_lock: Mutex::new(()), + diagnostics: Diagnostics::default(), + }) + } + + fn registry_error(&self) -> AppError { + AppError::Config( + self.startup_error + .clone() + .unwrap_or_else(|| "Plugin registry is unavailable".into()), + ) + } + + pub async fn list(&self) -> AppResult> { + Ok(self + .registry + .read() + .await + .as_ref() + .ok_or_else(|| self.registry_error())? + .list()) + } + + pub async fn inspect(&self, path: PathBuf) -> AppResult { + let root = self.root.clone(); + let version = self.app_version.clone(); + tokio::task::spawn_blocking(move || { + prepare(&path, &root, &version).map(|package| package.preview) + }) + .await + .map_err(|e| AppError::Config(e.to_string()))? + .map_err(plugin_error) + } + + pub async fn install( + &self, + app: &tauri::AppHandle, + path: PathBuf, + digest: String, + ) -> AppResult { + ensure_unlocked(app)?; + let root = self.root.clone(); + let version = self.app_version.clone(); + let prepared = tokio::task::spawn_blocking(move || prepare(&path, &root, &version)) + .await + .map_err(|e| AppError::Config(e.to_string()))? + .map_err(plugin_error)?; + let id = prepared.preview.manifest.id.clone(); + ensure_unlocked(app)?; + let _lease = self.lifecycle.acquire(&id, true)?; + self.revoke_plugin(&id).await; + let result = self + .registry + .write() + .await + .as_mut() + .ok_or_else(|| self.registry_error())? + .install(prepared, &digest) + .map_err(plugin_error)?; + let _ = app.emit("plugins-changed", ()); + Ok(result) + } + + pub async fn configure( + &self, + app: &tauri::AppHandle, + id: &str, + expected_version: &str, + enabled: bool, + permissions: Vec, + ) -> AppResult<()> { + ensure_unlocked(app)?; + let _lease = self.lifecycle.acquire(id, true)?; + if self.plugin(id).await?.active_version != expected_version { + return Err(AppError::Config( + "Plugin version changed; review its permissions again".into(), + )); + } + self.revoke_plugin(id).await; + self.registry + .write() + .await + .as_mut() + .ok_or_else(|| self.registry_error())? + .configure(id, enabled, permissions) + .map_err(plugin_error)?; + let _ = app.emit("plugins-changed", ()); + Ok(()) + } + + pub async fn activate_version( + &self, + app: &tauri::AppHandle, + id: &str, + version: &str, + ) -> AppResult<()> { + ensure_unlocked(app)?; + let _lease = self.lifecycle.acquire(id, true)?; + self.revoke_plugin(id).await; + self.registry + .write() + .await + .as_mut() + .ok_or_else(|| self.registry_error())? + .activate_version(id, version) + .map_err(plugin_error)?; + let _ = app.emit("plugins-changed", ()); + Ok(()) + } + + pub async fn uninstall(&self, app: &tauri::AppHandle, id: &str) -> AppResult<()> { + ensure_unlocked(app)?; + let _lease = self.lifecycle.acquire(id, true)?; + self.revoke_plugin(id).await; + self.registry + .write() + .await + .as_mut() + .ok_or_else(|| self.registry_error())? + .uninstall(id) + .map_err(plugin_error)?; + self.diagnostics.remove(id); + let _ = app.emit("plugins-changed", ()); + Ok(()) + } + + pub async fn create_scope( + &self, + app: &tauri::AppHandle, + window_label: &str, + plugin_id: &str, + session_id: Option, + ) -> AppResult { + ensure_unlocked(app)?; + if !crate::window_state::is_main_window_label(window_label) { + return Err(AppError::Config( + "Plugin scopes can only be created by a main window".into(), + )); + } + let _lease = self.lifecycle.acquire(plugin_id, false)?; + let plugin = self.plugin(plugin_id).await?; + if !plugin.enabled { + return Err(AppError::Config("Plugin is disabled".into())); + } + plugin + .active() + .map_err(plugin_error)? + .manifest + .validate(&self.app_version) + .map_err(plugin_error)?; + if let Some(id) = &session_id { + require_session(app, window_label, id).await?; + } + let scope = Scope { + plugin_id: plugin_id.into(), + version: plugin.active_version.clone(), + window_label: window_label.into(), + session_id, + expires: Instant::now() + Duration::from_mins(30), + cancellation: CancellationToken::new(), + }; + let token = uuid::Uuid::new_v4().to_string(); + let mut scopes = self.scopes.lock().await; + scopes.retain(|_, scope| { + if scope.expires <= Instant::now() { + scope.cancellation.cancel(); + false + } else { + true + } + }); + if scopes.len() >= 256 { + return Err(AppError::Config("Too many active plugin views".into())); + } + ensure_unlocked(app)?; + scopes.insert(token.clone(), scope); + Ok(PluginScope { + token, + plugin_id: plugin_id.into(), + version: plugin.active_version, + }) + } + + pub async fn close_scope(&self, window_label: &str, token: &str) -> AppResult<()> { + let mut scopes = self.scopes.lock().await; + if scopes + .get(token) + .is_some_and(|s| s.window_label != window_label) + { + return Err(AppError::Config( + "Plugin scope belongs to another window".into(), + )); + } + if let Some(scope) = scopes.remove(token) { + scope.cancellation.cancel(); + } + Ok(()) + } + + pub async fn revoke_window(&self, label: &str) { + self.scopes.lock().await.retain(|_, scope| { + if scope.window_label == label { + scope.cancellation.cancel(); + false + } else { + true + } + }); + self.approvals + .lock() + .await + .retain(|_, approval| approval.window_label != label); + } + + pub async fn revoke_session(&self, session_id: &str) { + self.scopes.lock().await.retain(|_, scope| { + if scope.session_id.as_deref() == Some(session_id) { + scope.cancellation.cancel(); + false + } else { + true + } + }); + } + + pub async fn revoke_all(&self) { + for (_, scope) in self.scopes.lock().await.drain() { + scope.cancellation.cancel(); + } + self.approvals.lock().await.clear(); + for (id, backend) in self.backends.lock().await.drain() { + self.diagnostics.stop(&id); + if let Some(sidecar) = backend.lock().await.take() { + sidecar.stop(); + } + } + } + + async fn revoke_plugin(&self, id: &str) { + self.diagnostics.stop(id); + self.scopes.lock().await.retain(|_, scope| { + if scope.plugin_id == id { + scope.cancellation.cancel(); + false + } else { + true + } + }); + if let Some(backend) = self.backends.lock().await.remove(id) + && let Some(sidecar) = backend.lock().await.take() + { + sidecar.stop(); + } + } + + async fn plugin(&self, id: &str) -> AppResult { + Ok(self + .registry + .read() + .await + .as_ref() + .ok_or_else(|| self.registry_error())? + .get(id) + .map_err(plugin_error)? + .clone()) + } + + async fn scope( + &self, + app: &tauri::AppHandle, + token: &str, + caller_window: Option<&str>, + ) -> AppResult { + ensure_unlocked(app)?; + let scope = self + .scopes + .lock() + .await + .get(token) + .cloned() + .ok_or_else(|| AppError::Config("Plugin scope has expired or been revoked".into()))?; + if scope.expires <= Instant::now() + || scope.cancellation.is_cancelled() + || caller_window.is_some_and(|label| label != scope.window_label) + || app.get_webview_window(&scope.window_label).is_none() + { + return Err(AppError::Config( + "Plugin scope is no longer available in this window".into(), + )); + } + let plugin = self.plugin(&scope.plugin_id).await?; + if !plugin.enabled || plugin.active_version != scope.version { + return Err(AppError::Config( + "Plugin scope version is no longer active".into(), + )); + } + if let Some(id) = &scope.session_id { + require_session(app, &scope.window_label, id).await?; + } + Ok(scope) + } + + pub async fn respond_approval( + &self, + window_label: &str, + request_id: &str, + approved: bool, + ) -> AppResult<()> { + let mut approvals = self.approvals.lock().await; + if approvals + .get(request_id) + .is_none_or(|a| a.window_label != window_label) + { + return Err(AppError::Config( + "Plugin approval is not pending in this window".into(), + )); + } + if let Some(approval) = approvals.remove(request_id) { + let _ = approval.responder.send(approved); + } + Ok(()) + } + + pub async fn backend_call( + self: &Arc, + app: &tauri::AppHandle, + window_label: &str, + token: &str, + method: &str, + params: Value, + ) -> AppResult { + let scope = self.scope(app, token, Some(window_label)).await?; + let _lease = self.lifecycle.acquire(&scope.plugin_id, false)?; + let plugin = self.plugin(&scope.plugin_id).await?; + plugin.require("native").map_err(plugin_error)?; + if !(method.starts_with("ui/") + || plugin + .active() + .map_err(plugin_error)? + .manifest + .contributions + .commands + .iter() + .any(|c| c.method.as_deref() == Some(method))) + || method.len() > 128 + { + return Err(AppError::Config( + "Backend method is not an allowed UI or contributed command method".into(), + )); + } + if serde_json::to_vec(¶ms)?.len() > 1024 * 1024 { + return Err(AppError::Config("Plugin request is too large".into())); + } + let backend = self + .backends + .lock() + .await + .entry(scope.plugin_id.clone()) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .clone(); + let mut sidecar = tokio::select! { + () = scope.cancellation.cancelled() => return Err(AppError::Cancelled("Plugin activation was cancelled".into())), + result = tokio::time::timeout(Duration::from_secs(15), backend.lock()) => result.map_err(|_| AppError::Cancelled("Plugin activation timed out".into()))?, + }; + self.scope(app, token, Some(window_label)).await?; + if sidecar.as_ref().is_none_or(|s| !s.is_running()) { + let root = self + .registry + .read() + .await + .as_ref() + .ok_or_else(|| self.registry_error())? + .active_path(&scope.plugin_id) + .map_err(plugin_error)?; + let ticket = self.diagnostics.begin(&scope.plugin_id, &scope.version); + let diagnostics = self.diagnostics.clone(); + let event_ticket = ticket.clone(); + let observer = Arc::new(move |event: BackendEvent| { + let (status, level, source, message) = match event { + BackendEvent::Notification { method, params } if method == "event/log" => { + let Some(message) = params.get("message").and_then(Value::as_str) else { + return; + }; + let level = params + .get("level") + .and_then(Value::as_str) + .unwrap_or("info"); + (None, level.to_owned(), "plugin", message.to_owned()) + } + BackendEvent::Stderr(message) => (None, "warn".into(), "stderr", message), + BackendEvent::ProtocolError(message) => { + (Some(Status::Error), "error".into(), "protocol", message) + } + BackendEvent::Stopped(message) => ( + Some(Status::Error), + "error".into(), + "process", + format!("Native backend exited: {message}"), + ), + _ => return, + }; + diagnostics.update( + &event_ticket, + status, + &level, + source, + &crate::core::ai::redact_sensitive_text(&message), + ); + }); + let activation = Sidecar::spawn_observed( + &root, + &plugin.active().map_err(plugin_error)?.manifest, + &self.app_version, + &plugin.granted_permissions, + Arc::new(PluginHostHandler { + manager: Arc::downgrade(self), + app: app.clone(), + plugin_id: scope.plugin_id.clone(), + }), + Some(observer), + ); + let activated = tokio::select! { + () = scope.cancellation.cancelled() => Err(AppError::Cancelled("Plugin activation was cancelled".into())), + result = activation => result.map_err(plugin_error), + }; + let activated = match activated { + Ok(activated) => activated, + Err(error) => { + if matches!(&error, AppError::Cancelled(_)) { + self.diagnostics.update( + &ticket, + Some(Status::Stopped), + "warn", + "activation", + "Native backend activation cancelled", + ); + self.diagnostics.stop(&scope.plugin_id); + } else { + self.diagnostics.update( + &ticket, + Some(Status::Error), + "error", + "activation", + &crate::core::ai::redact_sensitive_text(&error.to_string()), + ); + } + return Err(error); + } + }; + if let Err(error) = self.scope(app, token, Some(window_label)).await { + self.diagnostics.stop(&scope.plugin_id); + return Err(error); + } + if !activated.is_running() { + return Err(AppError::Config("Plugin exited during activation".into())); + } + self.diagnostics.update( + &ticket, + Some(Status::Running), + "info", + "host", + "Native backend initialized", + ); + *sidecar = Some(Arc::new(activated)); + } + let running = sidecar.as_ref().unwrap().clone(); + let request_ticket = self.diagnostics.ticket(&scope.plugin_id); + drop(sidecar); + let result = tokio::select! { + () = scope.cancellation.cancelled() => Err(AppError::Cancelled("Plugin request was cancelled".into())), + result = running.request(method, serde_json::json!({"scopeToken":token,"input":params}), Duration::from_secs(170)) => result.map_err(plugin_error), + }; + if let Err(error) = &result + && let Some(ticket) = request_ticket + { + self.diagnostics.update( + &ticket, + None, + "error", + "request", + &crate::core::ai::redact_sensitive_text(&error.to_string()), + ); + } + self.scope(app, token, Some(window_label)).await?; + result + } + + pub async fn diagnostic_snapshot( + &self, + app: &tauri::AppHandle, + id: &str, + ) -> AppResult { + ensure_unlocked(app)?; + let plugin = self.plugin(id).await?; + Ok(self.diagnostics.snapshot(id, &plugin.active_version)) + } + + pub async fn clear_logs(&self, app: &tauri::AppHandle, id: &str) -> AppResult<()> { + ensure_unlocked(app)?; + self.plugin(id).await?; + self.diagnostics.clear(id); + Ok(()) + } + + pub async fn stop_backend(&self, app: &tauri::AppHandle, id: &str) -> AppResult<()> { + ensure_unlocked(app)?; + let _lease = self.lifecycle.acquire(id, true)?; + self.plugin(id).await?; + self.diagnostics.stop(id); + if let Some(backend) = self.backends.lock().await.remove(id) + && let Some(sidecar) = backend.lock().await.take() + { + sidecar.stop(); + } + Ok(()) + } +} + +struct PluginHostHandler { + manager: Weak, + app: tauri::AppHandle, + plugin_id: String, +} + +#[async_trait::async_trait] +impl HostHandler for PluginHostHandler { + async fn call(&self, method: &str, params: Value) -> nyaterm_plugin_runtime::Result { + let manager = self.manager.upgrade().ok_or_else(|| { + nyaterm_plugin_runtime::Error::Runtime("Plugin host has stopped".into()) + })?; + let token = params + .get("scopeToken") + .and_then(Value::as_str) + .ok_or_else(|| { + nyaterm_plugin_runtime::Error::Invalid("Host request needs a scope token".into()) + })?; + let scope = manager + .scope(&self.app, token, None) + .await + .map_err(|e| nyaterm_plugin_runtime::Error::Invalid(e.to_string()))?; + if scope.plugin_id != self.plugin_id { + return Err(nyaterm_plugin_runtime::Error::Invalid( + "Scope belongs to another plugin".into(), + )); + } + manager + .host_call( + &self.app, + None, + token, + method, + params.get("input").cloned().unwrap_or(Value::Null), + ) + .await + .map_err(|e| nyaterm_plugin_runtime::Error::Runtime(e.to_string())) + } +} + +pub(super) fn ensure_unlocked(app: &tauri::AppHandle) -> AppResult<()> { + if app + .try_state::() + .is_some_and(|s| s.is_locked()) + { + return Err(AppError::Config( + "Plugins are unavailable while NyaTerm is locked".into(), + )); + } + Ok(()) +} + +async fn require_session( + app: &tauri::AppHandle, + window_label: &str, + id: &str, +) -> AppResult { + let manager = app.state::>(); + let info = manager.session_info(id).await?; + if !info.connected || info.owner_window_label.as_deref() != Some(window_label) { + return Err(AppError::Config( + "Session is not available in this plugin window scope".into(), + )); + } + Ok(info) +} diff --git a/src-tauri/src/core/plugins/process_ipc_message.js b/src-tauri/src/core/plugins/process_ipc_message.js new file mode 100644 index 00000000..4ee2cb2f --- /dev/null +++ b/src-tauri/src/core/plugins/process_ipc_message.js @@ -0,0 +1,46 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +// this is a function and not an iife so use it carefully + +(function (message) { + if ( + message instanceof ArrayBuffer + || ArrayBuffer.isView(message) + || Array.isArray(message) + ) { + return { + contentType: 'application/octet-stream', + data: message + } + } else { + const data = JSON.stringify(message, (_k, val) => { + // if this value changes, make sure to update it in: + // 1. ipc.js + // 2. core.ts + const SERIALIZE_TO_IPC_FN = '__TAURI_TO_IPC_KEY__' + + if (val instanceof Map) { + return Object.fromEntries(val.entries()) + } else if (val instanceof Uint8Array) { + return Array.from(val) + } else if (val instanceof ArrayBuffer) { + return Array.from(new Uint8Array(val)) + } else if ( + typeof val === 'object' + && val !== null + && SERIALIZE_TO_IPC_FN in val + ) { + return val[SERIALIZE_TO_IPC_FN]() + } else { + return val + } + }) + + return { + contentType: 'application/json', + data + } + } +}) diff --git a/src-tauri/src/core/plugins/protocol.rs b/src-tauri/src/core/plugins/protocol.rs new file mode 100644 index 00000000..d746c44f --- /dev/null +++ b/src-tauri/src/core/plugins/protocol.rs @@ -0,0 +1,77 @@ +use super::{PluginManager, plugin_error}; +use crate::error::{AppError, AppResult}; +use nyaterm_plugin_runtime::manifest::validate_path; +use std::sync::Arc; +use tauri::Manager; +use tokio::io::AsyncReadExt; + +/// A separate document response avoids inheriting the main application's CSP. +/// The opaque-origin iframe still cannot access Tauri IPC or its parent's DOM. +pub async fn serve_asset(app: &tauri::AppHandle, uri: &str) -> tauri::http::Response> { + let result = asset(app, uri).await; + match result { + Ok((body, mime)) => tauri::http::Response::builder() + .header("Content-Type", mime) + .header("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline' nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; style-src 'unsafe-inline' nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; img-src data: nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; font-src data: nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'") + .header("Access-Control-Allow-Origin", "*") + .header("X-Content-Type-Options", "nosniff") + .header("Referrer-Policy", "no-referrer") + .header("Cache-Control", "no-store") + .body(body).unwrap(), + Err(_) => tauri::http::Response::builder().status(403) + .header("Content-Type", "text/plain").header("Cache-Control", "no-store") + .body(b"Plugin asset is unavailable".to_vec()).unwrap(), + } +} + +async fn asset(app: &tauri::AppHandle, uri: &str) -> AppResult<(Vec, &'static str)> { + let url = reqwest::Url::parse(uri).map_err(|e| AppError::Config(e.to_string()))?; + let decoded = urlencoding::decode(url.path()).map_err(|e| AppError::Config(e.to_string()))?; + let path = decoded.trim_start_matches('/'); + let (token, relative) = path + .split_once('/') + .ok_or_else(|| AppError::Config("Invalid plugin asset URL".into()))?; + if token.len() != 36 || !(relative.starts_with("ui/") || relative.starts_with("assets/")) { + return Err(AppError::Config("Invalid plugin asset URL".into())); + } + validate_path(relative).map_err(plugin_error)?; + let manager = app.state::>(); + let scope = manager.scope(app, token, None).await?; + let _lease = manager.lifecycle.acquire(&scope.plugin_id, false)?; + manager.scope(app, token, None).await?; + let path = manager + .registry + .read() + .await + .as_ref() + .ok_or_else(|| manager.registry_error())? + .asset(&scope.plugin_id, relative) + .map_err(plugin_error)?; + if std::fs::metadata(&path)?.len() > 4 * 1024 * 1024 { + return Err(AppError::Config("Plugin UI asset is too large".into())); + } + let file = tokio::fs::File::open(path).await?; + let mut body = Vec::new(); + file.take(4 * 1024 * 1024 + 1) + .read_to_end(&mut body) + .await?; + if body.len() > 4 * 1024 * 1024 { + return Err(AppError::Config("Plugin UI asset is too large".into())); + } + manager.scope(app, token, None).await?; + let mime = match relative.rsplit('.').next().unwrap_or_default() { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "gif" => "image/gif", + "woff" => "font/woff", + "woff2" => "font/woff2", + _ => return Err(AppError::Config("Unsupported plugin UI asset type".into())), + }; + Ok((body, mime)) +} diff --git a/src-tauri/src/core/session.rs b/src-tauri/src/core/session.rs index 2a27520b..22bcf5a2 100644 --- a/src-tauri/src/core/session.rs +++ b/src-tauri/src/core/session.rs @@ -19,7 +19,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::Duration; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use time::format_description::well_known::Rfc3339; use tokio::sync::{Mutex, Notify, mpsc, oneshot}; @@ -848,6 +848,9 @@ impl SessionManager { } if removed { if let Some(app) = self.app_handle.get() { + if let Some(plugins) = app.try_state::>() { + plugins.revoke_session(id).await; + } let _ = app.emit("sessions-changed", ()); crate::tray::schedule_refresh(app); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae5a34d6..db69c82f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,8 +69,12 @@ pub fn run() { runtime.config_dir().to_path_buf(), runtime.executable_dir().to_path_buf(), ); + let plugin_manager = core::plugins::PluginManager::new( + runtime.config_dir().join("plugins"), + env!("CARGO_PKG_VERSION").into(), + ); - let builder = tauri::Builder::default(); + let builder = tauri::Builder::default().invoke_system(core::plugins::guarded_invoke_script()); #[cfg(not(any(target_os = "android", target_os = "ios")))] let builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { if external_open::handle_external_open_args( @@ -117,6 +121,7 @@ pub fn run() { .manage(docker_sudo_manager.clone()) .manage(remote_stats_sampler.clone()) .manage(mcp_manager.clone()) + .manage(plugin_manager) .manage(app_lock_state) .manage(external_open_state) .manage(portable_update_state) @@ -139,7 +144,32 @@ pub fn run() { Ok(()) }) .on_window_event(app::on_window_event) + .register_asynchronous_uri_scheme_protocol( + "nyaterm-plugin", + |context, request, responder| { + let app = context.app_handle().clone(); + tauri::async_runtime::spawn(async move { + responder.respond( + core::plugins::serve_asset(&app, &request.uri().to_string()).await, + ); + }); + }, + ) .invoke_handler(tauri::generate_handler![ + cmd::plugins::get_plugin_diagnostics, + cmd::plugins::clear_plugin_logs, + cmd::plugins::stop_plugin_backend, + cmd::plugins::list_plugins, + cmd::plugins::inspect_plugin_package, + cmd::plugins::install_plugin_package, + cmd::plugins::configure_plugin, + cmd::plugins::activate_plugin_version, + cmd::plugins::uninstall_plugin, + cmd::plugins::create_plugin_scope, + cmd::plugins::close_plugin_scope, + cmd::plugins::plugin_host_call, + cmd::plugins::plugin_backend_call, + cmd::plugins::respond_plugin_approval, cmd::app::quit_application, cmd::app::hide_main_window, cmd::app::open_download_dir, @@ -445,6 +475,9 @@ pub fn run() { tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } ) { if matches!(_event, tauri::RunEvent::Exit) { + if let Some(manager) = _app.try_state::>() { + tauri::async_runtime::block_on(manager.revoke_all()); + } if let Some(manager) = _app.try_state::>() { manager.finish_all_scopes(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ef45b9e5..298a37d4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ "enable": true, "scope": [] }, - "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost" + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index b5359534..3011d78e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,8 @@ import type { RdpCertificateVerifyRequest } from "./components/dialog/connection import type { DockerSudoPasswordRequest } from "./components/dialog/docker/DockerSudoPasswordDialog"; import type { QuickSwitcherSession } from "./components/dialog/terminal/SessionQuickSwitcherDialog"; import { useApp } from "./context/AppContext"; +import { usePlugins } from "./context/PluginContext"; +import { activeManifest, parsePluginPanelId } from "./lib/plugins"; import { TransferProvider } from "./context/TransferContext"; import { useActivityBarController } from "./hooks/useActivityBarController"; import { useAppWindowEvents } from "./hooks/useAppWindowEvents"; @@ -171,6 +173,7 @@ function isSftpOnlySession( /** Root layout: header, activity bars, sidebars, terminal area, dialogs. */ function App() { + const { plugins } = usePlugins(); useMacSelectionGuard(); // Keep dynamic session titles (local PTY shell integration) flowing for the // main window's tab labels and window title. @@ -3058,7 +3061,22 @@ function App() { const getPanelTitle = useCallback( (panelId: string) => { + const pluginPanel = parsePluginPanelId(panelId); + if (pluginPanel) { + const plugin = plugins.find( + (plugin) => plugin.id === pluginPanel.pluginId, + ); + return ( + (plugin && + activeManifest(plugin)?.contributions.panels.find( + (panel) => panel.id === pluginPanel.panelId, + )?.title) || + t("plugins.title") + ); + } switch (panelId) { + case "plugins": + return t("plugins.title"); case "securityAuth": return t("securityAuth.title"); case "aiAssistant": @@ -3069,7 +3087,7 @@ function App() { return t(`panel.${panelId}`); } }, - [t], + [t, plugins], ); const renderPanelContent = useCallback( diff --git a/src/components/app/AppPanelContent.tsx b/src/components/app/AppPanelContent.tsx index 888acecd..4ee64195 100644 --- a/src/components/app/AppPanelContent.tsx +++ b/src/components/app/AppPanelContent.tsx @@ -14,6 +14,9 @@ import ProcessManager from "@/components/panel/ProcessManager"; import RecordingPanel from "@/components/panel/RecordingPanel"; import ResourceMonitor from "@/components/panel/ResourceMonitor"; import SyncBackupHistoryPanel from "@/components/panel/SyncBackupHistoryPanel"; +import { PluginPanel } from "@/components/plugins/PluginPanel"; +import { PluginsPanel } from "@/components/plugins/PluginsPanel"; +import { parsePluginPanelId } from "@/lib/plugins"; import SavedConnections from "@/components/panel/saved-connections"; import SecurityAuthPanel from "@/components/panel/security-auth"; import type { NetworkHistoryStore } from "@/hooks/useNetworkHistory"; @@ -144,6 +147,8 @@ export default function AppPanelContent({ ); case "network": return ; + case "plugins": + return ; case "notes": return ; case "securityAuth": @@ -218,7 +223,9 @@ export default function AppPanelContent({ case "aiAssistant": return null; default: - return null; + return parsePluginPanelId(panelId) && panelId ? ( + + ) : null; } })(); diff --git a/src/components/panel/saved-connections/ConnectionItem.tsx b/src/components/panel/saved-connections/ConnectionItem.tsx index b3a14874..d0853300 100644 --- a/src/components/panel/saved-connections/ConnectionItem.tsx +++ b/src/components/panel/saved-connections/ConnectionItem.tsx @@ -3,6 +3,7 @@ import { FolderOpen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MdContentCopy, MdDelete, MdDriveFileRenameOutline, MdEdit, MdLink } from "react-icons/md"; import { toast } from "sonner"; +import { PluginContextMenuItems } from "@/components/plugins/PluginContextMenuItems"; import { Badge } from "@/components/ui/badge"; import { ContextMenu, @@ -741,6 +742,7 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection ? t("savedConnections.deleteSelected") : t("savedConnections.delete")} + ); diff --git a/src/components/plugins/PluginApprovalHost.tsx b/src/components/plugins/PluginApprovalHost.tsx new file mode 100644 index 00000000..35415bcc --- /dev/null +++ b/src/components/plugins/PluginApprovalHost.tsx @@ -0,0 +1,96 @@ +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { pluginApi } from "@/lib/plugins"; +import type { PluginApprovalRequest } from "@/types/plugins"; + +export function PluginApprovalHost() { + const { t } = useTranslation(); + const [requests, setRequests] = useState([]); + const current = requests[0] ?? null; + useEffect(() => { + let disposed = false; + const disposers: (() => void)[] = []; + const register = (promise: Promise<() => void>) => { + void promise + .then((dispose) => (disposed ? dispose() : disposers.push(dispose))) + .catch(() => {}); + }; + register( + listen("plugin-approval-request", (event) => { + if (!disposed) + setRequests((items) => + items.some((item) => item.requestId === event.payload.requestId) + ? items + : [...items, event.payload], + ); + }), + ); + register( + listen("plugin-approval-closed", (event) => { + if (!disposed) + setRequests((items) => + items.filter((item) => item.requestId !== event.payload), + ); + }), + ); + register( + listen<{ locked: boolean }>("app-lock-state-changed", (event) => { + if (!disposed && event.payload.locked) setRequests([]); + }), + ); + return () => { + disposed = true; + for (const dispose of disposers) dispose(); + }; + }, []); + const respond = (approved: boolean) => { + if (!current) return; + setRequests((items) => + items.filter((item) => item.requestId !== current.requestId), + ); + void pluginApi.approve(current.requestId, approved).catch(() => {}); + }; + return ( + !open && respond(false)} + > + + + {t("plugins.approvalTitle")} + + {t("plugins.approvalDescription", { name: current?.pluginName })} + + + {current && ( +
+

+ {current.sessionName} · {current.capability} · {current.risk} +

+
+              {current.summary}
+            
+
+ )} + + + + +
+
+ ); +} diff --git a/src/components/plugins/PluginCommandHost.tsx b/src/components/plugins/PluginCommandHost.tsx new file mode 100644 index 00000000..9fbc574a --- /dev/null +++ b/src/components/plugins/PluginCommandHost.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { usePlugins } from "@/context/PluginContext"; +import { getErrorMessage } from "@/lib/errors"; +import { + activeManifest, + PLUGIN_OPEN_EVENT, + pluginApi, + type PluginOpenIntent, +} from "@/lib/plugins"; + +export function PluginCommandHost() { + const { t } = useTranslation(); + const { plugins, locked } = usePlugins(); + const currentPlugins = useRef(plugins); + currentPlugins.current = plugins; + const lockedRef = useRef(locked); + lockedRef.current = locked; + const [result, setResult] = useState<{ + title: string; + output: string; + busy: boolean; + } | null>(null); + const activeToken = useRef(null); + const sequence = useRef(0); + useEffect(() => { + const receive = (event: Event) => { + const intent = (event as CustomEvent).detail; + if (!intent.commandId || lockedRef.current || activeToken.current) return; + const plugin = currentPlugins.current.find( + (plugin) => plugin.id === intent.pluginId && plugin.enabled, + ); + const command = + plugin && + activeManifest(plugin)?.contributions.commands.find( + (command) => command.id === intent.commandId, + ); + if (!plugin || !command?.method) return; + const request = ++sequence.current; + setResult({ title: command.title, output: "", busy: true }); + void (async () => { + let token: string | undefined; + try { + const scope = await pluginApi.createScope( + plugin.id, + intent.sessionId ?? null, + ); + token = scope.token; + if (request !== sequence.current) return; + activeToken.current = token; + const output = await pluginApi.backendCall( + token, + command.method as string, + { connectionId: intent.connectionId ?? null }, + ); + if (request === sequence.current) + setResult({ + title: command.title, + output: + typeof output === "string" + ? output + : JSON.stringify(output, null, 2), + busy: false, + }); + } catch (error) { + if (request === sequence.current) { + setResult(null); + toast.error(getErrorMessage(error)); + } + } finally { + if (token) await pluginApi.closeScope(token).catch(() => {}); + if (request === sequence.current) activeToken.current = null; + } + })(); + }; + window.addEventListener(PLUGIN_OPEN_EVENT, receive); + return () => { + window.removeEventListener(PLUGIN_OPEN_EVENT, receive); + sequence.current += 1; + if (activeToken.current) + void pluginApi.closeScope(activeToken.current).catch(() => {}); + }; + }, []); + const close = useCallback(() => { + sequence.current += 1; + setResult(null); + if (activeToken.current) + void pluginApi.closeScope(activeToken.current).catch(() => {}); + activeToken.current = null; + }, []); + useEffect(() => { + if (locked) close(); + }, [locked, close]); + return ( + !open && close()}> + + + {result?.title} + {t("plugins.commandResult")} + +
+          {result?.busy ? t("plugins.running") : result?.output}
+        
+
+
+ ); +} diff --git a/src/components/plugins/PluginContextMenuItems.tsx b/src/components/plugins/PluginContextMenuItems.tsx new file mode 100644 index 00000000..8efc912b --- /dev/null +++ b/src/components/plugins/PluginContextMenuItems.tsx @@ -0,0 +1,45 @@ +import { Puzzle } from "lucide-react"; +import { + ContextMenuItem, + ContextMenuSeparator, +} from "@/components/ui/context-menu"; +import { usePlugins } from "@/context/PluginContext"; +import { getPluginCommands, openPlugin } from "@/lib/plugins"; + +export function PluginContextMenuItems({ + menu, + sessionId, + connectionId, + disabled = false, +}: { + menu: "terminal" | "connection"; + sessionId?: string | null; + connectionId?: string; + disabled?: boolean; +}) { + const { plugins, locked } = usePlugins(); + const commands = getPluginCommands(plugins, menu); + if (!commands.length) return null; + return ( + <> + + {commands.map((command) => ( + + openPlugin({ + pluginId: command.pluginId, + commandId: command.id, + sessionId: menu === "connection" ? null : sessionId, + connectionId, + }) + } + > + + {command.title} + + ))} + + ); +} diff --git a/src/components/plugins/PluginDiagnostics.test.tsx b/src/components/plugins/PluginDiagnostics.test.tsx new file mode 100644 index 00000000..0bcc4994 --- /dev/null +++ b/src/components/plugins/PluginDiagnostics.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writeClipboardText } from "@/lib/clipboard"; +import { pluginApi } from "@/lib/plugins"; +import type { PluginDiagnosticsSnapshot } from "@/types/plugins"; +import { PluginDiagnostics } from "./PluginDiagnostics"; + +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock("@/lib/plugins", () => ({ + pluginApi: { diagnostics: vi.fn(), stopBackend: vi.fn(), clearLogs: vi.fn() }, +})); +vi.mock("@/lib/clipboard", () => ({ writeClipboardText: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); +const props = { + pluginId: "example.sdk", + name: "SDK", + enabled: true, + native: true, + locked: false, + disabled: false, +}; +const running: PluginDiagnosticsSnapshot = { + status: "running", + version: "1.0.0", + lastError: "Example error", + logs: [ + { + timestamp: 1000, + version: "1.0.0", + level: "error", + source: "plugin", + message: "", + }, + ], +}; +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(pluginApi.diagnostics).mockResolvedValue(running); +}); +afterEach(cleanup); + +describe("plugin diagnostics", () => { + it("shows native state, stops without disabling and refreshes the state", async () => { + render(); + await screen.findByText("plugins.diagnostics.status.running"); + vi.mocked(pluginApi.stopBackend).mockResolvedValue(); + vi.mocked(pluginApi.diagnostics).mockResolvedValue({ ...running, status: "stopped" }); + fireEvent.click(screen.getByText("plugins.diagnostics.stop")); + await screen.findByText("plugins.diagnostics.status.stopped"); + expect(pluginApi.stopBackend).toHaveBeenCalledWith("example.sdk"); + expect((screen.getByText("plugins.diagnostics.stop") as HTMLButtonElement).disabled).toBe(true); + }); + it("renders logs as text and supports copy, refresh and clearing", async () => { + render(); + await screen.findByText("plugins.diagnostics.status.running"); + fireEvent.click(screen.getByText("plugins.diagnostics.logs")); + const region = await screen.findByRole("region", { name: "plugins.diagnostics.logs" }); + await waitFor(() => expect(region.textContent).toContain("")); + expect(region.querySelector("script")).toBeNull(); + vi.mocked(writeClipboardText).mockResolvedValue(); + fireEvent.click(screen.getByText("plugins.diagnostics.copy")); + await waitFor(() => + expect(writeClipboardText).toHaveBeenCalledWith( + expect.stringContaining("[error] [plugin] v1.0.0"), + ), + ); + await waitFor(() => + expect((screen.getByText("plugins.diagnostics.clear") as HTMLButtonElement).disabled).toBe( + false, + ), + ); + vi.mocked(pluginApi.clearLogs).mockResolvedValue(); + vi.mocked(pluginApi.diagnostics).mockResolvedValue({ ...running, lastError: null, logs: [] }); + fireEvent.click(screen.getByText("plugins.diagnostics.clear")); + await screen.findByText("plugins.diagnostics.empty"); + expect(pluginApi.clearLogs).toHaveBeenCalledWith("example.sdk"); + const calls = vi.mocked(pluginApi.diagnostics).mock.calls.length; + fireEvent.click(screen.getByText("plugins.diagnostics.refresh")); + await waitFor(() => expect(vi.mocked(pluginApi.diagnostics).mock.calls.length).toBe(calls + 1)); + }); + it("discards a pending response after locking and distinguishes UI plugins", async () => { + let resolve!: (value: PluginDiagnosticsSnapshot) => void; + vi.mocked(pluginApi.diagnostics).mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); + const { rerender } = render(); + rerender(); + await act(async () => { + resolve(running); + }); + expect(screen.queryByText("Example error")).toBeNull(); + expect(screen.queryByText("plugins.diagnostics.status.running")).toBeNull(); + rerender(); + expect(screen.getByText("plugins.diagnostics.status.uiOnly")).toBeTruthy(); + expect(screen.queryByText("plugins.diagnostics.stop")).toBeNull(); + }); +}); diff --git a/src/components/plugins/PluginDiagnostics.tsx b/src/components/plugins/PluginDiagnostics.tsx new file mode 100644 index 00000000..90bf564a --- /dev/null +++ b/src/components/plugins/PluginDiagnostics.tsx @@ -0,0 +1,176 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { writeClipboardText } from "@/lib/clipboard"; +import { getErrorMessage } from "@/lib/errors"; +import { pluginApi } from "@/lib/plugins"; +import type { PluginDiagnosticsSnapshot } from "@/types/plugins"; + +export function formatPluginLogs(snapshot: PluginDiagnosticsSnapshot) { + return snapshot.logs + .map( + (entry) => + `${new Date(entry.timestamp).toISOString()} [${entry.level}] [${entry.source}] v${entry.version} ${entry.message}`, + ) + .join("\n"); +} + +export function PluginDiagnostics({ + pluginId, + name, + enabled, + native, + locked, + disabled, +}: { + pluginId: string; + name: string; + enabled: boolean; + native: boolean; + locked: boolean; + disabled: boolean; +}) { + const { t } = useTranslation(); + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const generation = useRef(0); + const refresh = useRef<() => Promise>(async () => {}); + + useEffect(() => { + const current = ++generation.current; + setSnapshot(null); + setError(null); + if (locked) { + setOpen(false); + return; + } + let pending = false; + const read = async () => { + if (pending || generation.current !== current) return; + pending = true; + try { + const result = await pluginApi.diagnostics(pluginId); + if (generation.current === current) { + setSnapshot(result); + setError(null); + } + } catch (error) { + if (generation.current === current) setError(getErrorMessage(error)); + } finally { + pending = false; + } + }; + refresh.current = read; + void read(); + const timer = window.setInterval(() => void read(), open ? 1000 : 2500); + return () => { + ++generation.current; + window.clearInterval(timer); + }; + }, [pluginId, locked, open]); + + const action = useCallback(async (operation: () => Promise) => { + setBusy(true); + try { + await operation(); + await refresh.current(); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }, []); + const status = !enabled ? "disabled" : !native ? "uiOnly" : snapshot?.status; + const blocked = busy || locked || disabled; + const text = snapshot ? formatPluginLogs(snapshot) : ""; + + return ( +
+
+ {status && ( + + {t(`plugins.diagnostics.status.${status}`)} + + )} + + {native && ( + + )} +
+ {snapshot?.lastError && !locked && ( +

+ {snapshot.lastError} +

+ )} + {error && ( +

+ {error} +

+ )} + + + + {t("plugins.diagnostics.title", { name })} + {t("plugins.diagnostics.description")} + +
+ + + +
+
+
+              {text || t("plugins.diagnostics.empty")}
+            
+
+ {error && ( +

+ {error} +

+ )} +
+
+
+ ); +} diff --git a/src/components/plugins/PluginPanel.tsx b/src/components/plugins/PluginPanel.tsx new file mode 100644 index 00000000..ace89e44 --- /dev/null +++ b/src/components/plugins/PluginPanel.tsx @@ -0,0 +1,158 @@ +import { convertFileSrc } from "@tauri-apps/api/core"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { usePlugins } from "@/context/PluginContext"; +import { getErrorMessage } from "@/lib/errors"; +import { createPluginBridge } from "@/lib/pluginBridge"; +import { activeManifest, parsePluginPanelId, pluginApi } from "@/lib/plugins"; +import type { PluginScope } from "@/types/plugins"; + +const THEME_VARIABLES = [ + "--background", + "--foreground", + "--primary", + "--primary-foreground", + "--muted", + "--muted-foreground", + "--border", + "--destructive", + "--df-bg", + "--df-fg", +]; + +function theme() { + const style = getComputedStyle(document.documentElement); + return Object.fromEntries( + THEME_VARIABLES.map((key) => [key, style.getPropertyValue(key).trim()]), + ); +} + +export function PluginPanel({ + activityId, + sessionId, +}: { + activityId: string; + sessionId: string | null; +}) { + const { t } = useTranslation(); + const { plugins, generation, locked, loaded, openIntent } = usePlugins(); + const panelId = parsePluginPanelId(activityId); + const plugin = plugins.find((plugin) => plugin.id === panelId?.pluginId); + const manifest = plugin && activeManifest(plugin); + const panel = manifest?.contributions.panels.find( + (panel) => panel.id === panelId?.panelId, + ); + const intentPanelId = + openIntent?.panelId ?? + manifest?.contributions.commands.find( + (command) => command.id === openIntent?.commandId, + )?.panel; + const scopedSessionId = + openIntent?.pluginId === plugin?.id && + intentPanelId === panel?.id && + openIntent?.sessionId !== undefined + ? openIntent.sessionId + : sessionId; + const [scope, setScope] = useState(null); + const [error, setError] = useState(null); + const [retry, setRetry] = useState(0); + const frame = useRef(null); + const enabled = Boolean(plugin?.enabled && panel && !locked); + const pluginId = plugin?.id; + const version = plugin?.activeVersion; + + // Permission changes and renewal must replace the backend-issued scope. + // biome-ignore lint/correctness/useExhaustiveDependencies: version, generation and retry invalidate scopes even if the plugin remains enabled. + useEffect(() => { + setScope(null); + setError(null); + if (!enabled || !pluginId) return; + let disposed = false; + let token: string | undefined; + void pluginApi + .createScope(pluginId, scopedSessionId) + .then((scope) => { + token = scope.token; + if (disposed) void pluginApi.closeScope(scope.token).catch(() => {}); + else setScope(scope); + }) + .catch((error) => { + if (!disposed) setError(getErrorMessage(error)); + }); + const refreshTimer = window.setTimeout( + () => setRetry((value) => value + 1), + 25 * 60 * 1000, + ); + return () => { + disposed = true; + window.clearTimeout(refreshTimer); + if (token) void pluginApi.closeScope(token).catch(() => {}); + }; + }, [enabled, pluginId, version, generation, scopedSessionId, retry]); + + const entry = panel?.entry; + useEffect(() => { + const iframe = frame.current; + if (!scope || !iframe || !entry) return; + const bridge = createPluginBridge(iframe, scope.token, () => ({ + pluginId: scope.pluginId, + version: scope.version, + theme: theme(), + })); + const observer = new MutationObserver(() => bridge.updateContext()); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style", "data-theme"], + }); + // Register the bridge before the document starts loading and sends its ready message. + iframe.src = `${convertFileSrc("", "nyaterm-plugin")}${scope.token}/${entry.split("/").map(encodeURIComponent).join("/")}`; + return () => { + bridge.dispose(); + observer.disconnect(); + }; + }, [scope, entry]); + + if (!enabled) + return ( + + {!loaded + ? t("plugins.loading") + : locked + ? t("plugins.locked") + : t("plugins.unavailable")} + + ); + if (error) + return ( +
+

{error}

+ +
+ ); + return ( +
+
+ {panel?.title} +
+ {scope ? ( +