feat(desktop): 暴露限定范围的扩展信任审阅与安装预览
This commit is contained in:
@@ -260,3 +260,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- install_confirmed 首次执行重新生成预览并比较确认摘要,再进入在线复核。schema 6 保存操作 ID 对应的请求摘要、确认摘要和冻结切换组,网络或进程中断重试沿用原组;改变请求/确认并重用 ID 拒绝。重试仍经过来源/撤销/归档/配置检查和活动 revision CAS。
|
||||
- 31 项扩展回归与全目标 Clippy -D warnings 通过,日志 `.build/extension-install-preview-tests.log`。新增预览重开摘要稳定、应用版本/活动状态变化使预览失效、错误确认在联网前失败、未知配置拒绝,以及确认计划重开重放不重新选择内容的测试。
|
||||
- 真实主窗口确认 UI、Host 注入的应用/平台信息、旧实例停机、沙箱迁移和健康探测仍待接入。确认摘要 API 本身不证明用户授权;本轮没有执行第三方包或宣称整体生产化完成。
|
||||
|
||||
|
||||
## 增量:受控桌面信任确认与预览命令
|
||||
|
||||
- Host 启动时打开应用数据目录 extensions-host 安装库并持有独占锁;新增 extension_trust_review / extension_trust_confirm / extension_install_preview 命令,Tauri ACL 仅授予本地主窗口,命令额外核对 main 标签,未增加远程来源权限。
|
||||
- 信任复核把完整候选、原 revision 和摘要保存在 Host 内存,返回随机 review_id。最多 64 个待确认项、两分钟单调时钟有效期;确认仅接收 ID 和摘要,不能替换候选。成功消耗凭据,过期/重用/不匹配及并发设置改变拒绝。
|
||||
- 安装预览由 Host 注入应用版本、OS 与架构,renderer 只提交根包、当前 Vault 和配置;后台阻塞任务中保持 Vault 绑定稳定并调用完整预览。尚未开放实际安装执行命令。
|
||||
- 桌面二进制 5 项测试和全目标 Clippy -D warnings 通过,日志 `.build/extension-commands-tests.log`。新增确认凭据生命周期与并发冲突测试;ACL 文件及生成权限项已更新。
|
||||
- 前端确认对话框、桌面包下载/暂存入口、安装执行编排、沙箱及真实 UI 验收尚待完成;extensions capability 仍关闭。本轮不宣称生产化或 D-04 完成。
|
||||
|
||||
@@ -16,6 +16,9 @@ fn main() {
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||
tauri_build::AppManifest::new().commands(&[
|
||||
"host_capabilities",
|
||||
"extension_trust_review",
|
||||
"extension_trust_confirm",
|
||||
"extension_install_preview",
|
||||
"record_get",
|
||||
"record_write",
|
||||
"sync_login",
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
"allow-sync-run",
|
||||
"allow-sync-preview",
|
||||
"allow-record-get",
|
||||
"allow-record-write"
|
||||
"allow-record-write",
|
||||
"allow-extension-trust-review",
|
||||
"allow-extension-trust-confirm",
|
||||
"allow-extension-install-preview"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-extension-install-preview"
|
||||
description = "Enables the extension_install_preview command without any pre-configured scope."
|
||||
commands.allow = ["extension_install_preview"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-extension-install-preview"
|
||||
description = "Denies the extension_install_preview command without any pre-configured scope."
|
||||
commands.deny = ["extension_install_preview"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-extension-trust-confirm"
|
||||
description = "Enables the extension_trust_confirm command without any pre-configured scope."
|
||||
commands.allow = ["extension_trust_confirm"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-extension-trust-confirm"
|
||||
description = "Denies the extension_trust_confirm command without any pre-configured scope."
|
||||
commands.deny = ["extension_trust_confirm"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Automatically generated - DO NOT EDIT!
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-extension-trust-review"
|
||||
description = "Enables the extension_trust_review command without any pre-configured scope."
|
||||
commands.allow = ["extension_trust_review"]
|
||||
|
||||
[[permission]]
|
||||
identifier = "deny-extension-trust-review"
|
||||
description = "Denies the extension_trust_review command without any pre-configured scope."
|
||||
commands.deny = ["extension_trust_review"]
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Only the local main window may review Host trust or prepared installations.
|
||||
use super::Host;
|
||||
use notesagent_host::extension_store::{ExtensionStore, InstallRequest, TrustSetting};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{State, WebviewWindow};
|
||||
|
||||
pub struct Review {
|
||||
setting: TrustSetting,
|
||||
expected: Option<String>,
|
||||
fingerprint: String,
|
||||
expires: Instant,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Reviews(pub Mutex<HashMap<String, Review>>);
|
||||
fn main_window(window: &WebviewWindow) -> Result<(), String> {
|
||||
if window.label() != "main" {
|
||||
return Err("EXTENSION_WINDOW_DENIED".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn store<T>(
|
||||
host: &Host,
|
||||
f: impl FnOnce(&mut ExtensionStore) -> notesagent_host::workspace::Result<T>,
|
||||
) -> Result<T, String> {
|
||||
let mut guard = host.extensions.lock().map_err(|_| "HOST_BUSY")?;
|
||||
f(guard.as_mut().ok_or("EXTENSIONS_NOT_READY")?).map_err(|e| e.code)
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn extension_trust_review(
|
||||
window: WebviewWindow,
|
||||
host: State<'_, Host>,
|
||||
setting: TrustSetting,
|
||||
) -> Result<Value, String> {
|
||||
main_window(&window)?;
|
||||
trust_review(&host, setting)
|
||||
}
|
||||
fn trust_review(host: &Host, setting: TrustSetting) -> Result<Value, String> {
|
||||
let fingerprint = setting.fingerprint().map_err(|e| e.code)?;
|
||||
let previous = store(host, |s| {
|
||||
s.trust_setting(&setting.source, &setting.namespace, &setting.key_id)
|
||||
})?;
|
||||
let expected = previous
|
||||
.as_ref()
|
||||
.map(TrustSetting::fingerprint)
|
||||
.transpose()
|
||||
.map_err(|e| e.code)?;
|
||||
let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?;
|
||||
reviews.retain(|_, r| r.expires > Instant::now());
|
||||
if reviews.len() >= 64 {
|
||||
return Err("EXTENSION_REVIEW_LIMIT".into());
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
reviews.insert(
|
||||
id.clone(),
|
||||
Review {
|
||||
setting: setting.clone(),
|
||||
expected,
|
||||
fingerprint: fingerprint.clone(),
|
||||
expires: Instant::now() + Duration::from_secs(120),
|
||||
},
|
||||
);
|
||||
Ok(json!({"review_id":id,"fingerprint":fingerprint,"previous":previous,"proposed":setting}))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Confirmation {
|
||||
review_id: String,
|
||||
fingerprint: String,
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn extension_trust_confirm(
|
||||
window: WebviewWindow,
|
||||
host: State<'_, Host>,
|
||||
request: Confirmation,
|
||||
) -> Result<String, String> {
|
||||
main_window(&window)?;
|
||||
trust_confirm(&host, request)
|
||||
}
|
||||
fn trust_confirm(host: &Host, request: Confirmation) -> Result<String, String> {
|
||||
let mut reviews = host.extension_reviews.0.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let review = reviews
|
||||
.get(&request.review_id)
|
||||
.ok_or("EXTENSION_REVIEW_EXPIRED")?;
|
||||
if review.expires <= Instant::now() {
|
||||
reviews.remove(&request.review_id);
|
||||
return Err("EXTENSION_REVIEW_EXPIRED".into());
|
||||
}
|
||||
if review.fingerprint != request.fingerprint {
|
||||
return Err("EXTENSION_TRUST_CONFIRMATION".into());
|
||||
}
|
||||
let revision = store(host, |s| {
|
||||
s.confirm_trust(
|
||||
&review.setting,
|
||||
review.expected.as_deref(),
|
||||
&review.fingerprint,
|
||||
)
|
||||
})?;
|
||||
reviews.remove(&request.review_id);
|
||||
Ok(revision)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Preview {
|
||||
root_key: String,
|
||||
vault_id: String,
|
||||
configurations: std::collections::BTreeMap<String, Value>,
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn extension_install_preview(
|
||||
window: WebviewWindow,
|
||||
host: State<'_, Host>,
|
||||
request: Preview,
|
||||
) -> Result<Value, String> {
|
||||
main_window(&window)?;
|
||||
let workspace = host.workspace.clone();
|
||||
let extensions = host.extensions.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Keep the workspace binding stable until this preview finishes.
|
||||
let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?;
|
||||
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != request.vault_id {
|
||||
return Err("VAULT_CHANGED".into());
|
||||
}
|
||||
let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let request = InstallRequest {
|
||||
root_key: request.root_key,
|
||||
vault_id: request.vault_id,
|
||||
app_version: env!("CARGO_PKG_VERSION").into(),
|
||||
platform: std::env::consts::OS.into(),
|
||||
architecture: std::env::consts::ARCH.into(),
|
||||
configurations: request.configurations,
|
||||
};
|
||||
let preview = store
|
||||
.as_mut()
|
||||
.ok_or("EXTENSIONS_NOT_READY")?
|
||||
.installation_preview(&request)
|
||||
.map_err(|e| e.code)?;
|
||||
serde_json::to_value(preview).map_err(|_| "EXTENSION_PREVIEW_INVALID".into())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn review_nonce_expiry_consumption_and_stale_setting_are_enforced() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let host = Host::default();
|
||||
*host.extensions.lock().unwrap() = Some(ExtensionStore::open(temp.path()).unwrap());
|
||||
let setting = TrustSetting {
|
||||
source: "https://catalog.example/".into(),
|
||||
source_id: "catalog".into(),
|
||||
namespace: "examples".into(),
|
||||
key_id: "key".into(),
|
||||
public_key: ed25519_dalek::SigningKey::from_bytes(&[7; 32])
|
||||
.verifying_key()
|
||||
.to_bytes(),
|
||||
enabled: true,
|
||||
};
|
||||
let review = trust_review(&host, setting.clone()).unwrap();
|
||||
let id = review["review_id"].as_str().unwrap();
|
||||
let fingerprint = review["fingerprint"].as_str().unwrap();
|
||||
assert!(trust_confirm(
|
||||
&host,
|
||||
Confirmation {
|
||||
review_id: id.into(),
|
||||
fingerprint: "wrong".into()
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
trust_confirm(
|
||||
&host,
|
||||
Confirmation {
|
||||
review_id: id.into(),
|
||||
fingerprint: fingerprint.into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(trust_confirm(
|
||||
&host,
|
||||
Confirmation {
|
||||
review_id: id.into(),
|
||||
fingerprint: fingerprint.into()
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
let mut changed = setting.clone();
|
||||
changed.enabled = false;
|
||||
let review = trust_review(&host, changed).unwrap();
|
||||
let id = review["review_id"].as_str().unwrap();
|
||||
host.extension_reviews
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut(id)
|
||||
.unwrap()
|
||||
.expires = Instant::now() - Duration::from_secs(1);
|
||||
assert!(trust_confirm(
|
||||
&host,
|
||||
Confirmation {
|
||||
review_id: id.into(),
|
||||
fingerprint: review["fingerprint"].as_str().unwrap().into()
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
assert!(host.extension_reviews.0.lock().unwrap().get(id).is_none());
|
||||
let mut changed = setting.clone();
|
||||
changed.public_key = ed25519_dalek::SigningKey::from_bytes(&[8; 32])
|
||||
.verifying_key()
|
||||
.to_bytes();
|
||||
let review = trust_review(&host, changed).unwrap();
|
||||
let mut concurrent = setting.clone();
|
||||
concurrent.enabled = false;
|
||||
store(&host, |s| {
|
||||
s.confirm_trust(
|
||||
&concurrent,
|
||||
Some(&setting.fingerprint()?),
|
||||
&concurrent.fingerprint()?,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(trust_confirm(
|
||||
&host,
|
||||
Confirmation {
|
||||
review_id: review["review_id"].as_str().unwrap().into(),
|
||||
fingerprint: review["fingerprint"].as_str().unwrap().into()
|
||||
}
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||
|
||||
mod extension_commands;
|
||||
use extension_commands::*;
|
||||
|
||||
mod record_commands;
|
||||
use record_commands::*;
|
||||
mod sync_commands;
|
||||
@@ -23,6 +26,8 @@ use zeroize::Zeroizing;
|
||||
#[derive(Default)]
|
||||
struct Host {
|
||||
requests: Requests,
|
||||
extensions: Arc<Mutex<Option<notesagent_host::extension_store::ExtensionStore>>>,
|
||||
extension_reviews: extension_commands::Reviews,
|
||||
sync: Arc<sync_commands::Runtime>,
|
||||
workspace: Arc<Mutex<Option<Workspace>>>,
|
||||
recent: Mutex<Option<RecentVaultStore>>,
|
||||
@@ -708,6 +713,15 @@ fn main() {
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? =
|
||||
Some(RecentVaultStore::open(&state_path).map_err(std::io::Error::other)?);
|
||||
let extension_root = app.path().app_data_dir()?.join("extensions-host");
|
||||
std::fs::create_dir_all(&extension_root)?;
|
||||
*app.state::<Host>()
|
||||
.extensions
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(
|
||||
notesagent_host::extension_store::ExtensionStore::open(&extension_root)
|
||||
.map_err(|error| std::io::Error::other(error.code))?,
|
||||
);
|
||||
let credential_state = app.state::<Host>().credentials.clone();
|
||||
*credential_state
|
||||
.lock()
|
||||
@@ -833,6 +847,9 @@ fn main() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
host_capabilities,
|
||||
extension_trust_review,
|
||||
extension_trust_confirm,
|
||||
extension_install_preview,
|
||||
record_get,
|
||||
record_write,
|
||||
sync_login,
|
||||
|
||||
Reference in New Issue
Block a user