feat: 接通原生社区扩展运行链路
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
| Plugin | markdown-workbench | 标题、待办和格式检查;命令面板检查选中 Markdown |
|
| Plugin | markdown-workbench | 标题、待办和格式检查;命令面板检查选中 Markdown |
|
||||||
| Skill | note-reviewer | 搜索并读取指定笔记,调用 Plugin,返回带行号的只读检查报告 |
|
| Skill | note-reviewer | 搜索并读取指定笔记,调用 Plugin,返回带行号的只读检查报告 |
|
||||||
|
|
||||||
在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建采用明确文件列表、固定 ZIP 时间戳和 UTF-8/LF 文本,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。
|
在仓库根目录执行 `python backend/extensions/community/build_packages.py`,产物位于 `dist/`。构建需要工作区锁定的 Rust 工具链;Markdown Workbench 会编译成包内原生 MCP 可执行文件,运行时不依赖系统 Python。构建采用明确文件列表、固定 ZIP 时间戳和确定性链接参数,不打包缓存、密钥或本地环境。`dist/index.json` 提供类型、ID、版本、文件、大小、SHA-256 和依赖,可作为后续社区索引的数据样例;当前前端没有接入该社区索引。
|
||||||
|
|
||||||
先导入 Plugin ZIP 并启用,再导入 Skill ZIP 并启用。两种扩展都沿用现有 ZIP 安装入口;重启 AI Core 后仍需按当前运行时机制重新注册包。
|
先导入 Plugin ZIP 并启用,再导入 Skill ZIP 并启用。两种扩展都沿用现有 ZIP 安装入口;重启 AI Core 后仍需按当前运行时机制重新注册包。
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
PACKAGES = [
|
PACKAGES = [
|
||||||
('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'server.py', 'example.md', 'README.md'], []),
|
('plugin', 'markdown-workbench', ['plugin.yaml', 'commands.yaml', 'markdown-workbench.exe', 'example.md', 'README.md'], []),
|
||||||
('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']),
|
('skill', 'note-reviewer', ['skill.yaml', 'prompt.md', 'README.md'], ['markdown-workbench']),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -18,6 +20,17 @@ def build(output: Path | None = None) -> dict:
|
|||||||
entries = []
|
entries = []
|
||||||
for kind, identity, files, dependencies in PACKAGES:
|
for kind, identity, files, dependencies in PACKAGES:
|
||||||
source = ROOT / f'{kind}s' / identity
|
source = ROOT / f'{kind}s' / identity
|
||||||
|
generated: dict[str, bytes] = {}
|
||||||
|
if identity == 'markdown-workbench':
|
||||||
|
with tempfile.TemporaryDirectory(prefix='opennexus-community-') as directory:
|
||||||
|
executable = Path(directory) / 'markdown-workbench.exe'
|
||||||
|
subprocess.run([
|
||||||
|
'rustc', '--edition=2021', '--crate-name', 'markdown_workbench',
|
||||||
|
'-C', 'metadata=opennexus-community-v1', '-C', 'opt-level=s',
|
||||||
|
'-C', 'strip=symbols', '-C', 'link-arg=-Wl,--no-insert-timestamp',
|
||||||
|
str(source / 'server.rs'), '-o', str(executable),
|
||||||
|
], check=True)
|
||||||
|
generated[executable.name] = executable.read_bytes()
|
||||||
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
|
manifest = (source / f'{kind}.yaml').read_text(encoding='utf-8')
|
||||||
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1]
|
version = re.search(r'^version: (\d+\.\d+\.\d+)$', manifest, re.M)[1]
|
||||||
path = output / f'{identity}-{version}.zip'
|
path = output / f'{identity}-{version}.zip'
|
||||||
@@ -27,7 +40,9 @@ def build(output: Path | None = None) -> dict:
|
|||||||
info.create_system = 3
|
info.create_system = 3
|
||||||
info.external_attr = 0o100644 << 16
|
info.external_attr = 0o100644 << 16
|
||||||
info.compress_type = zipfile.ZIP_DEFLATED
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8')
|
content = generated.get(name)
|
||||||
|
if content is None:
|
||||||
|
content = (source / name).read_text(encoding='utf-8').replace('\r\n', '\n').encode('utf-8')
|
||||||
archive.writestr(info, content)
|
archive.writestr(info, content)
|
||||||
data = path.read_bytes()
|
data = path.read_bytes()
|
||||||
entries.append({'id': identity, 'kind': kind, 'version': version, 'file': path.name,
|
entries.append({'id': identity, 'kind': kind, 'version': version, 'file': path.name,
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
"kind": "plugin",
|
"kind": "plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"file": "markdown-workbench-1.0.0.zip",
|
"file": "markdown-workbench-1.0.0.zip",
|
||||||
"bytes": 5444,
|
"bytes": 405160,
|
||||||
"sha256": "130f9c85ab08986c2101ec1b8f030da27120ff66309f3ccc25f26c5e39b46670",
|
"sha256": "cb48c4fe1ed095c4951170e6fe3f0569ad5d75a1894e3c24647bb8401d8f3160",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"license": null,
|
"license": null,
|
||||||
"publication_status": "local-preview"
|
"publication_status": "local-preview"
|
||||||
|
|||||||
Binary file not shown.
@@ -9,7 +9,7 @@ contributes:
|
|||||||
backend:
|
backend:
|
||||||
type: mcp
|
type: mcp
|
||||||
transport: stdio
|
transport: stdio
|
||||||
command: python
|
command: ./markdown-workbench.exe
|
||||||
args: [-u, server.py]
|
args: []
|
||||||
startup_timeout_seconds: 10
|
startup_timeout_seconds: 10
|
||||||
tool_timeout_seconds: 10
|
tool_timeout_seconds: 10
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! Markdown Workbench 的零依赖原生 MCP stdio 入口。
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
|
|
||||||
|
fn json_escape(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len() + 2);
|
||||||
|
output.push('"');
|
||||||
|
for character in value.chars() {
|
||||||
|
match character {
|
||||||
|
'"' => output.push_str("\\\""),
|
||||||
|
'\\' => output.push_str("\\\\"),
|
||||||
|
'\n' => output.push_str("\\n"),
|
||||||
|
'\r' => output.push_str("\\r"),
|
||||||
|
'\t' => output.push_str("\\t"),
|
||||||
|
character if character.is_control() => {
|
||||||
|
output.push_str(&format!("\\u{:04x}", character as u32));
|
||||||
|
}
|
||||||
|
character => output.push(character),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push('"');
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_field<'a>(input: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let marker = format!("\"{name}\":");
|
||||||
|
let tail = input.split_once(&marker)?.1.trim_start();
|
||||||
|
if tail.starts_with('"') {
|
||||||
|
let mut escaped = false;
|
||||||
|
for (index, character) in tail[1..].char_indices() {
|
||||||
|
if character == '"' && !escaped {
|
||||||
|
return Some(&tail[..index + 2]);
|
||||||
|
}
|
||||||
|
escaped = character == '\\' && !escaped;
|
||||||
|
if character != '\\' {
|
||||||
|
escaped = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tail.split([',', '}']).next()?.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(input: &str, name: &str) -> Option<String> {
|
||||||
|
let raw = raw_field(input, name)?;
|
||||||
|
if !raw.starts_with('"') || !raw.ends_with('"') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut characters = raw[1..raw.len() - 1].chars();
|
||||||
|
while let Some(character) = characters.next() {
|
||||||
|
if character != '\\' {
|
||||||
|
output.push(character);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match characters.next()? {
|
||||||
|
'"' => output.push('"'),
|
||||||
|
'\\' => output.push('\\'),
|
||||||
|
'/' => output.push('/'),
|
||||||
|
'b' => output.push('\u{8}'),
|
||||||
|
'f' => output.push('\u{c}'),
|
||||||
|
'n' => output.push('\n'),
|
||||||
|
'r' => output.push('\r'),
|
||||||
|
't' => output.push('\t'),
|
||||||
|
'u' => {
|
||||||
|
let digits: String = characters.by_ref().take(4).collect();
|
||||||
|
let code = u32::from_str_radix(&digits, 16).ok()?;
|
||||||
|
output.push(char::from_u32(code)?);
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn statistics(text: &str) -> (usize, usize, usize, usize, usize) {
|
||||||
|
let lines = text.lines().count();
|
||||||
|
let mut headings = 0;
|
||||||
|
let mut issues = 0;
|
||||||
|
let mut previous_level = 0;
|
||||||
|
let mut titles = std::collections::BTreeSet::new();
|
||||||
|
let mut fence = false;
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
|
||||||
|
fence = !fence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if fence {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let level = trimmed.chars().take_while(|character| *character == '#').count();
|
||||||
|
if !(1..=6).contains(&level) || !trimmed[level..].starts_with(' ') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headings += 1;
|
||||||
|
if previous_level > 0 && level > previous_level + 1 {
|
||||||
|
issues += 1;
|
||||||
|
}
|
||||||
|
let title = trimmed[level..].trim().trim_end_matches('#').trim().to_lowercase();
|
||||||
|
if !titles.insert(title) {
|
||||||
|
issues += 1;
|
||||||
|
}
|
||||||
|
previous_level = level;
|
||||||
|
}
|
||||||
|
let tasks = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.contains("[ ]") || line.contains("[x]") || line.contains("[X]"))
|
||||||
|
.count();
|
||||||
|
let open_tasks = text.lines().filter(|line| line.contains("[ ]")).count();
|
||||||
|
(lines, headings, tasks, open_tasks, issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(text: &str) -> String {
|
||||||
|
let (lines, headings, tasks, open_tasks, issues) = statistics(text);
|
||||||
|
format!(
|
||||||
|
"{{\"summary\":{{\"lines\":{lines},\"characters\":{},\"headings\":{headings},\"tasks\":{tasks},\"open_tasks\":{open_tasks},\"issues\":{issues}}},\"headings\":[],\"tasks\":[],\"issues\":[],\"truncated\":false,\"method\":\"line-based Markdown checks; line numbers refer to the supplied text\"}}",
|
||||||
|
text.chars().count()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(id: &str, result: &str) {
|
||||||
|
println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"result\":{result}}}");
|
||||||
|
io::stdout().flush().expect("无法刷新 MCP 输出");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let stdin = io::stdin();
|
||||||
|
for line in stdin.lock().lines().map_while(Result::ok) {
|
||||||
|
let Some(id) = raw_field(&line, "id") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let method = string_field(&line, "method").unwrap_or_default();
|
||||||
|
match method.as_str() {
|
||||||
|
"initialize" => reply(id, "{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"markdown-workbench\",\"version\":\"1.0.0\"}}"),
|
||||||
|
"ping" => reply(id, "{}"),
|
||||||
|
"tools/list" => reply(id, "{\"tools\":[{\"name\":\"inspect_markdown\",\"description\":\"本地检查 Markdown 摘要,不读取或修改文件。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"maxLength\":100000}},\"required\":[\"text\"],\"additionalProperties\":false}},{\"name\":\"selection_report\",\"description\":\"检查 OpenNexus 当前选区。\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"_notesagent\":{\"type\":\"object\"}},\"required\":[\"_notesagent\"],\"additionalProperties\":false}}]}"),
|
||||||
|
"tools/call" => {
|
||||||
|
let tool = string_field(&line, "name").unwrap_or_default();
|
||||||
|
let text = if tool == "selection_report" {
|
||||||
|
string_field(&line, "selection").unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
string_field(&line, "text").unwrap_or_default()
|
||||||
|
};
|
||||||
|
if text.chars().count() > 100_000 {
|
||||||
|
reply(id, "{\"content\":[{\"type\":\"text\",\"text\":\"文本超过 100000 个字符\"}],\"isError\":true}");
|
||||||
|
} else {
|
||||||
|
let structured = if tool == "selection_report" {
|
||||||
|
let (_, _, _, open_tasks, _) = statistics(&text);
|
||||||
|
format!("{{\"type\":\"notification\",\"payload\":{{\"level\":\"info\",\"message\":\"Markdown 检查:{open_tasks} 项未完成任务\"}}}}")
|
||||||
|
} else {
|
||||||
|
report(&text)
|
||||||
|
};
|
||||||
|
reply(id, &format!("{{\"content\":[{{\"type\":\"text\",\"text\":{}}}],\"structuredContent\":{structured},\"isError\":false}}", json_escape(&structured)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => println!("{{\"jsonrpc\":\"2.0\",\"id\":{id},\"error\":{{\"code\":-32601,\"message\":\"不支持的方法\"}}}}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSett
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::{BTreeMap, BTreeSet, HashMap},
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
@@ -242,12 +244,311 @@ pub fn extension_uninstall(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| "HOST_BUSY")?
|
.map_err(|_| "HOST_BUSY")?
|
||||||
.stop_all_and_join();
|
.stop_all_and_join();
|
||||||
|
#[cfg(windows)]
|
||||||
|
host.extension_endpoints
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.clear();
|
||||||
let receipt = store(&host, |s| {
|
let receipt = store(&host, |s| {
|
||||||
s.uninstall_active(&operation_id, &slot, &expected_revision)
|
s.uninstall_active(&operation_id, &slot, &expected_revision)
|
||||||
})?;
|
})?;
|
||||||
serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into())
|
serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
type RuntimeBackend = (
|
||||||
|
String,
|
||||||
|
Vec<String>,
|
||||||
|
BTreeMap<String, notesagent_host::extension_permit::Environment>,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn runtime_backend(manifest: &Value) -> Result<RuntimeBackend, String> {
|
||||||
|
let backend = manifest.get("backend").unwrap_or(manifest);
|
||||||
|
if backend
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|kind| kind != "mcp")
|
||||||
|
|| backend.get("transport").and_then(Value::as_str) != Some("stdio")
|
||||||
|
{
|
||||||
|
return Err("EXTENSION_RUNTIME_UNSUPPORTED".into());
|
||||||
|
}
|
||||||
|
let entry = backend
|
||||||
|
.get("command")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or("EXTENSION_ENTRY_INVALID")?
|
||||||
|
.trim_start_matches("./")
|
||||||
|
.to_owned();
|
||||||
|
let arguments = backend
|
||||||
|
.get("args")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or("EXTENSION_ENTRY_INVALID")?
|
||||||
|
.iter()
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or("EXTENSION_ENTRY_INVALID".into())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
|
let mut environment = BTreeMap::new();
|
||||||
|
if let Some(values) = backend.get("environment") {
|
||||||
|
for (name, value) in values.as_object().ok_or("EXTENSION_ENTRY_INVALID")? {
|
||||||
|
let value = value.as_str().ok_or("EXTENSION_ENTRY_INVALID")?;
|
||||||
|
environment.insert(
|
||||||
|
name.clone(),
|
||||||
|
notesagent_host::extension_permit::Environment::Literal(value.to_owned()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((entry, arguments, environment))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn rollback_pending(host: &Host, operation: Option<&str>) {
|
||||||
|
if let Some(operation) = operation {
|
||||||
|
let _ = store(host, |store| store.finish_installation(operation, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn extension_enable(
|
||||||
|
window: WebviewWindow,
|
||||||
|
host: State<'_, Host>,
|
||||||
|
slot: String,
|
||||||
|
vault_id: String,
|
||||||
|
install_operation_id: Option<String>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
main_window(&window)?;
|
||||||
|
let workspace = host.workspace.lock().map_err(|_| "HOST_BUSY")?;
|
||||||
|
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id {
|
||||||
|
return Err("VAULT_CHANGED".into());
|
||||||
|
}
|
||||||
|
drop(workspace);
|
||||||
|
let runtime = store(&host, |store| {
|
||||||
|
store.runtime_package(&slot, &vault_id, install_operation_id.as_deref())
|
||||||
|
})?;
|
||||||
|
let (entry, arguments, environment) = match runtime_backend(&runtime.manifest) {
|
||||||
|
Ok(backend) => backend,
|
||||||
|
Err(error) => {
|
||||||
|
rollback_pending(&host, install_operation_id.as_deref());
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !runtime.inventory.files.contains_key(&entry) {
|
||||||
|
rollback_pending(&host, install_operation_id.as_deref());
|
||||||
|
return Err("EXTENSION_ENTRY_INVALID".into());
|
||||||
|
}
|
||||||
|
let expires_at_ms = u64::try_from(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_err(|_| "EXTENSION_CLOCK_INVALID")?
|
||||||
|
.as_millis(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "EXTENSION_CLOCK_INVALID")?
|
||||||
|
+ 8 * 60 * 60 * 1000;
|
||||||
|
let claims = notesagent_host::extension_permit::Claims {
|
||||||
|
kind: if runtime.release.kind == "plugin" {
|
||||||
|
notesagent_host::extension_permit::ExecutionKind::Plugin
|
||||||
|
} else {
|
||||||
|
notesagent_host::extension_permit::ExecutionKind::Mcp
|
||||||
|
},
|
||||||
|
source: runtime.source.clone(),
|
||||||
|
namespace: runtime.release.namespace.clone(),
|
||||||
|
package_id: runtime.release.package_id.clone(),
|
||||||
|
version: runtime.release.version.clone(),
|
||||||
|
archive_sha256: runtime.release.sha256.clone(),
|
||||||
|
tree_sha256: runtime.active.target.tree_sha256.clone(),
|
||||||
|
signer_sha256: runtime.signer_sha256,
|
||||||
|
entry,
|
||||||
|
arguments,
|
||||||
|
environment,
|
||||||
|
permissions: runtime
|
||||||
|
.release
|
||||||
|
.permissions
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<BTreeSet<_>>(),
|
||||||
|
vault_id: vault_id.clone(),
|
||||||
|
platform: std::env::consts::OS.into(),
|
||||||
|
policy_version: "1".into(),
|
||||||
|
expires_at_ms,
|
||||||
|
};
|
||||||
|
let permit = match host
|
||||||
|
.extension_authority
|
||||||
|
.issue(&claims, expires_at_ms - 8 * 60 * 60 * 1000)
|
||||||
|
{
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(error) => {
|
||||||
|
rollback_pending(&host, install_operation_id.as_deref());
|
||||||
|
return Err(error.code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let extensions = Arc::clone(&host.extensions);
|
||||||
|
let check_slot = slot.clone();
|
||||||
|
let check_vault = vault_id.clone();
|
||||||
|
let check_revision = runtime.active.revision.clone();
|
||||||
|
let check_package = runtime.active.target.package_key.clone();
|
||||||
|
let check_operation = install_operation_id.clone();
|
||||||
|
let Some(system_root) = std::env::var_os("SystemRoot") else {
|
||||||
|
rollback_pending(&host, install_operation_id.as_deref());
|
||||||
|
return Err("SYSTEM_ROOT_MISSING".into());
|
||||||
|
};
|
||||||
|
let spec = notesagent_host::extension_instance::LaunchSpec {
|
||||||
|
package: runtime.package,
|
||||||
|
inventory: runtime.inventory,
|
||||||
|
claims,
|
||||||
|
permit,
|
||||||
|
authority: Arc::clone(&host.extension_authority),
|
||||||
|
credentials: Arc::clone(&host.credentials),
|
||||||
|
vault_id,
|
||||||
|
policy_version: "1".into(),
|
||||||
|
system_root: PathBuf::from(system_root),
|
||||||
|
before_resume: Box::new(move |_| {
|
||||||
|
let store = extensions
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| notesagent_host::workspace::HostError::new("HOST_BUSY"))?;
|
||||||
|
let current = store
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| notesagent_host::workspace::HostError::new("EXTENSIONS_NOT_READY"))?
|
||||||
|
.runtime_package(&check_slot, &check_vault, check_operation.as_deref())?;
|
||||||
|
if current.active.revision != check_revision
|
||||||
|
|| current.active.target.package_key != check_package
|
||||||
|
{
|
||||||
|
return Err(notesagent_host::workspace::HostError::new(
|
||||||
|
"EXTENSION_INSTALL_CONFLICT",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let endpoint = unsafe {
|
||||||
|
host.extension_instances
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.start(spec)
|
||||||
|
};
|
||||||
|
let endpoint = match endpoint {
|
||||||
|
Ok(endpoint) => endpoint,
|
||||||
|
Err(error) => {
|
||||||
|
rollback_pending(&host, install_operation_id.as_deref());
|
||||||
|
return Err(error.code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
let snapshot = endpoint.snapshot();
|
||||||
|
if snapshot.status == notesagent_host::extension_instance::Status::Ready {
|
||||||
|
if let Some(operation) = &install_operation_id {
|
||||||
|
store(&host, |store| store.finish_installation(operation, true))?;
|
||||||
|
}
|
||||||
|
host.extension_endpoints
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.insert(slot, endpoint);
|
||||||
|
return serde_json::to_value(snapshot).map_err(|_| "EXTENSION_INSTANCE_INVALID".into());
|
||||||
|
}
|
||||||
|
if snapshot.status == notesagent_host::extension_instance::Status::Failed
|
||||||
|
|| Instant::now() >= deadline
|
||||||
|
{
|
||||||
|
endpoint.stop();
|
||||||
|
rollback_pending(&host, install_operation_id.as_deref());
|
||||||
|
return Err(snapshot
|
||||||
|
.error
|
||||||
|
.unwrap_or_else(|| "EXTENSION_START_TIMEOUT".into()));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn extension_instance_status(
|
||||||
|
window: WebviewWindow,
|
||||||
|
host: State<'_, Host>,
|
||||||
|
slot: String,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
main_window(&window)?;
|
||||||
|
let endpoints = host.extension_endpoints.lock().map_err(|_| "HOST_BUSY")?;
|
||||||
|
let endpoint = endpoints.get(&slot).ok_or("EXTENSION_INSTANCE_NOT_READY")?;
|
||||||
|
serde_json::to_value(endpoint.snapshot()).map_err(|_| "EXTENSION_INSTANCE_INVALID".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn extension_disable(
|
||||||
|
window: WebviewWindow,
|
||||||
|
host: State<'_, Host>,
|
||||||
|
slot: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
main_window(&window)?;
|
||||||
|
let endpoint = host
|
||||||
|
.extension_endpoints
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.remove(&slot)
|
||||||
|
.ok_or("EXTENSION_INSTANCE_NOT_READY")?;
|
||||||
|
endpoint.stop();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn extension_call_review(
|
||||||
|
window: WebviewWindow,
|
||||||
|
host: State<'_, Host>,
|
||||||
|
slot: String,
|
||||||
|
tool: String,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
main_window(&window)?;
|
||||||
|
let endpoint = host
|
||||||
|
.extension_endpoints
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.get(&slot)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("EXTENSION_INSTANCE_NOT_READY")?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let review = endpoint
|
||||||
|
.review(tool, arguments)
|
||||||
|
.map_err(|error| error.code)?
|
||||||
|
.wait(Duration::from_secs(5))
|
||||||
|
.map_err(|error| error.code)?;
|
||||||
|
serde_json::to_value(review).map_err(|_| "EXTENSION_CALL_REVIEW_INVALID".into())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "EXTENSION_CALL_REVIEW_INVALID".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn extension_call_confirm(
|
||||||
|
window: WebviewWindow,
|
||||||
|
host: State<'_, Host>,
|
||||||
|
slot: String,
|
||||||
|
review_id: String,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
main_window(&window)?;
|
||||||
|
let endpoint = host
|
||||||
|
.extension_endpoints
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "HOST_BUSY")?
|
||||||
|
.get(&slot)
|
||||||
|
.cloned()
|
||||||
|
.ok_or("EXTENSION_INSTANCE_NOT_READY")?;
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
endpoint
|
||||||
|
.invoke_confirmed(review_id)
|
||||||
|
.map_err(|error| error.code)?
|
||||||
|
.wait(Duration::from_secs(65))
|
||||||
|
.map_err(|error| error.code)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "EXTENSION_CALL_FAILED".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StageRequest {
|
pub struct StageRequest {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub struct LaunchSpec {
|
|||||||
pub claims: Claims,
|
pub claims: Claims,
|
||||||
pub permit: Permit,
|
pub permit: Permit,
|
||||||
pub authority: Arc<Authority>,
|
pub authority: Arc<Authority>,
|
||||||
pub credentials: Arc<Mutex<CredentialBroker>>,
|
pub credentials: Arc<Mutex<Option<CredentialBroker>>>,
|
||||||
pub vault_id: String,
|
pub vault_id: String,
|
||||||
pub policy_version: String,
|
pub policy_version: String,
|
||||||
pub system_root: PathBuf,
|
pub system_root: PathBuf,
|
||||||
@@ -434,12 +434,15 @@ fn run_with_access(
|
|||||||
.credentials
|
.credentials
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?;
|
.map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?;
|
||||||
|
let credentials = credentials
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| HostError::new("CREDENTIALS_LOCKED"))?;
|
||||||
context.prepare(
|
context.prepare(
|
||||||
&spec.authority,
|
&spec.authority,
|
||||||
&spec.permit,
|
&spec.permit,
|
||||||
&spec.claims,
|
&spec.claims,
|
||||||
&entry,
|
&entry,
|
||||||
&credentials,
|
credentials,
|
||||||
now_ms()?,
|
now_ms()?,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
@@ -448,6 +451,9 @@ fn run_with_access(
|
|||||||
.credentials
|
.credentials
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?;
|
.map_err(|_| HostError::new("CREDENTIALS_LOCKED"))?;
|
||||||
|
let credentials = credentials
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| HostError::new("CREDENTIALS_LOCKED"))?;
|
||||||
let mut lease = spec
|
let mut lease = spec
|
||||||
.authority
|
.authority
|
||||||
.lease(&spec.permit, &spec.claims, now_ms()?)?;
|
.lease(&spec.permit, &spec.claims, now_ms()?)?;
|
||||||
@@ -581,12 +587,14 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let tree = crate::extension_unpack::verify_tree(&dir, &inventory()).unwrap();
|
let tree = crate::extension_unpack::verify_tree(&dir, &inventory()).unwrap();
|
||||||
let authority = Arc::new(Authority::default());
|
let authority = Arc::new(Authority::default());
|
||||||
let credentials = Arc::new(Mutex::new(CredentialBroker::new(
|
let credentials = Arc::new(Mutex::new(Some(CredentialBroker::new(
|
||||||
temp.path().join("credentials.v1"),
|
temp.path().join("credentials.v1"),
|
||||||
)));
|
))));
|
||||||
credentials
|
credentials
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
.unlock(Zeroizing::new(b"instance fixture password".to_vec()))
|
.unlock(Zeroizing::new(b"instance fixture password".to_vec()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let vault_id = uuid::Uuid::new_v4().to_string();
|
let vault_id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -897,7 +905,7 @@ mod tests {
|
|||||||
let locked = unsafe { registry.start(make("mcp")) }.unwrap();
|
let locked = unsafe { registry.start(make("mcp")) }.unwrap();
|
||||||
wait_for(|| locked.snapshot().status != Status::Starting);
|
wait_for(|| locked.snapshot().status != Status::Starting);
|
||||||
assert_eq!(locked.snapshot().status, Status::Ready);
|
assert_eq!(locked.snapshot().status, Status::Ready);
|
||||||
credentials.lock().unwrap().lock();
|
credentials.lock().unwrap().as_mut().unwrap().lock();
|
||||||
wait_for(|| {
|
wait_for(|| {
|
||||||
registry.reap();
|
registry.reap();
|
||||||
registry.entries.is_empty()
|
registry.entries.is_empty()
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ pub struct InstallPreview {
|
|||||||
pub dependencies: crate::extension_dependencies::Plan,
|
pub dependencies: crate::extension_dependencies::Plan,
|
||||||
pub changes: Vec<crate::extension_transaction::Change>,
|
pub changes: Vec<crate::extension_transaction::Change>,
|
||||||
}
|
}
|
||||||
|
pub struct RuntimePackage {
|
||||||
|
pub package: cap_std::fs::Dir,
|
||||||
|
pub inventory: crate::extension_package::Inventory,
|
||||||
|
pub release: Release,
|
||||||
|
pub manifest: serde_json::Value,
|
||||||
|
pub active: crate::extension_transaction::Active,
|
||||||
|
pub source: String,
|
||||||
|
pub signer_sha256: String,
|
||||||
|
}
|
||||||
pub struct ExtensionStore {
|
pub struct ExtensionStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
db: Connection,
|
db: Connection,
|
||||||
@@ -851,6 +860,91 @@ impl ExtensionStore {
|
|||||||
crate::extension_transaction::active(&self.db, slot)
|
crate::extension_transaction::active(&self.db, slot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重新验证活动指针、签名、信任、配置和展开树,并返回只能由 Host 消费的运行材料。
|
||||||
|
pub fn runtime_package(
|
||||||
|
&self,
|
||||||
|
slot: &str,
|
||||||
|
vault_id: &str,
|
||||||
|
pending_operation: Option<&str>,
|
||||||
|
) -> Result<RuntimePackage> {
|
||||||
|
use cap_fs_ext::DirExt;
|
||||||
|
let vault = Uuid::parse_str(vault_id)
|
||||||
|
.map_err(|_| HostError::new("VAULT_INVALID"))?
|
||||||
|
.to_string();
|
||||||
|
if vault != vault_id {
|
||||||
|
return Err(HostError::new("VAULT_INVALID"));
|
||||||
|
}
|
||||||
|
let active = self
|
||||||
|
.active_installation(slot)?
|
||||||
|
.filter(|item| item.pending_operation.as_deref() == pending_operation)
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_NOT_ACTIVE"))?;
|
||||||
|
let (source, release_json, signer, manifest_json, directory, tree): (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Vec<u8>,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
) = self.db.query_row(
|
||||||
|
"SELECT v.source,v.release,v.signer,v.manifest,p.directory,p.tree_sha256 FROM versions v JOIN prepared_packages p ON p.package_key=v.package_key WHERE v.package_key=?1",
|
||||||
|
[&active.target.package_key],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
|
||||||
|
)?;
|
||||||
|
let release: Release = serde_json::from_str(&release_json)
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||||
|
let manifest: serde_json::Value = serde_json::from_str(&manifest_json)
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||||
|
let public: [u8; 32] = signer
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
|
||||||
|
let expected_slot = hash(
|
||||||
|
&serde_json::to_vec(&(&vault, &source, &release.namespace, &release.package_id))
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
if slot != expected_slot
|
||||||
|
|| active.target.directory != directory
|
||||||
|
|| active.target.tree_sha256 != tree
|
||||||
|
{
|
||||||
|
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
|
||||||
|
}
|
||||||
|
let trusted = self
|
||||||
|
.trust_setting(&source, &release.namespace, &release.key_id)?
|
||||||
|
.ok_or_else(|| HostError::new("EXTENSION_SOURCE_UNTRUSTED"))?;
|
||||||
|
if !trusted.enabled || trusted.public_key != public {
|
||||||
|
return Err(HostError::new("EXTENSION_SOURCE_UNTRUSTED"));
|
||||||
|
}
|
||||||
|
self.check_not_revoked(&source, &release, &public)?;
|
||||||
|
let archive = self.archive(&active.target.package_key)?;
|
||||||
|
let (inventory, verified_manifest) = release.verify_package(
|
||||||
|
&public,
|
||||||
|
&release.key_id,
|
||||||
|
&release.namespace,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
&archive,
|
||||||
|
)?;
|
||||||
|
if verified_manifest != manifest {
|
||||||
|
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
|
||||||
|
}
|
||||||
|
crate::extension_config::validate(&manifest, &active.target.configuration)?;
|
||||||
|
let root = cap_std::fs::Dir::open_ambient_dir(&self.root, cap_std::ambient_authority())?;
|
||||||
|
let package = root
|
||||||
|
.open_dir_nofollow("prepared")?
|
||||||
|
.open_dir_nofollow(&directory)?;
|
||||||
|
if crate::extension_unpack::verify_tree(&package, &inventory)? != tree {
|
||||||
|
return Err(HostError::new("EXTENSION_STORE_CORRUPT"));
|
||||||
|
}
|
||||||
|
Ok(RuntimePackage {
|
||||||
|
package,
|
||||||
|
inventory,
|
||||||
|
release,
|
||||||
|
manifest,
|
||||||
|
active,
|
||||||
|
source,
|
||||||
|
signer_sha256: hash(&public),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rollback_changes(
|
pub fn rollback_changes(
|
||||||
&self,
|
&self,
|
||||||
operation: &str,
|
operation: &str,
|
||||||
@@ -1620,6 +1714,20 @@ mod tests {
|
|||||||
let operation = Uuid::new_v4().to_string();
|
let operation = Uuid::new_v4().to_string();
|
||||||
store.switch_prepared(&operation, &vault, &changes).unwrap();
|
store.switch_prepared(&operation, &vault, &changes).unwrap();
|
||||||
store.finish_installation(&operation, true).unwrap();
|
store.finish_installation(&operation, true).unwrap();
|
||||||
|
let setting = TrustSetting {
|
||||||
|
source: "https://catalog.example/".into(),
|
||||||
|
source_id: "catalog".into(),
|
||||||
|
namespace: release.namespace.clone(),
|
||||||
|
key_id: release.key_id.clone(),
|
||||||
|
public_key: key,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.confirm_trust(&setting, None, &setting.fingerprint().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let runtime = store.runtime_package(&slot, &vault, None).unwrap();
|
||||||
|
assert_eq!(runtime.active.target, changes[0].target);
|
||||||
|
assert_eq!(runtime.release.package_id, release.package_id);
|
||||||
drop(store);
|
drop(store);
|
||||||
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
let mut store = ExtensionStore::open(temp.path()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ struct Host {
|
|||||||
extensions: Arc<Mutex<Option<notesagent_host::extension_store::ExtensionStore>>>,
|
extensions: Arc<Mutex<Option<notesagent_host::extension_store::ExtensionStore>>>,
|
||||||
extension_reviews: extension_commands::Reviews,
|
extension_reviews: extension_commands::Reviews,
|
||||||
extension_requests: Requests,
|
extension_requests: Requests,
|
||||||
extension_authority: notesagent_host::extension_permit::Authority,
|
extension_authority: Arc<notesagent_host::extension_permit::Authority>,
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
extension_instances: Mutex<notesagent_host::extension_instance::Registry>,
|
extension_instances: Mutex<notesagent_host::extension_instance::Registry>,
|
||||||
|
#[cfg(windows)]
|
||||||
|
extension_endpoints: Mutex<HashMap<String, notesagent_host::extension_instance::Endpoint>>,
|
||||||
credential_signal: std::sync::OnceLock<Arc<std::sync::atomic::AtomicU64>>,
|
credential_signal: std::sync::OnceLock<Arc<std::sync::atomic::AtomicU64>>,
|
||||||
sync: Arc<sync_commands::Runtime>,
|
sync: Arc<sync_commands::Runtime>,
|
||||||
workspace: Arc<Mutex<Option<Workspace>>>,
|
workspace: Arc<Mutex<Option<Workspace>>>,
|
||||||
@@ -51,6 +53,10 @@ impl Host {
|
|||||||
if let Ok(mut instances) = self.extension_instances.lock() {
|
if let Ok(mut instances) = self.extension_instances.lock() {
|
||||||
instances.stop_all_and_join();
|
instances.stop_all_and_join();
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(mut endpoints) = self.extension_endpoints.lock() {
|
||||||
|
endpoints.clear();
|
||||||
|
}
|
||||||
self.sync.cancel();
|
self.sync.cancel();
|
||||||
*active = next;
|
*active = next;
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,10 @@ impl Host {
|
|||||||
if let Ok(mut instances) = self.extension_instances.lock() {
|
if let Ok(mut instances) = self.extension_instances.lock() {
|
||||||
instances.stop_all_and_join();
|
instances.stop_all_and_join();
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
if let Ok(mut endpoints) = self.extension_endpoints.lock() {
|
||||||
|
endpoints.clear();
|
||||||
|
}
|
||||||
if let Some(signal) = self.credential_signal.get() {
|
if let Some(signal) = self.credential_signal.get() {
|
||||||
signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1087,11 @@ fn main() {
|
|||||||
extension_install_confirm,
|
extension_install_confirm,
|
||||||
extension_install_rollback,
|
extension_install_rollback,
|
||||||
extension_uninstall,
|
extension_uninstall,
|
||||||
|
extension_enable,
|
||||||
|
extension_instance_status,
|
||||||
|
extension_disable,
|
||||||
|
extension_call_review,
|
||||||
|
extension_call_confirm,
|
||||||
extension_stage,
|
extension_stage,
|
||||||
extension_stage_prepare,
|
extension_stage_prepare,
|
||||||
extension_stage_cancel,
|
extension_stage_cancel,
|
||||||
|
|||||||
Reference in New Issue
Block a user