feat(plugins): add signed marketplace and standalone authoring CLI

Verify Store packages against pinned Ed25519 keys and catalog metadata, persist installation provenance, and add Marketplace review and installation UI while preserving unsigned local packages.

Bundle standalone plugin authoring tools and add a six-platform CLI packaging workflow. Official key initialization and protected signing environment setup remain separate rollout steps.
This commit is contained in:
Kang committed 2026-10-03 21:00:30 +08:00
1 parent ac014d9667
commit 8debbfbc9f
33 files changed
+2337 -209

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
name: Build plugin CLI
on:
workflow_dispatch:
inputs:
publish:
description: Publish assembled CLI to npm
type: boolean
default: false
permissions:
contents: read
jobs:
native:
strategy:
fail-fast: false
matrix:
include:
- {
runner: windows-latest,
platform: windows-x86_64,
extension: .exe,
}
- {
runner: windows-11-arm,
platform: windows-aarch64,
extension: .exe,
}
- { runner: ubuntu-22.04, platform: linux-x86_64, extension: "" }
- { runner: ubuntu-24.04-arm, platform: linux-aarch64, extension: "" }
- { runner: macos-15-intel, platform: macos-x86_64, extension: "" }
- { runner: macos-14, platform: macos-aarch64, extension: "" }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.94.0
- run: cargo build --locked --release --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --bin nyaterm-plugin
- name: Name platform binary
shell: bash
env:
PLATFORM: ${{ matrix.platform }}
EXT: ${{ matrix.extension }}
run: |
mkdir -p plugins/cli/native
cp "src-tauri/crates/nyaterm-plugin-runtime/target/release/nyaterm-plugin$EXT" "plugins/cli/native/nyaterm-plugin-$PLATFORM$EXT"
- uses: actions/upload-artifact@v4
with:
name: cli-${{ matrix.platform }}
path: plugins/cli/native/*
assemble:
needs: native
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/download-artifact@v4
with:
pattern: cli-*
path: plugins/cli/native
merge-multiple: true
- run: npm pack ./plugins/cli
env:
NYATERM_CLI_RELEASE: "1"
- name: Verify standalone package
run: |
npm install -g ./nyaterm-plugin-cli-*.tgz
nyaterm-plugin create example.smoke work/plugin
nyaterm-plugin pack work/plugin work/smoke.nyap
nyaterm-plugin inspect work/smoke.nyap
- uses: actions/upload-artifact@v4
with:
name: npm-plugin-cli
path: nyaterm-plugin-cli-*.tgz
publish:
if: inputs.publish && github.ref == 'refs/heads/main'
needs: assemble
runs-on: ubuntu-latest
environment: plugin-cli-publishing
permissions:
id-token: write
contents: read
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- uses: actions/download-artifact@v4
with:
name: npm-plugin-cli
- run: npm publish ./nyaterm-plugin-cli-*.tgz --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+31 -1
View File
@@ -52,7 +52,37 @@ 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.
Local packages may remain unsigned. The official Plugin Store uses repository
Ed25519 signing: `signature.json` signs the exact bytes of `checksums.json`, which
covers every content file (including `manifest.json`) but excludes `checksums.json`
and `signature.json` themselves. Publisher verification and package signatures are
separate concepts; a signature authenticates the Store's reviewed artifact.
The Marketplace downloads and verifies packages in Rust, checks the catalog's final
SHA-256 and size, and cross-checks ID, version, publisher, permissions and signing
key. Public keys are pinned in the client; catalog metadata cannot add trusted keys.
The review dialog shows the verified package before installation, and installation
leaves the plugin disabled until its runtime permissions are separately granted.
Installed versions persist provenance; switching between local and Store sources
requires uninstalling first. Old registry records default to local provenance.
The Store lives in [`nyakang/nyaterm-plugins`](https://github.com/nyakang/nyaterm-plugins).
Its records and signing workflow are independent of author plugin source. First
rollout requires initializing the official key and configuring the protected
`plugin-signing` environment; an empty client trust store rejects every Store install.
The standalone [`@nyaterm/plugin-cli`](../plugins/cli/README.md) package bundles the
authoring binary, SDK and templates, with a workflow to assemble six platforms:
```sh
npm install -g @nyaterm/plugin-cli
nyaterm-plugin create example.hello ./hello
nyaterm-plugin pack ./hello ./hello-1.0.0.nyap
nyaterm-plugin inspect ./hello-1.0.0.nyap
```
Publication to npm is a separate release step. Distributed Rust templates use a
pinned public Git SDK dependency; source-checkout templates retain local paths.
Plugin files live in the active runtime's `plugins/` directory (normally
`~/.nyaterm/plugins/`):
+2
View File
@@ -6,6 +6,8 @@ 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.
- `cli/`: independently publishable `@nyaterm/plugin-cli`, bundling the authoring
binary, browser SDK and templates. See its README for standalone usage and release builds.
```sh
pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap
+3
View File
@@ -0,0 +1,3 @@
assets/
native/
*.tgz
+22
View File
@@ -0,0 +1,22 @@
# @nyaterm/plugin-cli
Standalone plugin authoring tools. The npm package bundles platform binaries, browser SDK and templates; it does not clone NyaTerm or compile Rust to pack/inspect UI plugins. Node 22+ is required. Native plugin development additionally requires Rust 1.94+.
```sh
npm install -g @nyaterm/plugin-cli
nyaterm-plugin create example.hello ./hello
nyaterm-plugin pack ./hello ./hello-1.0.0.nyap
nyaterm-plugin inspect ./hello-1.0.0.nyap
nyaterm-plugin create example.native ./native --template rust
```
Pack/inspect use the same Rust validator as the desktop installer. Packages are unsigned author candidates. Submit them through the Store review/signing process; never use an official signing key in an author repository. Rust templates consume the SDK through a pinned public Git commit.
For local packaging from NyaTerm source:
```sh
cargo build --release --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --bin nyaterm-plugin
npm pack ./plugins/cli
```
A local tarball contains the current platform binary. The **Build plugin CLI** workflow assembles all six supported binaries, checks the release completeness and produces a publishable npm tarball. Its optional publication job requires the `plugin-cli-publishing` environment and `NPM_TOKEN`; build-only runs do not publish. Use a reviewed platform commit and verify npm package name ownership before first publication.
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const [command, ...args] = process.argv.slice(2);
const os = { win32: "windows", darwin: "macos", linux: "linux" }[
process.platform
];
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
const binary = path.join(
root,
"native",
`nyaterm-plugin-${os}-${arch}${process.platform === "win32" ? ".exe" : ""}`,
);
const appVersion = JSON.parse(
readFileSync(path.join(root, "assets/package.json"), "utf8"),
).version;
let executable, parameters;
if (command === "create") {
executable = process.execPath;
parameters = [path.join(root, "assets/scripts/create-plugin.mjs"), ...args];
} else if (
(command === "pack" && [2, 3].includes(args.length)) ||
(command === "inspect" && [1, 2].includes(args.length))
) {
if (!os || !arch || !existsSync(binary))
throw new Error(
"This CLI package has no binary for your platform. Supported: Windows, Linux and macOS x86_64/aarch64.",
);
executable = binary;
parameters = [command, ...args];
if (args.length === (command === "pack" ? 2 : 1)) parameters.push(appVersion);
} else {
process.stderr.write(
"Usage: nyaterm-plugin create <publisher.plugin> <directory> [--template ui|rust]\n nyaterm-plugin pack <directory> <output.nyap> [app-version]\n nyaterm-plugin inspect <package.nyap> [app-version]\n",
);
process.exit(1);
}
const result = spawnSync(executable, parameters, {
stdio: "inherit",
windowsHide: true,
});
if (result.error) process.stderr.write(`${result.error.message}\n`);
process.exit(result.status ?? 1);
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@nyaterm/plugin-cli",
"version": "0.1.0",
"description": "Standalone NyaTerm plugin authoring CLI and templates",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/nyakang/nyaterm.git",
"directory": "plugins/cli"
},
"engines": {
"node": ">=22"
},
"bin": {
"nyaterm-plugin": "bin/nyaterm-plugin.mjs"
},
"files": [
"bin",
"assets",
"native",
"README.md"
],
"scripts": {
"prepack": "node scripts/prepare.mjs"
},
"publishConfig": {
"access": "public"
}
}
+73
View File
@@ -0,0 +1,73 @@
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
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 cli = path.join(root, "plugins/cli");
const assets = path.join(cli, "assets");
mkdirSync(path.join(assets, "scripts"), { recursive: true });
mkdirSync(path.join(assets, "plugins/sdk"), { recursive: true });
copyFileSync(
path.join(root, "scripts/create-plugin.mjs"),
path.join(assets, "scripts/create-plugin.mjs"),
);
for (const file of ["nyaterm.js", "nyaterm.d.ts"])
copyFileSync(
path.join(root, "plugins/sdk", file),
path.join(assets, "plugins/sdk", file),
);
cpSync(
path.join(root, "plugins/templates"),
path.join(assets, "plugins/templates"),
{ recursive: true },
);
writeFileSync(
path.join(assets, "package.json"),
`${JSON.stringify({ type: "module", version: JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version }, null, 2)}\n`,
);
chmodSync(path.join(cli, "bin/nyaterm-plugin.mjs"), 0o755);
const os = { win32: "windows", darwin: "macos", linux: "linux" }[
process.platform
];
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
const native = path.join(cli, "native");
mkdirSync(native, { recursive: true });
const name = `nyaterm-plugin-${os}-${arch}${process.platform === "win32" ? ".exe" : ""}`;
const compiled =
process.env.NYATERM_PLUGIN_BINARY ??
path.join(
root,
"src-tauri/crates/nyaterm-plugin-runtime/target/release",
`nyaterm-plugin${process.platform === "win32" ? ".exe" : ""}`,
);
if (existsSync(compiled)) {
copyFileSync(compiled, path.join(native, name));
} else if (!existsSync(path.join(native, name))) {
throw new Error(
"Build the native CLI first: cargo build --release --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --bin nyaterm-plugin",
);
}
for (const platform of [
"windows-x86_64",
"windows-aarch64",
"linux-x86_64",
"linux-aarch64",
"macos-x86_64",
"macos-aarch64",
]) {
const file = path.join(
native,
`nyaterm-plugin-${platform}${platform.startsWith("windows") ? ".exe" : ""}`,
);
if (process.env.NYATERM_CLI_RELEASE === "1" && !existsSync(file))
throw new Error(`Release is missing native binary: ${platform}`);
if (existsSync(file)) chmodSync(file, 0o755);
}
+11 -6
View File
@@ -40,10 +40,12 @@ if (existsSync(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 distributed = !existsSync(
path.join(root, "plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml"),
);
const sdkDependency = distributed
? '{ git = "https://github.com/nyakang/nyaterm", rev = "ac014d96672b5c33736bdc364be9c21b44ec95dc" }'
: `{ path = ${JSON.stringify(path.relative(target, path.join(root, "plugins/sdk/rust/nyaterm-plugin-sdk")).split(path.sep).join("/"))} }`;
const manifest = {
manifestVersion: 1,
id,
@@ -105,7 +107,7 @@ try {
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`,
`[package]\nname = "nyaterm-plugin-starter"\nversion = "1.0.0"\nedition = "2024"\nrust-version = "1.94"\n\n[dependencies]\nnyaterm-plugin-sdk = ${sdkDependency}\ntokio = { version = "1", features = ["rt", "macros"] }\n`,
);
put(
"src/main.rs",
@@ -117,9 +119,12 @@ try {
);
}
const packer = path.join(root, "scripts/package-plugin.mjs");
const packCommand = distributed
? `nyaterm-plugin pack . ../${id}.nyap`
: `node ${JSON.stringify(packer)} . ../${id}.nyap`;
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`,
`# ${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\n${packCommand}\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 standalone CLI pins the Rust SDK to a public Git commit; source-checkout templates use a local SDK dependency. 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) {
+7
View File
@@ -1428,6 +1428,7 @@ dependencies = [
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
@@ -1969,6 +1970,7 @@ version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
@@ -1990,8 +1992,10 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
@@ -4975,6 +4979,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"thiserror 2.0.20",
"time",
"tokio",
@@ -5022,6 +5027,8 @@ name = "nyaterm-plugin-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"base64 0.22.1",
"ed25519-dalek 2.2.0",
"hex",
"nyaterm-gpu",
"regex",
+1
View File
@@ -85,6 +85,7 @@ tauri-build = { version = "2", features = [] }
tokio = { version = "1", features = ["test-util"] }
[dependencies]
tempfile = "3"
nyaterm-gpu = { path = "crates/nyaterm-gpu" }
nyaterm-otp = { path = "crates/otp" }
nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" }
+181 -13
View File
@@ -19,9 +19,21 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -55,6 +67,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -83,6 +101,43 @@ dependencies = [
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -101,7 +156,31 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
@@ -126,6 +205,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -158,7 +243,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@@ -196,6 +281,17 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -403,6 +499,8 @@ name = "nyaterm-plugin-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"ed25519-dalek",
"hex",
"nyaterm-gpu",
"nyaterm-plugin-sdk",
@@ -451,6 +549,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "potential_utf"
version = "0.1.6"
@@ -484,6 +592,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -513,6 +630,15 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.5"
@@ -565,7 +691,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@@ -602,6 +728,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -620,12 +755,39 @@ version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
@@ -645,7 +807,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@@ -655,7 +817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
@@ -678,7 +840,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@@ -714,7 +876,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@@ -773,7 +935,7 @@ version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom",
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
@@ -822,7 +984,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
"wasm-bindgen-shared",
]
@@ -875,7 +1037,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
"synstructure",
]
@@ -896,10 +1058,16 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
@@ -930,7 +1098,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@@ -5,6 +5,8 @@ edition = "2024"
rust-version = "1.94"
[dependencies]
base64 = "0.22"
ed25519-dalek = "2"
nyaterm-gpu = { path = "../nyaterm-gpu" }
async-trait = "0.1"
hex = "0.4"
@@ -29,7 +29,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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::<usize>() > 256 * 1024 * 1024 { return Err("Plugin package exceeds file count or expanded size limit".into()); }
if files.len() > 1022 || files.values().map(Vec::len).sum::<usize>() > 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)?;
@@ -83,7 +83,7 @@ fn collect(
.ok_or("Non-UTF-8 plugin path")?
.replace('\\', "/");
validate_path(&relative)?;
if files.len() >= 1023 || metadata.len() > 64 * 1024 * 1024 {
if files.len() >= 1022 || metadata.len() > 64 * 1024 * 1024 {
return Err("Plugin source is too large".into());
}
if files.values().map(Vec::len).sum::<usize>() + metadata.len() as usize > 256 * 1024 * 1024
@@ -1,11 +1,13 @@
//! Tauri-independent installation, manifest validation and sidecar transport.
pub mod diagnostics;
pub mod manifest;
pub mod marketplace;
pub mod monitoring;
pub mod package;
pub mod probe;
pub mod registry;
pub mod sidecar;
pub mod trust;
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -0,0 +1,525 @@
//! Catalog protocol and cross-validation; network I/O belongs to the host adapter.
use crate::manifest::{valid_id, validate_permission};
use crate::package::{MAX_PACKAGE_BYTES, PreparedPackage};
use crate::registry::Provenance;
use crate::trust::{SignatureStatus, inspect};
use crate::{Result, invalid};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub const REPOSITORY_ID: &str = "nyaterm-official";
pub const CATALOG_URL: &str =
"https://raw.githubusercontent.com/nyakang/nyaterm-plugins/main/catalog/index.json";
pub const MAX_CATALOG_BYTES: usize = 4 * 1024 * 1024;
pub const TARGETS: &[&str] = &[
"universal",
"windows-x86_64",
"windows-aarch64",
"linux-x86_64",
"linux-aarch64",
"macos-x86_64",
"macos-aarch64",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Repository {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Catalog {
pub catalog_version: u32,
pub repository: Repository,
pub generated_at: String,
pub plugins: Vec<CatalogPlugin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CatalogPlugin {
pub id: String,
pub name: String,
pub description: String,
pub publisher: String,
pub verified: bool,
pub tags: Vec<String>,
pub source: String,
pub homepage: String,
pub license: String,
pub permissions: Vec<String>,
pub latest_version: String,
pub versions: Vec<CatalogVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CatalogVersion {
pub version: String,
pub released_at: String,
pub release_notes: String,
// Version-scoped permissions allow safe permission changes on future upgrades.
pub permissions: Vec<String>,
pub artifacts: Vec<Artifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Artifact {
pub target: String,
pub url: String,
pub sha256: String,
pub size: u64,
pub signing_key_id: String,
}
pub fn https_url(value: &str) -> Result<url::Url> {
let url = url::Url::parse(value).map_err(|_| invalid("Invalid Store URL"))?;
if value.len() > 4096
|| url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(invalid(
"Store URLs must use HTTPS without credentials or fragments",
));
}
Ok(url)
}
fn permissions(values: &[String]) -> Result<()> {
if values.len() > 24 || values.iter().collect::<HashSet<_>>().len() != values.len() {
return Err(invalid("Invalid catalog permissions"));
}
for value in values {
validate_permission(value)?;
}
Ok(())
}
impl Catalog {
pub fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() > MAX_CATALOG_BYTES {
return Err(invalid("Store catalog is too large"));
}
let catalog: Self = serde_json::from_slice(bytes)?;
if catalog.catalog_version != 1
|| catalog.repository.id != REPOSITORY_ID
|| catalog.plugins.len() > 2048
{
return Err(invalid("Unsupported Store catalog"));
}
let mut ids = HashSet::new();
for plugin in &catalog.plugins {
if !valid_id(&plugin.id)
|| !plugin.id.contains('.')
|| !ids.insert(&plugin.id)
|| plugin.name.trim().is_empty()
|| plugin.name.len() > 120
|| plugin.description.len() > 2000
|| plugin.publisher.trim().is_empty()
|| plugin.publisher.len() > 120
|| plugin.versions.is_empty()
|| plugin.versions.len() > 128
{
return Err(invalid("Invalid Store plugin metadata"));
}
https_url(&plugin.source)?;
https_url(&plugin.homepage)?;
permissions(&plugin.permissions)?;
let mut versions = HashSet::new();
for version in &plugin.versions {
let parsed = semver::Version::parse(&version.version)
.map_err(|_| invalid("Invalid Store version"))?;
if parsed.to_string() != version.version
|| version.version.len() > 80
|| !versions.insert(&version.version)
|| version.artifacts.is_empty()
|| version.artifacts.len() > TARGETS.len()
{
return Err(invalid("Invalid or duplicate Store version"));
}
permissions(&version.permissions)?;
let mut targets = HashSet::new();
for artifact in &version.artifacts {
https_url(&artifact.url)?;
if !TARGETS.contains(&artifact.target.as_str())
|| !targets.insert(&artifact.target)
|| artifact.size == 0
|| artifact.size > MAX_PACKAGE_BYTES
|| artifact.sha256.len() != 64
|| !artifact
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| !valid_id(&artifact.signing_key_id)
{
return Err(invalid("Invalid Store artifact"));
}
}
}
let latest = plugin
.versions
.iter()
.find(|v| v.version == plugin.latest_version)
.ok_or_else(|| invalid("Store latest version does not exist"))?;
if latest.permissions != plugin.permissions {
return Err(invalid("Latest Store permissions disagree"));
}
}
Ok(catalog)
}
pub fn select(
&self,
id: &str,
version: &str,
target: &str,
) -> Result<(&CatalogPlugin, &CatalogVersion, &Artifact)> {
let plugin = self
.plugins
.iter()
.find(|p| p.id == id)
.ok_or_else(|| invalid("Plugin is not in the Store"))?;
let version = plugin
.versions
.iter()
.find(|v| v.version == version)
.ok_or_else(|| invalid("Version is not in the Store"))?;
let artifact = version
.artifacts
.iter()
.find(|a| a.target == target)
.or_else(|| version.artifacts.iter().find(|a| a.target == "universal"))
.ok_or_else(|| invalid("Plugin does not support this platform"))?;
Ok((plugin, version, artifact))
}
}
pub fn current_target() -> String {
format!(
"{}-{}",
if cfg!(target_os = "macos") {
"macos"
} else {
std::env::consts::OS
},
std::env::consts::ARCH
)
}
pub fn validate_package(
package: &mut PreparedPackage,
plugin: &CatalogPlugin,
version: &CatalogVersion,
artifact: &Artifact,
size: u64,
keys: &[(&str, &str)],
) -> Result<Provenance> {
if size != artifact.size || package.preview.digest != artifact.sha256 {
return Err(invalid(
"Downloaded Store package size or SHA-256 differs from catalog",
));
}
let status = inspect(package.directory.path(), keys)?;
if status
!= (SignatureStatus::Verified {
key_id: artifact.signing_key_id.clone(),
})
{
return Err(invalid(
"Store package requires the catalog's trusted signature",
));
}
let manifest = &package.preview.manifest;
let mut requested = manifest.permissions.clone();
requested.sort();
let mut advertised = version.permissions.clone();
advertised.sort();
if manifest.id != plugin.id
|| manifest.version != version.version
|| manifest.publisher != plugin.publisher
|| requested != advertised
{
return Err(invalid(
"Store package identity or permissions differ from catalog",
));
}
if let Some(backend) = &manifest.backend {
let target = if artifact.target == "universal" {
current_target()
} else {
artifact.target.clone()
};
if !backend.executables.contains_key(&target) {
return Err(invalid("Store package has no backend for its target"));
}
}
package.preview.signature = status;
let provenance = Provenance::Marketplace {
repository_id: REPOSITORY_ID.into(),
publisher: plugin.publisher.clone(),
signing_key_id: artifact.signing_key_id.clone(),
package_sha256: artifact.sha256.clone(),
};
package.provenance = provenance.clone();
Ok(provenance)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signer, SigningKey};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
fn fixture(signed: bool) -> (tempfile::TempDir, PreparedPackage, CatalogPlugin, String) {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("package.nyap");
let manifest = br#"{"manifestVersion":1,"id":"example.tools","name":"Tools","version":"1.0.0","description":"test","publisher":"example","engine":">=1.0.0","permissions":["session.read"]}"#;
let checksums = BTreeMap::from([
("manifest.json", hex::encode(Sha256::digest(manifest))),
(
"ui/index.html",
hex::encode(Sha256::digest(b"<p>Hello</p>")),
),
]);
let key = SigningKey::from_bytes(&[42; 32]);
let document = serde_json::to_vec(&crate::trust::PackageSignature {
algorithm: "ed25519".into(),
key_id: "test".into(),
signature: STANDARD.encode(
key.sign(&serde_json::to_vec(&checksums).unwrap())
.to_bytes(),
),
})
.unwrap();
let extra = [("signature.json", document.as_slice())];
crate::package::tests::package_manifest(&path, manifest, if signed { &extra } else { &[] });
let package = crate::package::prepare(&path, temp.path(), "1.2.12").unwrap();
let plugin = CatalogPlugin {
id: "example.tools".into(),
name: "Tools".into(),
description: "test".into(),
publisher: "example".into(),
verified: false,
tags: vec![],
source: "https://github.com/example/tools".into(),
homepage: "https://github.com/example/tools".into(),
license: "MIT".into(),
permissions: vec!["session.read".into()],
latest_version: "1.0.0".into(),
versions: vec![CatalogVersion {
version: "1.0.0".into(),
released_at: "2026-10-03T00:00:00Z".into(),
release_notes: "test".into(),
permissions: vec!["session.read".into()],
artifacts: vec![Artifact {
target: "universal".into(),
url: "https://example.com/package.nyap".into(),
sha256: package.preview.digest.clone(),
size: std::fs::metadata(path).unwrap().len(),
signing_key_id: "test".into(),
}],
}],
};
(
temp,
package,
plugin,
STANDARD.encode(key.verifying_key().to_bytes()),
)
}
#[test]
fn signed_store_install_is_disabled_and_provenance_survives_restart() {
let (temp, mut package, plugin, public) = fixture(true);
let version = &plugin.versions[0];
let artifact = &version.artifacts[0];
validate_package(
&mut package,
&plugin,
version,
artifact,
artifact.size,
&[("test", &public)],
)
.unwrap();
let root = temp.path().join("installed");
let mut registry = crate::registry::Registry::open(root.clone(), "1.2.12".into()).unwrap();
let installed = registry.install(package, &artifact.sha256).unwrap();
assert!(!installed.enabled);
assert!(matches!(
installed.active().unwrap().provenance,
Provenance::Marketplace { .. }
));
let mut registry = crate::registry::Registry::open(root, "1.2.12".into()).unwrap();
assert!(matches!(
registry
.get("example.tools")
.unwrap()
.active()
.unwrap()
.provenance,
Provenance::Marketplace { .. }
));
let (_local_temp, local, _, _) = fixture(false);
let digest = local.preview.digest.clone();
assert!(registry.install(local, &digest).is_err());
// The opposite source switch also requires an explicit uninstall.
let (temp, local, _, _) = fixture(false);
let root = temp.path().join("local");
let mut registry = crate::registry::Registry::open(root, "1.2.12".into()).unwrap();
let digest = local.preview.digest.clone();
registry.install(local, &digest).unwrap();
let (_temp, mut package, plugin, public) = fixture(true);
let version = &plugin.versions[0];
let artifact = &version.artifacts[0];
validate_package(
&mut package,
&plugin,
version,
artifact,
artifact.size,
&[("test", &public)],
)
.unwrap();
assert!(registry.install(package, &artifact.sha256).is_err());
}
#[test]
fn store_requires_signature_and_cross_validates_all_security_metadata() {
let (_temp, mut package, plugin, public) = fixture(true);
let version = &plugin.versions[0];
let artifact = &version.artifacts[0];
let keys = [("test", public.as_str())];
assert!(
validate_package(&mut package, &plugin, version, artifact, artifact.size, &[]).is_err()
);
assert!(
validate_package(
&mut package,
&plugin,
version,
artifact,
artifact.size - 1,
&keys
)
.is_err()
);
let mut wrong = artifact.clone();
wrong.sha256 = "0".repeat(64);
assert!(
validate_package(&mut package, &plugin, version, &wrong, artifact.size, &keys).is_err()
);
wrong = artifact.clone();
wrong.signing_key_id = "other".into();
assert!(
validate_package(&mut package, &plugin, version, &wrong, artifact.size, &keys).is_err()
);
for (id, publisher) in [("example.other", "example"), ("example.tools", "attacker")] {
let mut wrong = plugin.clone();
wrong.id = id.into();
wrong.publisher = publisher.into();
assert!(
validate_package(
&mut package,
&wrong,
version,
artifact,
artifact.size,
&keys
)
.is_err()
);
}
let mut wrong = version.clone();
wrong.permissions.push("native".into());
assert!(
validate_package(
&mut package,
&plugin,
&wrong,
artifact,
artifact.size,
&keys
)
.is_err()
);
wrong = version.clone();
wrong.version = "2.0.0".into();
assert!(
validate_package(
&mut package,
&plugin,
&wrong,
artifact,
artifact.size,
&keys
)
.is_err()
);
let (_temp, mut unsigned, mut metadata, _) = fixture(false);
metadata.versions[0].artifacts[0].sha256 = unsigned.preview.digest.clone();
let version = &metadata.versions[0];
let artifact = &version.artifacts[0];
assert!(
validate_package(
&mut unsigned,
&metadata,
version,
artifact,
artifact.size,
&keys
)
.is_err()
);
}
#[test]
fn selects_exact_target_before_universal_and_rejects_bad_catalogs() {
let (_temp, _, mut plugin, _) = fixture(true);
let mut exact = plugin.versions[0].artifacts[0].clone();
exact.target = "windows-x86_64".into();
plugin.versions[0].artifacts.push(exact);
let mut catalog = Catalog {
catalog_version: 1,
repository: Repository {
id: REPOSITORY_ID.into(),
name: "Store".into(),
},
generated_at: "2026-10-03T00:00:00Z".into(),
plugins: vec![plugin],
};
Catalog::parse(&serde_json::to_vec(&catalog).unwrap()).unwrap();
assert_eq!(
catalog
.select("example.tools", "1.0.0", "windows-x86_64")
.unwrap()
.2
.target,
"windows-x86_64"
);
assert_eq!(
catalog
.select("example.tools", "1.0.0", "linux-aarch64")
.unwrap()
.2
.target,
"universal"
);
assert!(
catalog
.select("example.tools", "2.0.0", "linux-aarch64")
.is_err()
);
catalog.plugins[0].versions[0].artifacts[0].url = "http://example.com/package".into();
assert!(Catalog::parse(&serde_json::to_vec(&catalog).unwrap()).is_err());
catalog.plugins[0].versions[0].artifacts[0].url = "https://example.com/package".into();
catalog.plugins[0].versions[0]
.permissions
.push("native".into());
assert!(Catalog::parse(&serde_json::to_vec(&catalog).unwrap()).is_err());
}
}
@@ -19,11 +19,13 @@ pub struct PackagePreview {
pub digest: String,
pub expanded_bytes: u64,
pub probe_scripts: BTreeMap<String, String>,
pub signature: crate::trust::SignatureStatus,
}
pub struct PreparedPackage {
pub preview: PackagePreview,
pub directory: tempfile::TempDir,
pub(crate) provenance: crate::registry::Provenance,
}
/// Inspect and extract into a private staging directory. Nothing becomes active here.
@@ -99,7 +101,10 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result<
if name == "checksums.json" && bytes.len() > 256 * 1024 {
return Err(invalid("Checksums document is too large"));
}
if name != "checksums.json" {
if name == "signature.json" && bytes.len() > 4096 {
return Err(invalid("Signature document is too large"));
}
if name != "checksums.json" && name != "signature.json" {
hashes.insert(name.clone(), hex::encode(Sha256::digest(&bytes)));
}
if let Some(parent) = target.parent() {
@@ -114,6 +119,7 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result<
"Plugin package checksums do not match its complete contents",
));
}
let signature = crate::trust::inspect(directory.path(), crate::trust::OFFICIAL_PLUGIN_KEYS)?;
let manifest = Manifest::parse(
&std::fs::read(directory.path().join("manifest.json"))?,
app_version,
@@ -146,11 +152,13 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result<
}
}
Ok(PreparedPackage {
provenance: crate::registry::Provenance::Local,
preview: PackagePreview {
manifest,
digest: package_digest,
expanded_bytes,
probe_scripts,
signature,
},
directory,
})
@@ -188,6 +196,7 @@ pub(crate) mod tests {
entries.extend_from_slice(extra);
let checksums = entries
.iter()
.filter(|(name, _)| *name != "signature.json")
.map(|(name, bytes)| (name.to_string(), hex::encode(Sha256::digest(bytes))))
.collect::<BTreeMap<_, _>>();
let mut zip = zip::ZipWriter::new(File::create(path).unwrap());
@@ -6,11 +6,52 @@ use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "source", rename_all = "camelCase", deny_unknown_fields)]
pub enum Provenance {
#[default]
Local,
Marketplace {
#[serde(rename = "repositoryId")]
repository_id: String,
publisher: String,
#[serde(rename = "signingKeyId")]
signing_key_id: String,
#[serde(rename = "packageSha256")]
package_sha256: String,
},
}
impl Provenance {
fn continuous_with(&self, other: &Self) -> bool {
match (self, other) {
(Self::Local, Self::Local) => true,
(
Self::Marketplace {
repository_id: a,
publisher: p,
..
},
Self::Marketplace {
repository_id: b,
publisher: q,
..
},
) => a == b && p == q,
_ => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledVersion {
pub manifest: Manifest,
pub digest: String,
#[serde(default)]
pub provenance: Provenance,
#[serde(default)]
pub signature: crate::trust::SignatureStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -91,6 +132,23 @@ impl Registry {
if record.manifest.id != *id || record.manifest.version != *version {
return Err(invalid("Invalid plugin version record"));
}
if let Provenance::Marketplace {
repository_id,
publisher,
signing_key_id,
package_sha256,
} = &record.provenance
&& (repository_id != crate::marketplace::REPOSITORY_ID
|| publisher != &record.manifest.publisher
|| package_sha256 != &record.digest
|| !valid_id(signing_key_id)
|| record.signature
!= (crate::trust::SignatureStatus::Verified {
key_id: signing_key_id.clone(),
}))
{
return Err(invalid("Invalid marketplace provenance record"));
}
}
}
Ok(Self {
@@ -153,6 +211,8 @@ impl Registry {
let record = InstalledVersion {
manifest: package.preview.manifest.clone(),
digest: package.preview.digest.clone(),
provenance: package.provenance.clone(),
signature: package.preview.signature.clone(),
};
let id = record.manifest.id.clone();
let version = record.manifest.version.clone();
@@ -175,9 +235,18 @@ impl Registry {
"Plugin publisher changed; uninstall before installing from a new publisher",
));
}
if plugin
.versions
.values()
.any(|v| !v.provenance.continuous_with(&record.provenance))
{
return Err(invalid(
"Plugin source changed; uninstall before installing from a different repository or local source",
));
}
let mut moved_target = None;
if let Some(existing) = plugin.versions.get(&version) {
if existing.digest != record.digest {
if existing.digest != record.digest || existing.provenance != record.provenance {
return Err(invalid(
"An installed version cannot be replaced by different content",
));
@@ -324,6 +393,37 @@ mod tests {
use super::*;
use crate::package::{prepare, tests::package};
#[test]
fn legacy_records_migrate_to_local_provenance() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("plugins");
let path = temp.path().join("test.nyap");
package(&path, &[]);
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();
registry.install(prepared, &digest).unwrap();
let index = root.join("registry.json");
let mut json: serde_json::Value =
serde_json::from_slice(&std::fs::read(&index).unwrap()).unwrap();
let record = json["example.tools"]["versions"]["1.0.0"]
.as_object_mut()
.unwrap();
record.remove("provenance");
record.remove("signature");
std::fs::write(index, serde_json::to_vec(&json).unwrap()).unwrap();
let migrated = Registry::open(root, "1.2.12".into()).unwrap();
assert_eq!(
migrated
.get("example.tools")
.unwrap()
.active()
.unwrap()
.provenance,
Provenance::Local
);
}
#[test]
fn updates_rollback_and_revocation_require_fresh_activation() {
use crate::package::tests::package_manifest;
@@ -0,0 +1,114 @@
//! Repository keys are pinned in the client. Catalogs cannot extend this trust store.
use crate::{Result, invalid};
use base64::{Engine, engine::general_purpose::STANDARD};
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const OFFICIAL_PLUGIN_KEYS: &[(&str, &str)] = &[];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PackageSignature {
pub algorithm: String,
pub key_id: String,
pub signature: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum SignatureStatus {
#[default]
Unsigned,
Untrusted {
#[serde(rename = "keyId")]
key_id: String,
},
Verified {
#[serde(rename = "keyId")]
key_id: String,
},
}
pub fn verify(bytes: &[u8], signature: &PackageSignature, keys: &[(&str, &str)]) -> Result<()> {
if signature.algorithm != "ed25519" {
return Err(invalid("Unsupported plugin signature algorithm"));
}
let public = keys
.iter()
.find(|(id, _)| *id == signature.key_id)
.ok_or_else(|| invalid("Plugin signing key is not trusted"))?
.1;
let public: [u8; 32] = STANDARD
.decode(public)
.map_err(|_| invalid("Invalid trusted public key"))?
.try_into()
.map_err(|_| invalid("Invalid trusted public key length"))?;
let key =
VerifyingKey::from_bytes(&public).map_err(|_| invalid("Invalid trusted public key"))?;
let raw = STANDARD
.decode(&signature.signature)
.map_err(|_| invalid("Invalid plugin signature encoding"))?;
let signature =
Signature::from_slice(&raw).map_err(|_| invalid("Invalid plugin signature length"))?;
key.verify_strict(bytes, &signature)
.map_err(|_| invalid("Plugin signature verification failed"))
}
pub fn inspect(root: &Path, keys: &[(&str, &str)]) -> Result<SignatureStatus> {
let path = root.join("signature.json");
if !path.exists() {
return Ok(SignatureStatus::Unsigned);
}
let signature: PackageSignature = serde_json::from_slice(&std::fs::read(path)?)?;
if signature.algorithm != "ed25519"
|| signature.key_id.is_empty()
|| signature.key_id.len() > 128
{
return Err(invalid("Invalid plugin signature metadata"));
}
let raw = STANDARD
.decode(&signature.signature)
.map_err(|_| invalid("Invalid plugin signature encoding"))?;
if raw.len() != 64 {
return Err(invalid("Invalid plugin signature length"));
}
if !keys.iter().any(|(id, _)| *id == signature.key_id) {
return Ok(SignatureStatus::Untrusted {
key_id: signature.key_id,
});
}
verify(
&std::fs::read(root.join("checksums.json"))?,
&signature,
keys,
)?;
Ok(SignatureStatus::Verified {
key_id: signature.key_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
#[test]
fn verifies_exact_bytes_and_rejects_wrong_key_algorithm_and_signature() {
let key = SigningKey::from_bytes(&[42; 32]);
let public = STANDARD.encode(key.verifying_key().to_bytes());
let keys = [("test", public.as_str())];
let mut signature = PackageSignature {
algorithm: "ed25519".into(),
key_id: "test".into(),
signature: STANDARD.encode(key.sign(b"checksums").to_bytes()),
};
verify(b"checksums", &signature, &keys).unwrap();
assert!(verify(b"checksums\n", &signature, &keys).is_err());
assert!(verify(b"checksums", &signature, &[]).is_err());
signature.algorithm = "rsa".into();
assert!(verify(b"checksums", &signature, &keys).is_err());
signature.algorithm = "ed25519".into();
signature.signature = STANDARD.encode([0; 64]);
assert!(verify(b"checksums", &signature, &keys).is_err());
}
}
+44
View File
@@ -6,6 +6,50 @@ use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
#[tauri::command]
pub async fn get_plugin_marketplace(
manager: tauri::State<'_, Arc<PluginManager>>,
) -> AppResult<crate::core::plugins::MarketplaceCatalog> {
manager.marketplace_catalog().await
}
#[tauri::command]
pub async fn inspect_marketplace_plugin(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
version: String,
expected_sha256: String,
) -> AppResult<crate::core::plugins::MarketplacePreview> {
manager
.inspect_marketplace(&app, window.label(), &plugin_id, &version, &expected_sha256)
.await
}
#[tauri::command]
pub async fn install_marketplace_plugin(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
) -> AppResult<InstalledPlugin> {
manager
.install_marketplace(&app, window.label(), &token)
.await
}
#[tauri::command]
pub async fn cancel_marketplace_plugin_review(
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
) -> AppResult<()> {
manager
.cancel_marketplace_review(window.label(), &token)
.await
}
#[tauri::command]
pub async fn list_plugins(
manager: tauri::State<'_, Arc<PluginManager>>,
+247
View File
@@ -0,0 +1,247 @@
use super::{PluginManager, ensure_unlocked, plugin_error};
use crate::error::{AppError, AppResult};
use nyaterm_plugin_runtime::marketplace::{self, Artifact, Catalog, CatalogPlugin, CatalogVersion};
use nyaterm_plugin_runtime::package::{PackagePreview, PreparedPackage, prepare};
use nyaterm_plugin_runtime::registry::InstalledPlugin;
use nyaterm_plugin_runtime::trust::OFFICIAL_PLUGIN_KEYS;
use serde::Serialize;
use std::time::{Duration, Instant};
use tauri::Emitter;
use tokio::io::AsyncWriteExt;
pub(super) struct Review {
pub window_label: String,
expires: Instant,
package: PreparedPackage,
artifact: Artifact,
plugin: CatalogPlugin,
version: CatalogVersion,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceCatalog {
catalog: Catalog,
target: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplacePreview {
token: String,
package: PackagePreview,
repository_id: &'static str,
}
fn network_error(error: impl std::fmt::Display) -> AppError {
AppError::Config(format!("Plugin Store: {error}"))
}
fn client() -> AppResult<reqwest::Client> {
reqwest::Client::builder()
.https_only(true)
.user_agent(concat!("NyaTerm/", env!("CARGO_PKG_VERSION")))
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_mins(2))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(network_error)
}
async fn catalog() -> AppResult<Catalog> {
let mut response = client()?
.get(marketplace::CATALOG_URL)
.send()
.await
.map_err(network_error)?
.error_for_status()
.map_err(network_error)?;
if response
.content_length()
.is_some_and(|n| n > marketplace::MAX_CATALOG_BYTES as u64)
{
return Err(network_error("Catalog exceeds size limit"));
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(network_error)? {
if bytes.len() + chunk.len() > marketplace::MAX_CATALOG_BYTES {
return Err(network_error("Catalog exceeds size limit"));
}
bytes.extend_from_slice(&chunk);
}
Catalog::parse(&bytes).map_err(plugin_error)
}
fn main_window(label: &str) -> AppResult<()> {
if !crate::window_state::is_main_window_label(label) {
return Err(network_error("Store installs require a main window"));
}
Ok(())
}
impl PluginManager {
pub async fn marketplace_catalog(&self) -> AppResult<MarketplaceCatalog> {
Ok(MarketplaceCatalog {
catalog: catalog().await?,
target: marketplace::current_target(),
})
}
pub async fn inspect_marketplace(
&self,
app: &tauri::AppHandle,
window: &str,
id: &str,
version: &str,
expected_sha256: &str,
) -> AppResult<MarketplacePreview> {
ensure_unlocked(app)?;
main_window(window)?;
// Serialize downloads so the package and pending-review quotas also bound memory/disk usage.
let mut reviews = self.marketplace_reviews.lock().await;
reviews.retain(|_, review| review.expires > Instant::now());
if reviews.len() >= 8 {
return Err(network_error("Too many pending Store reviews"));
}
let catalog = catalog().await?;
let (plugin, version, artifact) = catalog
.select(id, version, &marketplace::current_target())
.map_err(plugin_error)?;
if artifact.sha256 != expected_sha256 {
return Err(network_error("Catalog changed; refresh and review again"));
}
if !OFFICIAL_PLUGIN_KEYS
.iter()
.any(|(id, _)| *id == artifact.signing_key_id)
{
return Err(network_error(
"Store signing key is not trusted by this NyaTerm version",
));
}
marketplace::https_url(&artifact.url).map_err(plugin_error)?;
std::fs::create_dir_all(&self.root).map_err(network_error)?;
let download = tempfile::NamedTempFile::new_in(&self.root).map_err(network_error)?;
let mut output = tokio::fs::File::from_std(download.reopen().map_err(network_error)?);
let mut response = client()?
.get(&artifact.url)
.send()
.await
.map_err(network_error)?
.error_for_status()
.map_err(network_error)?;
if response
.content_length()
.is_some_and(|n| n != artifact.size)
{
return Err(network_error("Store package size mismatch"));
}
let mut size = 0_u64;
while let Some(chunk) = response.chunk().await.map_err(network_error)? {
size = size
.checked_add(chunk.len() as u64)
.ok_or_else(|| network_error("Package size overflow"))?;
if size > artifact.size {
return Err(network_error("Store download exceeds catalog size"));
}
output.write_all(&chunk).await.map_err(network_error)?;
}
output.flush().await.map_err(network_error)?;
drop(output);
let root = self.root.clone();
let app_version = self.app_version.clone();
let p = plugin.clone();
let v = version.clone();
let a = artifact.clone();
let package = tokio::task::spawn_blocking(move || {
let mut package = prepare(download.path(), &root, &app_version)?;
marketplace::validate_package(&mut package, &p, &v, &a, size, OFFICIAL_PLUGIN_KEYS)?;
Ok::<_, nyaterm_plugin_runtime::Error>(package)
})
.await
.map_err(network_error)?
.map_err(plugin_error)?;
ensure_unlocked(app)?;
let token = uuid::Uuid::new_v4().to_string();
let preview = MarketplacePreview {
token: token.clone(),
package: package.preview.clone(),
repository_id: marketplace::REPOSITORY_ID,
};
reviews.insert(
token,
Review {
window_label: window.into(),
expires: Instant::now() + Duration::from_mins(10),
package,
artifact: artifact.clone(),
plugin: plugin.clone(),
version: version.clone(),
},
);
Ok(preview)
}
pub async fn cancel_marketplace_review(&self, window: &str, token: &str) -> AppResult<()> {
let mut reviews = self.marketplace_reviews.lock().await;
if reviews
.get(token)
.is_some_and(|review| review.window_label != window)
{
return Err(network_error("Review belongs to another window"));
}
reviews.remove(token);
Ok(())
}
pub async fn install_marketplace(
&self,
app: &tauri::AppHandle,
window: &str,
token: &str,
) -> AppResult<InstalledPlugin> {
ensure_unlocked(app)?;
main_window(window)?;
let review = {
let mut reviews = self.marketplace_reviews.lock().await;
if reviews.get(token).is_none_or(|review| {
review.window_label != window || review.expires <= Instant::now()
}) {
return Err(network_error(
"Store review expired or belongs to another window",
));
}
reviews.remove(token).unwrap()
};
// A removed/changed artifact cannot be installed from an old review.
let current = catalog().await?;
let (plugin, version, artifact) = current
.select(
&review.plugin.id,
&review.version.version,
&marketplace::current_target(),
)
.map_err(plugin_error)?;
if artifact.sha256 != review.artifact.sha256
|| artifact.signing_key_id != review.artifact.signing_key_id
|| version.permissions != review.version.permissions
|| plugin.publisher != review.plugin.publisher
{
return Err(network_error(
"Store metadata changed; review the package again",
));
}
let id = review.plugin.id;
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(&id, true)?;
self.revoke_plugin(&id).await;
let mut registry = self.registry.write().await;
ensure_unlocked(app)?;
let result = registry
.as_mut()
.ok_or_else(|| self.registry_error())?
.install(review.package, &review.artifact.sha256)
.map_err(plugin_error)?;
let _ = app.emit("plugins-changed", ());
Ok(result)
}
}
+9
View File
@@ -1,5 +1,7 @@
mod gateway;
mod lifecycle;
mod marketplace;
pub use marketplace::{MarketplaceCatalog, MarketplacePreview};
mod monitoring;
pub use monitoring::MonitorSubscription;
mod probe;
@@ -101,6 +103,7 @@ pub struct PluginManager {
storage_lock: Mutex<()>,
diagnostics: Diagnostics,
monitors: std::sync::Mutex<monitoring::Monitors>,
marketplace_reviews: Mutex<HashMap<String, marketplace::Review>>,
}
impl PluginManager {
@@ -121,6 +124,7 @@ impl PluginManager {
storage_lock: Mutex::new(()),
diagnostics: Diagnostics::default(),
monitors: std::sync::Mutex::new(monitoring::Monitors::default()),
marketplace_reviews: Mutex::new(HashMap::new()),
})
}
@@ -320,6 +324,10 @@ impl PluginManager {
}
pub async fn revoke_window(&self, label: &str) {
self.marketplace_reviews
.lock()
.await
.retain(|_, review| review.window_label != label);
self.monitors.lock().unwrap().revoke(|k| k.window == label);
self.scopes.lock().await.retain(|_, scope| {
if scope.window_label == label {
@@ -351,6 +359,7 @@ impl PluginManager {
}
pub async fn revoke_all(&self) {
self.marketplace_reviews.lock().await.clear();
self.monitors.lock().unwrap().revoke(|_| true);
for (_, scope) in self.scopes.lock().await.drain() {
scope.cancellation.cancel();
+4
View File
@@ -164,6 +164,10 @@ pub fn run() {
cmd::plugins::clear_plugin_logs,
cmd::plugins::stop_plugin_backend,
cmd::plugins::list_plugins,
cmd::plugins::get_plugin_marketplace,
cmd::plugins::inspect_marketplace_plugin,
cmd::plugins::install_marketplace_plugin,
cmd::plugins::cancel_marketplace_plugin_review,
cmd::plugins::inspect_plugin_package,
cmd::plugins::install_plugin_package,
cmd::plugins::configure_plugin,
+96
View File
@@ -0,0 +1,96 @@
import { openUrl } from "@tauri-apps/plugin-opener";
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,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getErrorMessage } from "@/lib/errors";
import type { MarketplacePlugin, MarketplacePreview } from "@/types/plugins";
export function PluginDetails({
plugin,
preview,
busy,
locked,
onCancel,
onInstall,
}: {
plugin: MarketplacePlugin;
preview: MarketplacePreview;
busy: boolean;
locked: boolean;
onCancel: () => void;
onInstall: () => void;
}) {
const { t } = useTranslation();
const { manifest, signature } = preview.package;
const version = plugin.versions.find((v) => v.version === manifest.version);
return (
<Dialog open onOpenChange={(open) => !open && !busy && onCancel()}>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{manifest.name} · {manifest.version}
</DialogTitle>
<DialogDescription>{t("plugins.reviewDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>{manifest.description}</p>
<p>
{t("plugins.publisher", { name: manifest.publisher })}{" "}
{plugin.verified && (
<Badge variant="secondary">{t("plugins.store.publisherVerified")}</Badge>
)}
</p>
<p>
{t("plugins.store.repository")}: {t("plugins.store.official")}
</p>
<Badge variant="outline">{t(`plugins.store.${signature.status}`)}</Badge>
<Button
variant="link"
className="h-auto max-w-full whitespace-normal break-all p-0 text-left"
onClick={() =>
void openUrl(plugin.source).catch((error) => toast.error(getErrorMessage(error)))
}
>
{t("plugins.store.source")}: {plugin.source}
</Button>
<p className="text-muted-foreground">{plugin.license}</p>
<div className="space-y-1">
<p className="font-medium">{t("plugins.permissionsTitle")}</p>
{manifest.permissions.map((permission) => (
<p key={permission}>
{permission.startsWith("network:")
? t("plugins.permissions.network", { origin: permission.slice(8) })
: t(`plugins.permissions.${permission.split(".").join("_")}`)}
</p>
))}
</div>
{manifest.backend && (
<p className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
{t("plugins.nativeTrust")}
</p>
)}
{version?.releaseNotes && (
<p className="whitespace-pre-wrap text-muted-foreground">{version.releaseNotes}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onCancel}>
{t("plugins.cancel")}
</Button>
<Button disabled={busy || locked || signature.status !== "verified"} onClick={onInstall}>
{t("plugins.install")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pluginApi } from "@/lib/plugins";
import type { MarketplaceCatalog, MarketplacePreview } from "@/types/plugins";
import { PluginMarketplace } from "./PluginMarketplace";
const context = vi.hoisted(() => ({ plugins: [], locked: false, refresh: vi.fn() }));
vi.mock("@/context/PluginContext", () => ({ usePlugins: () => context }));
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
vi.mock("@/lib/plugins", () => ({
pluginApi: {
marketplace: vi.fn(),
inspectMarketplace: vi.fn(),
installMarketplace: vi.fn(),
cancelMarketplaceReview: vi.fn(),
},
}));
const data: MarketplaceCatalog = {
target: "windows-x86_64",
catalog: {
catalogVersion: 1,
repository: { id: "nyaterm-official", name: "Store" },
generatedAt: "2026-10-03T00:00:00Z",
plugins: [
{
id: "example.tools",
name: "Tools",
description: "Session tools",
publisher: "example",
verified: true,
tags: ["session"],
source: "https://github.com/example/tools",
homepage: "https://github.com/example/tools",
license: "MIT",
permissions: ["session.read"],
latestVersion: "1.0.0",
versions: [
{
version: "1.0.0",
releasedAt: "2026-10-03T00:00:00Z",
releaseNotes: "Initial release",
permissions: ["session.read"],
artifacts: [
{
target: "universal",
url: "https://example.com/plugin.nyap",
sha256: "a".repeat(64),
size: 123,
signingKeyId: "test",
},
],
},
],
},
],
},
};
const preview: MarketplacePreview = {
token: "review-token",
repositoryId: "nyaterm-official",
package: {
digest: "a".repeat(64),
expandedBytes: 1000,
signature: { status: "verified", keyId: "test" },
manifest: {
manifestVersion: 1,
id: "example.tools",
name: "Tools",
description: "Session tools",
publisher: "example",
version: "1.0.0",
engine: ">=1.0.0",
permissions: ["session.read"],
contributions: { panels: [], commands: [] },
},
},
};
beforeEach(() => {
vi.clearAllMocks();
context.locked = false;
vi.mocked(pluginApi.marketplace).mockResolvedValue(data);
vi.mocked(pluginApi.inspectMarketplace).mockResolvedValue(preview);
vi.mocked(pluginApi.cancelMarketplaceReview).mockResolvedValue();
});
afterEach(cleanup);
describe("Marketplace review and installation", () => {
it("installs only after verified package review and does not grant runtime permissions", async () => {
render(<PluginMarketplace />);
fireEvent.click(await screen.findByText("plugins.install"));
await screen.findByText("plugins.store.verified");
expect(pluginApi.inspectMarketplace).toHaveBeenCalledWith(
"example.tools",
"1.0.0",
"a".repeat(64),
);
expect(pluginApi.installMarketplace).not.toHaveBeenCalled();
const installButtons = screen.getAllByText("plugins.install");
fireEvent.click(installButtons[installButtons.length - 1]);
await waitFor(() => expect(pluginApi.installMarketplace).toHaveBeenCalledWith("review-token"));
await waitFor(() => expect(context.refresh).toHaveBeenCalled());
});
it("discards a pending review when cancelled and filters by plugin metadata", async () => {
render(<PluginMarketplace />);
await screen.findByText("Tools");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "missing" } });
expect(screen.queryByText("Tools")).toBeNull();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "session" } });
fireEvent.click(screen.getByText("plugins.install"));
await screen.findByText("plugins.store.verified");
fireEvent.click(screen.getByText("plugins.cancel"));
expect(pluginApi.cancelMarketplaceReview).toHaveBeenCalledWith("review-token");
expect(pluginApi.installMarketplace).not.toHaveBeenCalled();
});
it("does not allow installation after backend verification fails", async () => {
vi.mocked(pluginApi.inspectMarketplace).mockRejectedValue(new Error("Signature failed"));
render(<PluginMarketplace />);
fireEvent.click(await screen.findByText("plugins.install"));
await waitFor(() => expect(pluginApi.inspectMarketplace).toHaveBeenCalled());
expect(screen.queryByText("plugins.store.verified")).toBeNull();
expect(pluginApi.installMarketplace).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,190 @@
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 { Input } from "@/components/ui/input";
import { usePlugins } from "@/context/PluginContext";
import { getErrorMessage } from "@/lib/errors";
import { pluginApi } from "@/lib/plugins";
import type { MarketplaceCatalog, MarketplacePlugin, MarketplacePreview } from "@/types/plugins";
import { PluginDetails } from "./PluginDetails";
export function PluginMarketplace() {
const { t } = useTranslation();
const { plugins, refresh, locked } = usePlugins();
const [data, setData] = useState<MarketplaceCatalog | null>(null);
const [query, setQuery] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [review, setReview] = useState<{
plugin: MarketplacePlugin;
preview: MarketplacePreview;
} | null>(null);
const mounted = useRef(false);
const token = useRef<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const catalog = await pluginApi.marketplace();
if (mounted.current) setData(catalog);
} catch (error) {
if (mounted.current) setError(getErrorMessage(error));
} finally {
if (mounted.current) setLoading(false);
}
}, []);
useEffect(() => {
mounted.current = true;
void load();
return () => {
mounted.current = false;
if (token.current) void pluginApi.cancelMarketplaceReview(token.current).catch(() => {});
token.current = null;
};
}, [load]);
const cancel = useCallback(() => {
if (token.current) void pluginApi.cancelMarketplaceReview(token.current).catch(() => {});
token.current = null;
setReview(null);
}, []);
useEffect(() => {
if (locked) cancel();
}, [locked, cancel]);
const inspect = async (plugin: MarketplacePlugin, sha256: string) => {
setBusy(true);
try {
const preview = await pluginApi.inspectMarketplace(plugin.id, plugin.latestVersion, sha256);
if (!mounted.current) {
await pluginApi.cancelMarketplaceReview(preview.token);
return;
}
token.current = preview.token;
setReview({ plugin, preview });
} catch (error) {
if (mounted.current) toast.error(getErrorMessage(error));
} finally {
if (mounted.current) setBusy(false);
}
};
const install = async () => {
if (!review) return;
setBusy(true);
try {
await pluginApi.installMarketplace(review.preview.token);
await refresh();
toast.success(t("plugins.installed"));
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
token.current = null;
if (mounted.current) {
setReview(null);
setBusy(false);
}
}
};
const matches =
data?.catalog.plugins.filter((p) =>
`${p.name} ${p.id} ${p.publisher} ${p.description} ${p.tags.join(" ")}`
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
) ?? [];
return (
<div className="space-y-4 p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plugins.store.official")}</h3>
<Button size="sm" variant="outline" disabled={loading || busy} onClick={() => void load()}>
{t("plugins.store.refresh")}
</Button>
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("plugins.store.search")}
aria-label={t("plugins.store.search")}
/>
{loading && <p className="text-sm text-muted-foreground">{t("plugins.loading")}</p>}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{!loading && !error && !matches.length && (
<p className="rounded-md border p-5 text-sm text-muted-foreground">
{t("plugins.store.empty")}
</p>
)}
{!loading &&
!error &&
matches.map((plugin) => {
const version = plugin.versions.find((v) => v.version === plugin.latestVersion);
const artifact =
version?.artifacts.find((a) => a.target === data?.target) ??
version?.artifacts.find((a) => a.target === "universal");
const installed = plugins.find((p) => p.id === plugin.id);
const current = installed?.activeVersion === plugin.latestVersion;
const local =
installed &&
installed.versions[installed.activeVersion]?.provenance?.source !== "marketplace";
return (
<div key={plugin.id} className="space-y-3 rounded-lg border bg-card p-4">
<h4 className="font-semibold">
{plugin.name}{" "}
<span className="text-xs font-normal text-muted-foreground">
{plugin.latestVersion}
</span>
</h4>
<p className="text-xs text-muted-foreground">
{t("plugins.publisher", { name: plugin.publisher })}{" "}
{plugin.verified && (
<Badge variant="secondary">{t("plugins.store.publisherVerified")}</Badge>
)}
</p>
<p className="text-sm text-muted-foreground">{plugin.description}</p>
<div className="flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
{version?.permissions.includes("native") && (
<Badge variant="outline">{t("plugins.native")}</Badge>
)}
</div>
{local && (
<p className="text-xs text-muted-foreground">{t("plugins.store.sourceConflict")}</p>
)}
<Button
size="sm"
disabled={busy || locked || current || !artifact || Boolean(local)}
onClick={() => artifact && void inspect(plugin, artifact.sha256)}
>
{t(
!artifact
? "plugins.store.unsupported"
: current
? "plugins.store.installedTab"
: installed
? "plugins.store.update"
: "plugins.install",
)}
</Button>
</div>
);
})}
{review && (
<PluginDetails
plugin={review.plugin}
preview={review.preview}
busy={busy}
locked={locked}
onCancel={cancel}
onInstall={() => void install()}
/>
)}
</div>
);
}
+36
View File
@@ -31,13 +31,33 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePlugins } from "@/context/PluginContext";
import { getErrorMessage } from "@/lib/errors";
import { activeManifest, openPlugin, pluginApi } from "@/lib/plugins";
import type { InstalledPlugin, PluginPackagePreview } from "@/types/plugins";
import { PluginDiagnostics } from "./PluginDiagnostics";
import { PluginMarketplace } from "./PluginMarketplace";
export function PluginsPanel({ sessionId }: { sessionId: string | null }) {
const { t } = useTranslation();
return (
<Tabs defaultValue="marketplace" className="h-full min-h-0 gap-0">
<TabsList className="m-3 shrink-0">
<TabsTrigger value="marketplace">{t("plugins.store.marketplace")}</TabsTrigger>
<TabsTrigger value="installed">{t("plugins.store.installedTab")}</TabsTrigger>
</TabsList>
<TabsContent value="marketplace" className="min-h-0 overflow-auto">
<PluginMarketplace />
</TabsContent>
<TabsContent value="installed" className="min-h-0 overflow-auto">
<InstalledPluginsPanel sessionId={sessionId} />
</TabsContent>
</Tabs>
);
}
function InstalledPluginsPanel({ sessionId }: { sessionId: string | null }) {
const { t } = useTranslation();
const { plugins, loaded, error, refresh, locked } = usePlugins();
const [busy, setBusy] = useState(false);
@@ -132,6 +152,18 @@ export function PluginsPanel({ sessionId }: { sessionId: string | null }) {
{t(plugin.enabled ? "plugins.enabled" : "plugins.disabled")}
</Badge>
{manifest.backend && <Badge variant="outline">{t("plugins.native")}</Badge>}
<Badge variant="outline">
{t(
plugin.versions[plugin.activeVersion]?.provenance?.source === "marketplace"
? "plugins.store.marketplace"
: "plugins.store.local",
)}
</Badge>
<Badge variant="outline">
{t(
`plugins.store.${plugin.versions[plugin.activeVersion]?.signature?.status ?? "unsigned"}`,
)}
</Badge>
</div>
<p className="break-words text-sm text-muted-foreground">{manifest.description}</p>
<p className="break-all text-xs text-muted-foreground">
@@ -270,6 +302,10 @@ export function PluginsPanel({ sessionId }: { sessionId: string | null }) {
})}
</p>
<p className="text-xs text-muted-foreground">{t("plugins.publisherUnverified")}</p>
<Badge variant="outline">{t("plugins.store.local")}</Badge>
<Badge variant="outline">
{t(`plugins.store.${preview.package.signature.status}`)}
</Badge>
<div className="space-y-1">
<p className="font-medium">{t("plugins.permissionsTitle")}</p>
{preview.package.manifest.permissions.map((permission) => (
+64 -46
View File
@@ -52,6 +52,7 @@
"actionName": "Action name",
"actionPrompt": "Action prompt",
"activeProfile": "Active Profile",
"addProvider": "Add provider",
"agentApproveExecute": "Execute",
"agentAwaitingApproval": "Awaiting approval...",
"agentCommandExecutionMode": "Command execution policy",
@@ -94,6 +95,7 @@
"backgroundAgentExecutionDesc": "Run commands as background tasks and return results to the current AI chat.",
"baseUrl": "API Base URL",
"baseUrlRootHint": "Enter the API root URL, not a specific chat request path.",
"changeProviderIcon": "Change provider icon",
"claudeCodeAuthMode": "Auth mode",
"claudeCodeConfigDirectory": "Config directory",
"claudeCodeConnected": "Available",
@@ -145,10 +147,22 @@
"commandTargetLabel": "Target: {{target}}",
"commandTargetMissing": "Command target missing",
"commandTargetUnavailable": "Target terminal is unavailable",
"confirmAddProvider": "Confirm add",
"connectionModelCount": "{{count}} models",
"connectionStatus": {
"error": "Connection failed",
"idle": "Not tested",
"success": "Connected",
"testing": "Testing connection…"
},
"connectionTest": "Test connection",
"connectionTestSucceeded": "Provider connection succeeded",
"connectionTestUnsupported": "Connection testing currently supports OpenAI-compatible providers only",
"contextLineLimit": "Default Context Lines",
"copied": "Copied",
"copy": "Copy",
"customProvider": "Custom Provider",
"customProviderOption": "Custom provider",
"dateEarlier": "Earlier",
"dateLast7Days": "Last 7 Days",
"dateToday": "Today",
@@ -156,9 +170,13 @@
"defaultSession": "Current session (default)",
"deleteManualModel": "Delete manual model",
"deleteModel": "Delete model",
"deleteProvider": "Delete provider",
"deleteProviderConfirm": "Delete {{name}}? Its associated model configurations will also be removed.",
"deleteSession": "Delete",
"detect": "Detect",
"disabled": "AI is disabled",
"discardProviderConfirm": "Discard {{name}}? The values you entered will be lost.",
"editModelConfig": "Edit model configuration",
"empty": "Ask AI to explain output or generate commands.",
"enableAutoExecution": "Enable auto",
"enableFullAccess": "Enable full access",
@@ -209,6 +227,7 @@
"generate": "Generate",
"generateCommand": "Generate Command",
"goToSettingsToEnable": "Enable in Settings",
"hideApiKey": "Hide API key",
"history": "History",
"historyCurrentTerminal": "Current Terminal",
"historyInUse": "Already open in another terminal",
@@ -235,7 +254,6 @@
},
"manualModelAdded": "Model added: {{model}}",
"manualModelBadge": "Manual",
"providerReturnedBadge": "Provider returned",
"manualModelDeleted": "Model deleted: {{model}}",
"manualModelExists": "Model already exists: {{model}}",
"manualModelNoProvider": "Enable an API key profile before adding a model manually.",
@@ -253,40 +271,13 @@
"model": "Model",
"modelDiscoveryUnsupported": "The current protocol does not support automatic model discovery yet.",
"modelList": "Models from this provider",
"editModelConfig": "Edit model configuration",
"supportedReasoningEfforts": "Supported reasoning effort",
"modelListDesc": "Refresh pulls known models from rust-genai. New models are disabled by default.",
"providerList": "Provider list",
"addProvider": "Add provider",
"changeProviderIcon": "Change provider icon",
"deleteProvider": "Delete provider",
"deleteProviderConfirm": "Delete {{name}}? Its associated model configurations will also be removed.",
"discardProviderConfirm": "Discard {{name}}? The values you entered will be lost.",
"customProviderOption": "Custom provider",
"confirmAddProvider": "Confirm add",
"providerEndpoint": "API endpoint",
"providerEndpointRequired": "Enter an API endpoint",
"providerApiKeyRequired": "Enter an API key",
"providerNameDuplicate": "A provider with this name already exists",
"providerProtocol": "API protocol",
"providerProtocolChatCompletions": "/chat/completions (OpenAI-compatible)",
"providerProtocolResponses": "/responses (OpenAI Responses API)",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat (Ollama native)",
"connectionTest": "Test connection",
"connectionTestUnsupported": "Connection testing currently supports OpenAI-compatible providers only",
"connectionTestSucceeded": "Provider connection succeeded",
"connectionModelCount": "{{count}} models",
"connectionStatus": {
"idle": "Not tested",
"testing": "Testing connection…",
"success": "Connected",
"error": "Connection failed"
},
"showApiKey": "Show API key",
"hideApiKey": "Hide API key",
"modelSelect": "Model",
"modelTestFailed": "Model test failed: {{model}}",
"modelTestFailedShort": "Test failed",
"modelTestSucceeded": "Model test succeeded: {{model}}",
"modelTestSucceededShort": "Test succeeded",
"modelTestUnsupported": "Codex models cannot be tested here yet",
"models": "Models",
"modelsRefreshed": "Model list refreshed",
"newChat": "New Chat",
@@ -314,9 +305,21 @@
"permissionObserverDesc": "Allows basic reads, asks before sensitive reads, and blocks write operations.",
"placeholder": "Ask anything... Type @ for sessions",
"profileName": "Name",
"providerName": "Provider name",
"providerApiKeyRequired": "Enter an API key",
"providerEndpoint": "API endpoint",
"providerEndpointRequired": "Enter an API endpoint",
"providerKind": "Provider",
"providerList": "Provider list",
"providerName": "Provider name",
"providerNameDuplicate": "A provider with this name already exists",
"providerProfiles": "Profiles",
"providerProtocol": "API protocol",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolChatCompletions": "/chat/completions (OpenAI-compatible)",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat (Ollama native)",
"providerProtocolResponses": "/responses (OpenAI Responses API)",
"providerReturnedBadge": "Provider returned",
"quickCommandCategory": "AI Generated",
"quickPrompt": "Example: find top CPU processes",
"quote": "Quote",
@@ -352,27 +355,24 @@
"saveQuickCommand": "Save",
"savedQuickCommand": "Saved to Quick Commands",
"searchModels": "Search models",
"testModel": "Test model",
"modelTestSucceeded": "Model test succeeded: {{model}}",
"modelTestFailed": "Model test failed: {{model}}",
"modelTestSucceededShort": "Test succeeded",
"modelTestFailedShort": "Test failed",
"modelTestUnsupported": "Codex models cannot be tested here yet",
"settings": "AI Settings",
"setupAction": "Settings",
"setupStep1": "Add an API key credential",
"setupStep2": "Enable a model",
"setupTitle": "AI Not Configured",
"showApiKey": "Show API key",
"shownInTerminal": "Shown in terminal",
"smartAutoExecuteMaxRisk": "Smart auto-execute max risk",
"smartAutoExecuteMaxRiskDesc": "Commands auto-run when the locally reviewed risk is at or below this level. Critical always requires confirmation.",
"stepCommand": "Command",
"stepDuration": "{{ms}}ms",
"stepExitCode": "exit {{code}}",
"supportedReasoningEfforts": "Supported reasoning effort",
"targetSession": "Target",
"terminalActions": "Terminal AI actions",
"terminalOutputLines": "Output lines",
"terminalOutputLinesDesc": "Lines shown in terminal when AI Agent runs a command. Set 0 to hide inline output.",
"testModel": "Test model",
"thinking": "Thinking",
"thoughtComplete": "Thought",
"timeoutMs": "Timeout (ms)",
@@ -1286,7 +1286,6 @@
"uploadStarted": "Uploading file \"{{name}}\"..."
},
"gpuMonitor": {
"paused": "Monitoring paused. Refresh to resume.",
"cuda": "CUDA",
"details": "Details",
"disabled": "Enable GPU monitoring in Settings",
@@ -1305,6 +1304,7 @@
"noMatches": "No matching GPU processes",
"noProcesses": "No active GPU compute processes",
"noSession": "Connect via SSH to monitor NVIDIA GPUs",
"paused": "Monitoring paused. Refresh to resume.",
"pid": "PID",
"power": "Power",
"search": "Search GPU processes...",
@@ -1645,7 +1645,6 @@
"usernamePlaceholder": "e.g. root"
},
"plugins": {
"probeTrust": "These scripts run automatically on the selected SSH session after authorization. Review their contents; third-party scripts are not guaranteed to be read-only. Updates require renewed authorization.",
"allowOnce": "Allow once",
"approvalDescription": "{{name}} requests access to the selected session.",
"approvalTitle": "Approve plugin operation",
@@ -1684,10 +1683,10 @@
"nativeTrust": "A native backend runs with your operating system user permissions. It can access files, networks and programs independently of these host permissions. Enable native code only from a publisher you trust.",
"noInstalled": "No plugins installed.",
"permissions": {
"remote_probe": "Automatically execute this plugin’s declared remote scripts",
"filesystem_read": "Request text file reads in the selected session",
"native": "Run this plugin’s native executable",
"network": "Make HTTPS requests to {{origin}}",
"remote_probe": "Automatically execute this plugin’s declared remote scripts",
"session_read": "Read the selected session’s name and type",
"storage": "Store this plugin’s own settings",
"terminal_execute": "Request command execution in the selected terminal",
@@ -1695,6 +1694,7 @@
},
"permissionsDescription": "Choose which capabilities {{name}} may use. Terminal commands and file reads require approval for each operation.",
"permissionsTitle": "Permissions",
"probeTrust": "These scripts run automatically on the selected SSH session after authorization. Review their contents; third-party scripts are not guaranteed to be read-only. Updates require renewed authorization.",
"publisher": "Publisher: {{name}}",
"publisherUnverified": "This publisher name is declared by the package and has not been verified.",
"retry": "Retry",
@@ -1702,6 +1702,24 @@
"reviewPackage": "Review plugin package",
"running": "Running plugin command…",
"saveAndEnable": "Save and enable",
"store": {
"empty": "No matching plugins in the Store.",
"installedTab": "Installed",
"local": "Local plugin",
"marketplace": "Marketplace",
"official": "NyaTerm Plugin Store",
"publisherVerified": "Verified publisher",
"refresh": "Refresh",
"repository": "Repository",
"search": "Search plugins…",
"source": "Source",
"sourceConflict": "Uninstall the local plugin before switching to the official Store.",
"unsigned": "Unsigned",
"unsupported": "Unsupported platform",
"untrusted": "Untrusted signing key",
"update": "Review update",
"verified": "Signature verified"
},
"title": "Plugins",
"unavailable": "This plugin panel is disabled or unavailable. Its layout is preserved.",
"uninstall": "Uninstall",
@@ -2624,19 +2642,19 @@
"syncDebounceSeconds": "Auto Push Debounce (seconds)",
"syncDebounceSecondsDesc": "Wait this long after the latest local change before uploading a sync snapshot.",
"syncEnableFirst": "Enable cloud sync first.",
"syncGistCapacityHint": "Gitee Snippet may have reached its file limit. Cleaning old snapshots and pushing again usually recovers sync.",
"syncMasterPasswordMissingDesc": "Set a master password first. Cloud sync stays disabled until one is configured.",
"syncMasterPasswordReadyDesc": "Cloud sync uses your master password to encrypt the remote snapshot.",
"syncProvider": "Provider",
"syncProviderConfig": "Provider Configuration",
"syncProviderConfigDesc": "Configure the remote storage used by cloud sync.",
"syncProviderDesc": "Choose which remote storage backend OpenDAL should use.",
"syncPruneAndRetry": "Clean remote snapshots & retry",
"syncPruneAndRetrySuccess": "Remote snapshots cleaned and push succeeded.",
"syncPullNow": "Pull Now",
"syncPullSuccess": "Snapshot downloaded.",
"syncPushNow": "Push Now",
"syncPushSuccess": "Snapshot uploaded.",
"syncPruneAndRetry": "Clean remote snapshots & retry",
"syncPruneAndRetrySuccess": "Remote snapshots cleaned and push succeeded.",
"syncGistCapacityHint": "Gitee Snippet may have reached its file limit. Cleaning old snapshots and pushing again usually recovers sync.",
"syncRecoverCurrentSuccess": "Remote metadata recovered.",
"syncRemoteIncompleteTitle": "Incomplete Remote Sync Data",
"syncResolveDownloadSuccess": "Remote version downloaded.",
+64 -46
View File
@@ -52,6 +52,7 @@
"actionName": "작업 이름",
"actionPrompt": "작업 프롬프트",
"activeProfile": "활성 프로필",
"addProvider": "공급자 추가",
"agentApproveExecute": "실행",
"agentAwaitingApproval": "승인 대기 중...",
"agentCommandExecutionMode": "명령 실행 정책",
@@ -94,6 +95,7 @@
"backgroundAgentExecutionDesc": "명령을 백그라운드 작업으로 실행하고 결과를 현재 AI 채팅으로 반환합니다.",
"baseUrl": "API 기본 URL",
"baseUrlRootHint": "Enter the API root URL, not a specific chat request path.",
"changeProviderIcon": "공급자 아이콘 변경",
"claudeCodeAuthMode": "인증 방식",
"claudeCodeConfigDirectory": "설정 디렉터리",
"claudeCodeConnected": "사용 가능",
@@ -145,10 +147,22 @@
"commandTargetLabel": "대상: {{target}}",
"commandTargetMissing": "명령 대상이 없습니다",
"commandTargetUnavailable": "대상 터미널을 사용할 수 없습니다",
"confirmAddProvider": "추가 확인",
"connectionModelCount": "{{count}}개 모델",
"connectionStatus": {
"error": "연결 실패",
"idle": "테스트되지 않음",
"success": "연결됨",
"testing": "연결 테스트 중…"
},
"connectionTest": "연결 테스트",
"connectionTestSucceeded": "공급자 연결에 성공했습니다",
"connectionTestUnsupported": "현재 OpenAI 호환 공급자만 연결 테스트를 지원합니다",
"contextLineLimit": "기본 컨텍스트 줄 수",
"copied": "복사됨",
"copy": "복사",
"customProvider": "사용자 지정 공급자",
"customProviderOption": "사용자 지정 / Custom",
"dateEarlier": "이전",
"dateLast7Days": "최근 7일",
"dateToday": "오늘",
@@ -156,9 +170,13 @@
"defaultSession": "현재 세션(기본값)",
"deleteManualModel": "수동 모델 삭제",
"deleteModel": "모델 삭제",
"deleteProvider": "공급자 삭제",
"deleteProviderConfirm": "{{name}} 공급자를 삭제하시겠습니까? 연결된 모델 설정도 함께 제거됩니다.",
"deleteSession": "삭제",
"detect": "감지",
"disabled": "AI가 비활성화되어 있습니다",
"discardProviderConfirm": "{{name}} 공급자 추가를 취소하시겠습니까? 입력한 설정이 사라집니다.",
"editModelConfig": "모델 설정 편집",
"empty": "AI에게 출력 설명이나 명령 생성을 요청하세요.",
"enableAutoExecution": "자동 실행 켜기",
"enableFullAccess": "전체 권한 사용",
@@ -209,6 +227,7 @@
"generate": "생성",
"generateCommand": "명령 생성",
"goToSettingsToEnable": "설정에서 활성화",
"hideApiKey": "API 키 숨기기",
"history": "기록",
"historyCurrentTerminal": "현재 터미널",
"historyInUse": "다른 터미널에서 열려 있음",
@@ -235,7 +254,6 @@
},
"manualModelAdded": "모델 추가됨: {{model}}",
"manualModelBadge": "수동",
"providerReturnedBadge": "공급자 반환",
"manualModelDeleted": "모델 삭제됨: {{model}}",
"manualModelExists": "이미 존재하는 모델입니다: {{model}}",
"manualModelNoProvider": "모델을 수동으로 추가하려면 먼저 API 키 프로필을 활성화하세요.",
@@ -253,40 +271,13 @@
"model": "모델",
"modelDiscoveryUnsupported": "The current protocol does not support automatic model discovery yet.",
"modelList": "이 공급자의 모델",
"editModelConfig": "모델 설정 편집",
"supportedReasoningEfforts": "지원되는 추론 강도",
"modelListDesc": "새로고침하면 rust-genai에서 알려진 모델을 가져옵니다. 새 모델은 기본적으로 비활성화됩니다.",
"providerList": "공급자 목록",
"addProvider": "공급자 추가",
"changeProviderIcon": "공급자 아이콘 변경",
"deleteProvider": "공급자 삭제",
"deleteProviderConfirm": "{{name}} 공급자를 삭제하시겠습니까? 연결된 모델 설정도 함께 제거됩니다.",
"discardProviderConfirm": "{{name}} 공급자 추가를 취소하시겠습니까? 입력한 설정이 사라집니다.",
"customProviderOption": "사용자 지정 / Custom",
"confirmAddProvider": "추가 확인",
"providerEndpoint": "API 주소",
"providerEndpointRequired": "API 주소를 입력하세요",
"providerApiKeyRequired": "API 키를 입력하세요",
"providerNameDuplicate": "같은 이름의 공급자가 이미 있습니다",
"providerProtocol": "API 프로토콜",
"providerProtocolChatCompletions": "/chat/completions (OpenAI 호환 형식)",
"providerProtocolResponses": "/responses (OpenAI Responses API)",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat (Ollama 기본 프로토콜)",
"connectionTest": "연결 테스트",
"connectionTestUnsupported": "현재 OpenAI 호환 공급자만 연결 테스트를 지원합니다",
"connectionTestSucceeded": "공급자 연결에 성공했습니다",
"connectionModelCount": "{{count}}개 모델",
"connectionStatus": {
"idle": "테스트되지 않음",
"testing": "연결 테스트 중…",
"success": "연결됨",
"error": "연결 실패"
},
"showApiKey": "API 키 표시",
"hideApiKey": "API 키 숨기기",
"modelSelect": "모델",
"modelTestFailed": "모델 테스트 실패: {{model}}",
"modelTestFailedShort": "테스트 실패",
"modelTestSucceeded": "모델 테스트 성공: {{model}}",
"modelTestSucceededShort": "테스트 성공",
"modelTestUnsupported": "Codex 모델은 아직 여기서 테스트할 수 없습니다",
"models": "모델",
"modelsRefreshed": "모델 목록이 새로고침되었습니다",
"newChat": "새 채팅",
@@ -314,9 +305,21 @@
"permissionObserverDesc": "일반 읽기는 허용하고 민감한 읽기는 확인하며 쓰기 작업은 차단합니다.",
"placeholder": "무엇이든 물어보세요... @를 입력하면 세션을 선택할 수 있습니다",
"profileName": "이름",
"providerName": "공급자 이름",
"providerApiKeyRequired": "API 키를 입력하세요",
"providerEndpoint": "API 주소",
"providerEndpointRequired": "API 주소를 입력하세요",
"providerKind": "공급자",
"providerList": "공급자 목록",
"providerName": "공급자 이름",
"providerNameDuplicate": "같은 이름의 공급자가 이미 있습니다",
"providerProfiles": "프로필",
"providerProtocol": "API 프로토콜",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolChatCompletions": "/chat/completions (OpenAI 호환 형식)",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat (Ollama 기본 프로토콜)",
"providerProtocolResponses": "/responses (OpenAI Responses API)",
"providerReturnedBadge": "공급자 반환",
"quickCommandCategory": "AI 생성",
"quickPrompt": "예: CPU 사용량이 가장 높은 프로세스 찾기",
"quote": "인용",
@@ -352,27 +355,24 @@
"saveQuickCommand": "저장",
"savedQuickCommand": "빠른 명령에 저장됨",
"searchModels": "모델 검색",
"testModel": "모델 테스트",
"modelTestSucceeded": "모델 테스트 성공: {{model}}",
"modelTestFailed": "모델 테스트 실패: {{model}}",
"modelTestSucceededShort": "테스트 성공",
"modelTestFailedShort": "테스트 실패",
"modelTestUnsupported": "Codex 모델은 아직 여기서 테스트할 수 없습니다",
"settings": "AI 설정",
"setupAction": "설정",
"setupStep1": "API 키 자격 증명 추가",
"setupStep2": "모델 활성화",
"setupTitle": "AI가 설정되지 않았습니다",
"showApiKey": "API 키 표시",
"shownInTerminal": "터미널에 표시됨",
"smartAutoExecuteMaxRisk": "스마트 자동 실행 최대 위험도",
"smartAutoExecuteMaxRiskDesc": "로컬 검토 위험도가 이 수준 이하일 때 명령을 자동 실행합니다. 심각 등급은 항상 확인이 필요합니다.",
"stepCommand": "명령",
"stepDuration": "{{ms}}ms",
"stepExitCode": "종료 코드 {{code}}",
"supportedReasoningEfforts": "지원되는 추론 강도",
"targetSession": "대상",
"terminalActions": "터미널 AI 작업",
"terminalOutputLines": "출력 줄 수",
"terminalOutputLinesDesc": "AI Agent가 명령을 실행할 때 터미널에 표시할 줄 수입니다. 0으로 설정하면 인라인 출력을 숨깁니다.",
"testModel": "모델 테스트",
"thinking": "생각 중",
"thoughtComplete": "생각 완료",
"timeoutMs": "제한 시간(ms)",
@@ -1285,7 +1285,6 @@
"uploadStarted": "파일 \"{{name}}\" 업로드 중..."
},
"gpuMonitor": {
"paused": "모니터링이 일시 중지되었습니다. 새로 고쳐 재개하세요.",
"cuda": "CUDA",
"details": "세부 정보",
"disabled": "설정에서 GPU 모니터링을 활성화하세요",
@@ -1304,6 +1303,7 @@
"noMatches": "일치하는 GPU 프로세스가 없습니다",
"noProcesses": "활성 GPU 연산 프로세스가 없습니다",
"noSession": "SSH로 연결하여 NVIDIA GPU를 모니터링하세요",
"paused": "모니터링이 일시 중지되었습니다. 새로 고쳐 재개하세요.",
"pid": "PID",
"power": "전력",
"search": "GPU 프로세스 검색...",
@@ -1643,7 +1643,6 @@
"usernamePlaceholder": "예: root"
},
"plugins": {
"probeTrust": "승인 후 선택한 SSH 세션에서 스크립트가 자동 실행됩니다. 내용을 검토하세요. 타사 스크립트는 읽기 전용을 보장하지 않습니다. 버전 업데이트 후 다시 승인해야 합니다.",
"allowOnce": "이번만 허용",
"approvalDescription": "{{name}}에서 선택한 세션에 대한 접근을 요청합니다.",
"approvalTitle": "플러그인 작업 승인",
@@ -1682,10 +1681,10 @@
"nativeTrust": "네이티브 백엔드는 운영체제 사용자 권한으로 실행됩니다. 호스트 권한과 별개로 파일, 네트워크 및 프로그램에 접근할 수 있습니다. 신뢰할 수 있는 게시자의 네이티브 코드만 활성화하세요.",
"noInstalled": "설치된 플러그인이 없습니다.",
"permissions": {
"remote_probe": "플러그인이 선언한 원격 스크립트 자동 실행",
"filesystem_read": "선택한 세션의 텍스트 파일 읽기 요청",
"native": "이 플러그인의 네이티브 실행 파일 실행",
"network": "{{origin}}에 HTTPS 요청 보내기",
"remote_probe": "플러그인이 선언한 원격 스크립트 자동 실행",
"session_read": "선택한 세션의 이름 및 유형 읽기",
"storage": "이 플러그인의 설정 저장",
"terminal_execute": "선택한 터미널에서 명령 실행 요청",
@@ -1693,6 +1692,7 @@
},
"permissionsDescription": "{{name}}에서 사용할 기능을 선택하세요. 터미널 명령과 파일 읽기는 매번 승인이 필요합니다.",
"permissionsTitle": "권한",
"probeTrust": "승인 후 선택한 SSH 세션에서 스크립트가 자동 실행됩니다. 내용을 검토하세요. 타사 스크립트는 읽기 전용을 보장하지 않습니다. 버전 업데이트 후 다시 승인해야 합니다.",
"publisher": "게시자: {{name}}",
"publisherUnverified": "게시자 이름은 패키지에 선언된 정보이며 검증되지 않았습니다.",
"retry": "다시 시도",
@@ -1700,6 +1700,24 @@
"reviewPackage": "플러그인 패키지 검토",
"running": "플러그인 명령 실행 중…",
"saveAndEnable": "저장 및 활성화",
"store": {
"empty": "스토어에 일치하는 플러그인이 없습니다.",
"installedTab": "설치됨",
"local": "로컬 플러그인",
"marketplace": "마켓플레이스",
"official": "NyaTerm 플러그인 스토어",
"publisherVerified": "인증된 게시자",
"refresh": "새로 고침",
"repository": "저장소",
"search": "플러그인 검색…",
"source": "소스 코드",
"sourceConflict": "공식 스토어로 전환하기 전에 로컬 플러그인을 제거하세요.",
"unsigned": "서명 없음",
"unsupported": "지원하지 않는 플랫폼",
"untrusted": "신뢰할 수 없는 서명 키",
"update": "업데이트 검토",
"verified": "서명 확인됨"
},
"title": "플러그인",
"unavailable": "이 플러그인 패널은 비활성화되었거나 사용할 수 없습니다. 레이아웃은 유지됩니다.",
"uninstall": "제거",
@@ -2622,19 +2640,19 @@
"syncDebounceSeconds": "자동 업로드 지연(초)",
"syncDebounceSecondsDesc": "최근 로컬 변경 후 동기화 스냅샷을 업로드하기까지 대기하는 시간입니다.",
"syncEnableFirst": "먼저 클라우드 동기화를 활성화하세요.",
"syncGistCapacityHint": "Gitee gist 파일 수 한도에 도달했을 수 있습니다. 오래된 스냅샷을 정리한 뒤 다시 푸시하면 보통 복구됩니다.",
"syncMasterPasswordMissingDesc": "먼저 마스터 비밀번호를 설정하세요. 설정되기 전까지 클라우드 동기화는 비활성화된 상태로 유지됩니다.",
"syncMasterPasswordReadyDesc": "클라우드 동기화는 마스터 비밀번호를 사용해 원격 스냅샷을 암호화합니다.",
"syncProvider": "공급자",
"syncProviderConfig": "공급자 구성",
"syncProviderConfigDesc": "클라우드 동기화에 사용할 원격 저장소를 구성합니다.",
"syncProviderDesc": "OpenDAL이 사용할 원격 저장소 백엔드를 선택하세요.",
"syncPruneAndRetry": "원격 스냅샷 정리 후 재시도",
"syncPruneAndRetrySuccess": "원격 스냅샷을 정리하고 푸시에 성공했습니다.",
"syncPullNow": "지금 다운로드",
"syncPullSuccess": "스냅샷을 다운로드했습니다.",
"syncPushNow": "지금 업로드",
"syncPushSuccess": "스냅샷을 업로드했습니다.",
"syncPruneAndRetry": "원격 스냅샷 정리 후 재시도",
"syncPruneAndRetrySuccess": "원격 스냅샷을 정리하고 푸시에 성공했습니다.",
"syncGistCapacityHint": "Gitee gist 파일 수 한도에 도달했을 수 있습니다. 오래된 스냅샷을 정리한 뒤 다시 푸시하면 보통 복구됩니다.",
"syncRecoverCurrentSuccess": "원격 동기화 메타데이터를 복구했습니다.",
"syncRemoteIncompleteTitle": "불완전한 원격 동기화 데이터",
"syncResolveDownloadSuccess": "원격 버전을 다운로드했습니다.",
+64 -46
View File
@@ -52,6 +52,7 @@
"actionName": "功能名称",
"actionPrompt": "功能提示词",
"activeProfile": "当前配置档",
"addProvider": "添加供应商",
"agentApproveExecute": "执行",
"agentAwaitingApproval": "等待确认执行…",
"agentCommandExecutionMode": "命令执行策略",
@@ -94,6 +95,7 @@
"backgroundAgentExecutionDesc": "命令在后台任务中运行,结果回传到当前 AI 会话。",
"baseUrl": "API 地址",
"baseUrlRootHint": "请填写 API 根地址,不要包含具体的聊天请求路径。",
"changeProviderIcon": "更换供应商图标",
"claudeCodeAuthMode": "认证模式",
"claudeCodeConfigDirectory": "配置目录",
"claudeCodeConnected": "可用",
@@ -145,10 +147,22 @@
"commandTargetLabel": "目标:{{target}}",
"commandTargetMissing": "缺少命令目标",
"commandTargetUnavailable": "目标终端不可用",
"confirmAddProvider": "确认添加",
"connectionModelCount": "{{count}} 个模型",
"connectionStatus": {
"error": "连接失败",
"idle": "尚未测试",
"success": "连接成功",
"testing": "正在测试连接…"
},
"connectionTest": "连接测试",
"connectionTestSucceeded": "供应商连接成功",
"connectionTestUnsupported": "目前仅支持测试 OpenAI 兼容供应商",
"contextLineLimit": "默认上下文行数",
"copied": "已复制",
"copy": "复制",
"customProvider": "自定义供应商",
"customProviderOption": "自定义 / Custom",
"dateEarlier": "更早",
"dateLast7Days": "最近7天",
"dateToday": "今天",
@@ -156,9 +170,13 @@
"defaultSession": "当前会话(默认)",
"deleteManualModel": "删除手动模型",
"deleteModel": "删除模型",
"deleteProvider": "删除供应商",
"deleteProviderConfirm": "确定删除“{{name}}”吗?关联的模型配置也会被移除。",
"deleteSession": "删除",
"detect": "检测",
"disabled": "AI 助手未启用",
"discardProviderConfirm": "确定放弃添加“{{name}}”吗?已填写的参数将会丢失。",
"editModelConfig": "编辑模型配置",
"empty": "让 AI 帮你解释终端输出或生成命令。",
"enableAutoExecution": "开启全自动",
"enableFullAccess": "启用完全权限",
@@ -209,6 +227,7 @@
"generate": "生成",
"generateCommand": "AI 生成命令",
"goToSettingsToEnable": "请前往设置启用",
"hideApiKey": "隐藏 API Key",
"history": "历史",
"historyCurrentTerminal": "当前终端",
"historyInUse": "已在另一个终端打开",
@@ -235,7 +254,6 @@
},
"manualModelAdded": "已添加模型:{{model}}",
"manualModelBadge": "手动",
"providerReturnedBadge": "供应商返回",
"manualModelDeleted": "已删除模型:{{model}}",
"manualModelExists": "模型已存在:{{model}}",
"manualModelNoProvider": "请先启用一个 API Key 配置档,再手动添加模型。",
@@ -253,40 +271,13 @@
"model": "模型名称",
"modelDiscoveryUnsupported": "当前协议暂不支持自动获取模型列表。",
"modelList": "来自此供应商的模型",
"editModelConfig": "编辑模型配置",
"supportedReasoningEfforts": "支持的推理强度",
"modelListDesc": "刷新会从 rust-genai 获取已知模型;新模型默认不启用。",
"providerList": "供应商列表",
"addProvider": "添加供应商",
"changeProviderIcon": "更换供应商图标",
"deleteProvider": "删除供应商",
"deleteProviderConfirm": "确定删除“{{name}}”吗?关联的模型配置也会被移除。",
"discardProviderConfirm": "确定放弃添加“{{name}}”吗?已填写的参数将会丢失。",
"customProviderOption": "自定义 / Custom",
"confirmAddProvider": "确认添加",
"providerEndpoint": "接口地址",
"providerEndpointRequired": "请填写接口地址",
"providerApiKeyRequired": "请填写 API 密钥",
"providerNameDuplicate": "供应商名称不能重复",
"providerProtocol": "接口协议",
"providerProtocolChatCompletions": "/chat/completions(OpenAI 兼容格式)",
"providerProtocolResponses": "/responses(OpenAI Responses API)",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat(Ollama 原生协议)",
"connectionTest": "连接测试",
"connectionTestUnsupported": "目前仅支持测试 OpenAI 兼容供应商",
"connectionTestSucceeded": "供应商连接成功",
"connectionModelCount": "{{count}} 个模型",
"connectionStatus": {
"idle": "尚未测试",
"testing": "正在测试连接…",
"success": "连接成功",
"error": "连接失败"
},
"showApiKey": "显示 API Key",
"hideApiKey": "隐藏 API Key",
"modelSelect": "模型",
"modelTestFailed": "模型“{{model}}”测试失败",
"modelTestFailedShort": "测试失败",
"modelTestSucceeded": "模型“{{model}}”测试成功",
"modelTestSucceededShort": "测试成功",
"modelTestUnsupported": "Codex 模型暂不支持在此测试",
"models": "模型",
"modelsRefreshed": "模型列表已刷新",
"newChat": "新对话",
@@ -314,9 +305,21 @@
"permissionObserverDesc": "允许普通读取;敏感读取需要确认;禁止写入操作。",
"placeholder": "描述你的需求… 输入 @ 选择目标会话",
"profileName": "名称",
"providerName": "供应商名称",
"providerApiKeyRequired": "请填写 API 密钥",
"providerEndpoint": "接口地址",
"providerEndpointRequired": "请填写接口地址",
"providerKind": "供应商",
"providerList": "供应商列表",
"providerName": "供应商名称",
"providerNameDuplicate": "供应商名称不能重复",
"providerProfiles": "模型配置档",
"providerProtocol": "接口协议",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolChatCompletions": "/chat/completions(OpenAI 兼容格式)",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat(Ollama 原生协议)",
"providerProtocolResponses": "/responses(OpenAI Responses API)",
"providerReturnedBadge": "供应商返回",
"quickCommandCategory": "AI 生成",
"quickPrompt": "例如:查 CPU 占用最高的进程",
"quote": "引用",
@@ -352,27 +355,24 @@
"saveQuickCommand": "保存",
"savedQuickCommand": "已保存到快捷命令",
"searchModels": "搜索模型",
"testModel": "测试模型",
"modelTestSucceeded": "模型“{{model}}”测试成功",
"modelTestFailed": "模型“{{model}}”测试失败",
"modelTestSucceededShort": "测试成功",
"modelTestFailedShort": "测试失败",
"modelTestUnsupported": "Codex 模型暂不支持在此测试",
"settings": "AI 设置",
"setupAction": "前往设置",
"setupStep1": "添加 API Key 凭证(支持 OpenAI / Anthropic / DeepSeek 等)",
"setupStep2": "启用至少一个可用模型",
"setupTitle": "AI 助手尚未配置",
"showApiKey": "显示 API Key",
"shownInTerminal": "已显示在终端",
"smartAutoExecuteMaxRisk": "智能模式自动执行上限",
"smartAutoExecuteMaxRiskDesc": "本地复核后的风险不高于该等级时自动执行;极高风险始终需要确认。",
"stepCommand": "命令",
"stepDuration": "耗时 {{ms}}ms",
"stepExitCode": "退出码 {{code}}",
"supportedReasoningEfforts": "支持的推理强度",
"targetSession": "目标",
"terminalActions": "终端右键 AI 功能",
"terminalOutputLines": "终端输出行数",
"terminalOutputLinesDesc": "AI Agent 执行命令时在终端中显示的输出行数。设为 0 则禁用终端内联显示。",
"testModel": "测试模型",
"thinking": "思考中",
"thoughtComplete": "已思考",
"timeoutMs": "请求超时时间 ms",
@@ -1285,7 +1285,6 @@
"uploadStarted": "正在上传文件「{{name}}」…"
},
"gpuMonitor": {
"paused": "监控已暂停,刷新可恢复。",
"cuda": "CUDA",
"details": "详情",
"disabled": "请在设置中启用 GPU 监控",
@@ -1304,6 +1303,7 @@
"noMatches": "没有匹配的 GPU 进程",
"noProcesses": "暂无活跃 GPU 计算进程",
"noSession": "连接 SSH 会话以监控 NVIDIA GPU",
"paused": "监控已暂停,刷新可恢复。",
"pid": "PID",
"power": "功耗",
"search": "搜索 GPU 进程...",
@@ -1643,7 +1643,6 @@
"usernamePlaceholder": "例如:root"
},
"plugins": {
"probeTrust": "授权后,这些脚本会在选中的 SSH 会话中自动执行。请审核脚本内容;第三方脚本不保证只读。更新版本后需要重新授权。",
"allowOnce": "仅允许本次",
"approvalDescription": "{{name}} 请求访问所选会话。",
"approvalTitle": "批准插件操作",
@@ -1682,10 +1681,10 @@
"nativeTrust": "原生后端以当前系统用户权限运行,可独立访问文件、网络和程序,宿主权限无法限制这些操作。请仅启用可信作者提供的原生代码。",
"noInstalled": "尚未安装插件。",
"permissions": {
"remote_probe": "自动执行插件声明的远程脚本",
"filesystem_read": "请求读取所选会话中的文本文件",
"native": "运行此插件的原生程序",
"network": "向 {{origin}} 发起 HTTPS 请求",
"remote_probe": "自动执行插件声明的远程脚本",
"session_read": "读取所选会话的名称和类型",
"storage": "保存此插件自己的设置",
"terminal_execute": "请求在所选终端执行命令",
@@ -1693,6 +1692,7 @@
},
"permissionsDescription": "选择 {{name}} 可使用的能力。终端命令和文件读取仍需逐次批准。",
"permissionsTitle": "权限",
"probeTrust": "授权后,这些脚本会在选中的 SSH 会话中自动执行。请审核脚本内容;第三方脚本不保证只读。更新版本后需要重新授权。",
"publisher": "作者:{{name}}",
"publisherUnverified": "作者名称由插件包声明,尚未经过验证。",
"retry": "重试",
@@ -1700,6 +1700,24 @@
"reviewPackage": "检查插件包",
"running": "正在运行插件命令…",
"saveAndEnable": "保存并启用",
"store": {
"empty": "插件市场中没有匹配的插件。",
"installedTab": "已安装",
"local": "本地插件",
"marketplace": "插件市场",
"official": "NyaTerm 官方插件市场",
"publisherVerified": "发布者已认证",
"refresh": "刷新",
"repository": "插件仓库",
"search": "搜索插件…",
"source": "源码",
"sourceConflict": "切换到官方插件市场前,请先卸载本地插件。",
"unsigned": "未签名",
"unsupported": "不支持当前平台",
"untrusted": "签名密钥不受信任",
"update": "审核更新",
"verified": "签名已验证"
},
"title": "插件",
"unavailable": "此插件面板已停用或暂不可用,布局位置已保留。",
"uninstall": "卸载",
@@ -2622,19 +2640,19 @@
"syncDebounceSeconds": "自动推送防抖(秒)",
"syncDebounceSecondsDesc": "距离最近一次本地变更过去这么久后,才会自动上传同步快照。",
"syncEnableFirst": "请先启用云同步再执行该操作。",
"syncGistCapacityHint": "Gitee 代码片段可能已达文件数上限。清理旧快照后重新推送通常即可恢复。",
"syncMasterPasswordMissingDesc": "请先设置主密码。未配置主密码前,云同步保持禁用。",
"syncMasterPasswordReadyDesc": "云同步会使用你的主密码加密远端快照。",
"syncProvider": "提供方",
"syncProviderConfig": "存储提供方配置",
"syncProviderConfigDesc": "配置云同步使用的远程存储提供方与命名空间。",
"syncProviderDesc": "选择 OpenDAL 要连接的远程存储后端。",
"syncPruneAndRetry": "清理远端旧快照并重试",
"syncPruneAndRetrySuccess": "已清理远端旧快照并推送成功。",
"syncPullNow": "立即拉取",
"syncPullSuccess": "同步快照已下载。",
"syncPushNow": "立即推送",
"syncPushSuccess": "同步快照已上传。",
"syncPruneAndRetry": "清理远端旧快照并重试",
"syncPruneAndRetrySuccess": "已清理远端旧快照并推送成功。",
"syncGistCapacityHint": "Gitee 代码片段可能已达文件数上限。清理旧快照后重新推送通常即可恢复。",
"syncRecoverCurrentSuccess": "远端同步元数据已恢复。",
"syncRemoteIncompleteTitle": "远端同步数据不完整",
"syncResolveDownloadSuccess": "远程版本已成功下载。",
+64 -46
View File
@@ -52,6 +52,7 @@
"actionName": "功能名稱",
"actionPrompt": "功能提示詞",
"activeProfile": "目前設定檔",
"addProvider": "新增供應商",
"agentApproveExecute": "執行",
"agentAwaitingApproval": "等待確認執行…",
"agentCommandExecutionMode": "命令執行策略",
@@ -94,6 +95,7 @@
"backgroundAgentExecutionDesc": "命令在背景任務中執行,結果回傳到目前 AI 工作階段。",
"baseUrl": "API 網址",
"baseUrlRootHint": "請填寫 API 根網址,不要包含具體的聊天請求路徑。",
"changeProviderIcon": "更換供應商圖示",
"claudeCodeAuthMode": "認證模式",
"claudeCodeConfigDirectory": "設定目錄",
"claudeCodeConnected": "可用",
@@ -145,10 +147,22 @@
"commandTargetLabel": "目標:{{target}}",
"commandTargetMissing": "缺少命令目標",
"commandTargetUnavailable": "目標終端不可用",
"confirmAddProvider": "確認新增",
"connectionModelCount": "{{count}} 個模型",
"connectionStatus": {
"error": "連線失敗",
"idle": "尚未測試",
"success": "連線成功",
"testing": "正在測試連線…"
},
"connectionTest": "連線測試",
"connectionTestSucceeded": "供應商連線成功",
"connectionTestUnsupported": "目前僅支援測試 OpenAI 相容供應商",
"contextLineLimit": "預設上下文行數",
"copied": "已複製",
"copy": "複製",
"customProvider": "自訂供應商",
"customProviderOption": "自訂 / Custom",
"dateEarlier": "更早",
"dateLast7Days": "最近 7 天",
"dateToday": "今天",
@@ -156,9 +170,13 @@
"defaultSession": "目前工作階段(預設)",
"deleteManualModel": "刪除手動模型",
"deleteModel": "刪除模型",
"deleteProvider": "刪除供應商",
"deleteProviderConfirm": "確定刪除「{{name}}」嗎?關聯的模型設定也會一併移除。",
"deleteSession": "刪除",
"detect": "偵測",
"disabled": "AI 助手未啟用",
"discardProviderConfirm": "確定放棄新增「{{name}}」嗎?已填寫的參數將會遺失。",
"editModelConfig": "編輯模型設定",
"empty": "讓 AI 幫你解釋終端輸出或產生命令。",
"enableAutoExecution": "開啟全自動",
"enableFullAccess": "啟用完全權限",
@@ -209,6 +227,7 @@
"generate": "產生",
"generateCommand": "AI 產生命令",
"goToSettingsToEnable": "請前往設定啟用",
"hideApiKey": "隱藏 API Key",
"history": "歷史",
"historyCurrentTerminal": "目前終端",
"historyInUse": "已在另一個終端開啟",
@@ -235,7 +254,6 @@
},
"manualModelAdded": "已新增模型:{{model}}",
"manualModelBadge": "手動",
"providerReturnedBadge": "供應商回傳",
"manualModelDeleted": "已刪除模型:{{model}}",
"manualModelExists": "模型已存在:{{model}}",
"manualModelNoProvider": "請先啟用一個 API Key 設定檔,再手動新增模型。",
@@ -253,40 +271,13 @@
"model": "模型名稱",
"modelDiscoveryUnsupported": "目前協定暫不支援自動取得模型清單。",
"modelList": "來自此供應商的模型",
"editModelConfig": "編輯模型設定",
"supportedReasoningEfforts": "支援的推理強度",
"modelListDesc": "重新整理會從 rust-genai 取得已知模型;新模型預設不啟用。",
"providerList": "供應商清單",
"addProvider": "新增供應商",
"changeProviderIcon": "更換供應商圖示",
"deleteProvider": "刪除供應商",
"deleteProviderConfirm": "確定刪除「{{name}}」嗎?關聯的模型設定也會一併移除。",
"discardProviderConfirm": "確定放棄新增「{{name}}」嗎?已填寫的參數將會遺失。",
"customProviderOption": "自訂 / Custom",
"confirmAddProvider": "確認新增",
"providerEndpoint": "介面地址",
"providerEndpointRequired": "請填寫介面地址",
"providerApiKeyRequired": "請填寫 API 金鑰",
"providerNameDuplicate": "供應商名稱不可重複",
"providerProtocol": "介面協定",
"providerProtocolChatCompletions": "/chat/completions(OpenAI 相容格式)",
"providerProtocolResponses": "/responses(OpenAI Responses API)",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat(Ollama 原生協定)",
"connectionTest": "連線測試",
"connectionTestUnsupported": "目前僅支援測試 OpenAI 相容供應商",
"connectionTestSucceeded": "供應商連線成功",
"connectionModelCount": "{{count}} 個模型",
"connectionStatus": {
"idle": "尚未測試",
"testing": "正在測試連線…",
"success": "連線成功",
"error": "連線失敗"
},
"showApiKey": "顯示 API Key",
"hideApiKey": "隱藏 API Key",
"modelSelect": "模型",
"modelTestFailed": "模型「{{model}}」測試失敗",
"modelTestFailedShort": "測試失敗",
"modelTestSucceeded": "模型「{{model}}」測試成功",
"modelTestSucceededShort": "測試成功",
"modelTestUnsupported": "Codex 模型暫不支援在此測試",
"models": "模型",
"modelsRefreshed": "模型清單已重新整理",
"newChat": "新對話",
@@ -314,9 +305,21 @@
"permissionObserverDesc": "允許一般讀取;敏感讀取需要確認;禁止寫入操作。",
"placeholder": "描述你的需求… 輸入 @ 選擇目標工作階段",
"profileName": "名稱",
"providerName": "供應商名稱",
"providerApiKeyRequired": "請填寫 API 金鑰",
"providerEndpoint": "介面地址",
"providerEndpointRequired": "請填寫介面地址",
"providerKind": "供應商",
"providerList": "供應商清單",
"providerName": "供應商名稱",
"providerNameDuplicate": "供應商名稱不可重複",
"providerProfiles": "模型設定檔",
"providerProtocol": "介面協定",
"providerProtocolAnthropic": "Anthropic Messages API",
"providerProtocolChatCompletions": "/chat/completions(OpenAI 相容格式)",
"providerProtocolGemini": "Google Gemini API",
"providerProtocolOllama": "/api/chat(Ollama 原生協定)",
"providerProtocolResponses": "/responses(OpenAI Responses API)",
"providerReturnedBadge": "供應商回傳",
"quickCommandCategory": "AI 產生",
"quickPrompt": "例如:查 CPU 佔用最高的處理程序",
"quote": "引用",
@@ -352,27 +355,24 @@
"saveQuickCommand": "儲存",
"savedQuickCommand": "已儲存到快捷命令",
"searchModels": "搜尋模型",
"testModel": "測試模型",
"modelTestSucceeded": "模型「{{model}}」測試成功",
"modelTestFailed": "模型「{{model}}」測試失敗",
"modelTestSucceededShort": "測試成功",
"modelTestFailedShort": "測試失敗",
"modelTestUnsupported": "Codex 模型暫不支援在此測試",
"settings": "AI 設定",
"setupAction": "前往設定",
"setupStep1": "新增 API Key 認證資訊(支援 OpenAI / Anthropic / DeepSeek 等)",
"setupStep2": "啟用至少一個可用模型",
"setupTitle": "AI 助手尚未設定",
"showApiKey": "顯示 API Key",
"shownInTerminal": "已顯示在終端",
"smartAutoExecuteMaxRisk": "智慧模式自動執行上限",
"smartAutoExecuteMaxRiskDesc": "本機複核後的風險不高於該等級時自動執行;極高風險始終需要確認。",
"stepCommand": "命令",
"stepDuration": "耗時 {{ms}}ms",
"stepExitCode": "退出碼 {{code}}",
"supportedReasoningEfforts": "支援的推理強度",
"targetSession": "目標",
"terminalActions": "終端右鍵 AI 功能",
"terminalOutputLines": "終端輸出行數",
"terminalOutputLinesDesc": "AI Agent 執行命令時在終端中顯示的輸出行數。設為 0 則停用終端內聯顯示。",
"testModel": "測試模型",
"thinking": "思考中",
"thoughtComplete": "已思考",
"timeoutMs": "請求逾時時間 ms",
@@ -1285,7 +1285,6 @@
"uploadStarted": "正在上傳檔案「{{name}}」…"
},
"gpuMonitor": {
"paused": "監控已暫停,重新整理可恢復。",
"cuda": "CUDA",
"details": "詳情",
"disabled": "請在設定中啟用 GPU 監控",
@@ -1304,6 +1303,7 @@
"noMatches": "沒有符合的 GPU 處理程序",
"noProcesses": "暫無使用中 GPU 計算處理程序",
"noSession": "連線 SSH 工作階段以監控 NVIDIA GPU",
"paused": "監控已暫停,重新整理可恢復。",
"pid": "PID",
"power": "功耗",
"search": "搜尋 GPU 處理程序...",
@@ -1643,7 +1643,6 @@
"usernamePlaceholder": "例如:root"
},
"plugins": {
"probeTrust": "授權後,這些指令碼會在選取的 SSH 工作階段中自動執行。請審核內容;第三方指令碼不保證唯讀。更新版本後需要重新授權。",
"allowOnce": "僅允許本次",
"approvalDescription": "{{name}} 要求存取所選工作階段。",
"approvalTitle": "核准外掛操作",
@@ -1682,10 +1681,10 @@
"nativeTrust": "原生後端以目前系統使用者權限執行,可獨立存取檔案、網路及程式,宿主權限無法限制這些操作。請僅啟用可信作者提供的原生程式碼。",
"noInstalled": "尚未安裝外掛。",
"permissions": {
"remote_probe": "自動執行外掛宣告的遠端指令碼",
"filesystem_read": "要求讀取所選工作階段中的文字檔案",
"native": "執行此外掛的原生程式",
"network": "向 {{origin}} 發出 HTTPS 要求",
"remote_probe": "自動執行外掛宣告的遠端指令碼",
"session_read": "讀取所選工作階段的名稱和類型",
"storage": "儲存此外掛自己的設定",
"terminal_execute": "要求在所選終端執行命令",
@@ -1693,6 +1692,7 @@
},
"permissionsDescription": "選擇 {{name}} 可使用的能力。終端命令和檔案讀取仍需逐次核准。",
"permissionsTitle": "權限",
"probeTrust": "授權後,這些指令碼會在選取的 SSH 工作階段中自動執行。請審核內容;第三方指令碼不保證唯讀。更新版本後需要重新授權。",
"publisher": "作者:{{name}}",
"publisherUnverified": "作者名稱由外掛套件宣告,尚未經過驗證。",
"retry": "重試",
@@ -1700,6 +1700,24 @@
"reviewPackage": "檢查外掛套件",
"running": "正在執行外掛命令…",
"saveAndEnable": "儲存並啟用",
"store": {
"empty": "外掛市場中沒有符合的外掛。",
"installedTab": "已安裝",
"local": "本機外掛",
"marketplace": "外掛市場",
"official": "NyaTerm 官方外掛市場",
"publisherVerified": "發行者已認證",
"refresh": "重新整理",
"repository": "外掛儲存庫",
"search": "搜尋外掛…",
"source": "原始碼",
"sourceConflict": "切換到官方外掛市場前,請先解除安裝本機外掛。",
"unsigned": "未簽章",
"unsupported": "不支援目前平台",
"untrusted": "簽章金鑰不受信任",
"update": "審核更新",
"verified": "簽章已驗證"
},
"title": "外掛",
"unavailable": "此外掛面板已停用或暫時無法使用,版面位置已保留。",
"uninstall": "解除安裝",
@@ -2617,19 +2635,19 @@
"syncDebounceSeconds": "自動推送防抖(秒)",
"syncDebounceSecondsDesc": "距離最近一次本機變更過去這麼久後,才會自動上傳雲端同步快照。",
"syncEnableFirst": "請先啟用雲端同步再執行該操作。",
"syncGistCapacityHint": "Gitee 程式碼片段可能已達檔案數上限。清理舊快照後重新推送通常即可恢復。",
"syncMasterPasswordMissingDesc": "請先設定主密碼。未設定主密碼前,雲端同步保持停用。",
"syncMasterPasswordReadyDesc": "雲端同步會使用你的主密碼加密遠端快照。",
"syncProvider": "提供方",
"syncProviderConfig": "儲存提供方設定",
"syncProviderConfigDesc": "設定雲端同步使用的遠端儲存提供方與名稱空間。",
"syncProviderDesc": "選擇 OpenDAL 要連線的遠端儲存後端。",
"syncPruneAndRetry": "清理遠端舊快照並重試",
"syncPruneAndRetrySuccess": "已清理遠端舊快照並推送成功。",
"syncPullNow": "立即拉取",
"syncPullSuccess": "雲端同步快照已下載。",
"syncPushNow": "立即推送",
"syncPushSuccess": "雲端同步快照已上傳。",
"syncPruneAndRetry": "清理遠端舊快照並重試",
"syncPruneAndRetrySuccess": "已清理遠端舊快照並推送成功。",
"syncGistCapacityHint": "Gitee 程式碼片段可能已達檔案數上限。清理舊快照後重新推送通常即可恢復。",
"syncRecoverCurrentSuccess": "遠端同步中繼資料已復原。",
"syncRemoteIncompleteTitle": "遠端同步資料不完整",
"syncResolveDownloadSuccess": "遠端版本已成功下載。",
+9
View File
@@ -1,6 +1,8 @@
import { invoke } from "@/lib/invoke";
import type {
InstalledPlugin,
MarketplaceCatalog,
MarketplacePreview,
PluginDiagnosticsSnapshot,
PluginMonitorSubscription,
PluginPackagePreview,
@@ -8,6 +10,13 @@ import type {
} from "@/types/plugins";
export const pluginApi = {
marketplace: () => invoke<MarketplaceCatalog>("get_plugin_marketplace"),
inspectMarketplace: (pluginId: string, version: string, expectedSha256: string) =>
invoke<MarketplacePreview>("inspect_marketplace_plugin", { pluginId, version, expectedSha256 }),
installMarketplace: (token: string) =>
invoke<InstalledPlugin>("install_marketplace_plugin", { token }),
cancelMarketplaceReview: (token: string) =>
invoke<void>("cancel_marketplace_plugin_review", { token }),
probeScripts: (pluginId: string, expectedVersion: string) =>
invoke<Record<string, string>>("get_plugin_probe_scripts", { pluginId, expectedVersion }),
subscribeMonitor: (token: string, monitorId: string, intervalSeconds: number) =>
+65 -1
View File
@@ -53,7 +53,15 @@ export interface InstalledPlugin {
activeVersion: string;
enabled: boolean;
grantedPermissions: PluginPermission[];
versions: Record<string, { manifest: PluginManifest; digest: string }>;
versions: Record<
string,
{
manifest: PluginManifest;
digest: string;
provenance?: PluginProvenance;
signature?: PluginSignatureStatus;
}
>;
}
export interface PluginPackagePreview {
@@ -61,6 +69,62 @@ export interface PluginPackagePreview {
digest: string;
expandedBytes: number;
probeScripts?: Record<string, string>;
signature: PluginSignatureStatus;
}
export type PluginSignatureStatus =
| { status: "unsigned" }
| { status: "untrusted" | "verified"; keyId: string };
export type PluginProvenance =
| { source: "local" }
| {
source: "marketplace";
repositoryId: string;
publisher: string;
signingKeyId: string;
packageSha256: string;
};
export interface MarketplaceArtifact {
target: string;
url: string;
sha256: string;
size: number;
signingKeyId: string;
}
export interface MarketplaceVersion {
version: string;
releasedAt: string;
releaseNotes: string;
permissions: string[];
artifacts: MarketplaceArtifact[];
}
export interface MarketplacePlugin {
id: string;
name: string;
description: string;
publisher: string;
verified: boolean;
tags: string[];
source: string;
homepage: string;
license: string;
permissions: string[];
latestVersion: string;
versions: MarketplaceVersion[];
}
export interface MarketplaceCatalog {
target: string;
catalog: {
catalogVersion: number;
repository: { id: string; name: string };
generatedAt: string;
plugins: MarketplacePlugin[];
};
}
export interface MarketplacePreview {
token: string;
package: PluginPackagePreview;
repositoryId: string;
}
export interface PluginMonitorSnapshot {