From 96778c02f36857ef1a8a55bc07c05c451ed5b73e Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 01:10:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=B0=86=E5=8D=95=E6=AC=A1?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=AE=A1=E9=98=85=E7=BB=91=E5=AE=9A=E5=88=B0?= =?UTF-8?q?=E5=B7=B2=E9=AA=8C=E8=AF=81=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenNexus生产化实施进度-2026-09-08.md | 12 + .../src/extension_call_authorization.rs | 278 ++++++++++++++++++ frontend/src-tauri/src/extension_container.rs | 56 +++- .../src/extension_launch_authorization.rs | 7 +- frontend/src-tauri/src/extension_mcp.rs | 59 +++- frontend/src-tauri/src/extension_mcp_tools.rs | 3 + frontend/src-tauri/src/extension_process.rs | 15 +- frontend/src-tauri/src/lib.rs | 3 + 8 files changed, 421 insertions(+), 12 deletions(-) create mode 100644 frontend/src-tauri/src/extension_call_authorization.rs diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index e6c8574..6495133 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -507,3 +507,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 单元测试覆盖分页、重复、循环、总量/页数限制,必填项/类型/额外参数、结构化结果、无效 base64、所有引用类型、未知方言/不支持正则/任务要求与过深输入。真实 AppContainer 测试新增两页目录、发现前/未知名/无效参数拒绝、错误 structuredContent、调用中及空闲时工具变更;全部经实际原生 Session/Pump 运行。 - 62 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个需显式执行的长时验收,本轮未重跑长时项;上一轮实际 MCP 60 秒验收仍为其当时提交的证据。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-mcp-catalog-tests.log`、`.build/extension-mcp-catalog-clippy.log`。协议依据 [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。 - 工具发现和校验不等于用户批准执行。生产入口仍需完成实际安装实例注册、来源/活动版本/在线信任与工具目录摘要绑定、用户调用授权及 Core 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。 + + +## 增量:实例身份与一次性工具调用预览凭据 + +- Context.prepare 在现有许可、入口和上下文验证完成后生成独立实例 UUID,以及覆盖完整 Claims 的执行摘要;用于摘要的临时序列化字节由 Zeroizing 清理。公开身份预览只包含执行类型、来源/命名空间/包/版本、Vault、权限集和摘要,不包含环境秘密或原始启动参数。 +- PreparedLaunch → LeasedSuspended → Running 持续携带该身份,带租约恢复方法收为 crate 内部。Session.new 要求运行实例具有这条准备链产生的身份,普通低层 resume 的受控测试进程不能直接进入该 MCP 会话入口。 +- 新增内存 Gate,生成包含真实工具描述、冻结参数、实例身份和工具契约摘要的 Review;最多 64 条待确认预览、合计 2 MiB 待确认参数,120 秒单调时钟有效期。确认会消费预览 ID,不能重复确认;过期和目录失效清理待确认项,已确认对象也绑定原到期时间。 +- 正式 Session.call_tool 现在只接受 ApprovedCall。该类型字段私有、不可 Clone、不可序列化/反序列化,工具名及参数不能在确认后另行替换;调用消费该对象并核对实例、目录代次和当前契约摘要,再进入参数/结果校验、运行租约及期限链路。按名称/参数直接调用的方法收为私有,只有 cfg(test) 辅助方法能模拟本测试用户确认。 +- 目录刷新开始、已观察到工具变更及会话失败均作废旧预览和目录代次;即使工具名字和 schema 恢复成原值,旧已确认对象也不能重新使用。此处是 Host 观察和持有的目录版本绑定,不是对服务端执行语义永不变化或核验到发送间“零竞态”的证明。 +- 单元测试覆盖预览对象被改写后实际参数仍冻结、重复确认、跨实例、目录代次、确认前/后过期、条数/字节上限及清理后复用。真实 AppContainer 测试核对预览来源/包/Vault,修改展示副本不会改变实际请求,旧目录凭据被拒绝;新增确认后锁定保险库导致 CREDENTIALS_LOCKED,重新解锁也不能恢复已失效会话。 +- 64 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个显式长时验收,本轮未重跑长时项。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-call-binding-tests.log`、`.build/extension-call-binding-clippy.log`。 +- 当前 confirm_call 仍只是 Host 内部组件接口,未登记为 renderer/Core 命令,也没有自动批准策略或真实用户确认 UI。完整产品入口必须通过经过认证的 Host 路由确认用户批准,再与活动安装/在线信任、实例注册和 Core 路由组合;全部沙箱资源与其他生产化验收仍未完成,第三方执行能力保持禁用。 diff --git a/frontend/src-tauri/src/extension_call_authorization.rs b/frontend/src-tauri/src/extension_call_authorization.rs new file mode 100644 index 0000000..7ab80f4 --- /dev/null +++ b/frontend/src-tauri/src/extension_call_authorization.rs @@ -0,0 +1,278 @@ +//! Host-memory call reviews. The UI/registry must establish actual user consent +//! before confirm; no renderer command or automatic-consent policy is added here. +use crate::{ + extension_mcp_tools::{Description, Tool}, + extension_permit::{Claims, ExecutionKind}, + workspace::{HostError, Result}, +}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; +#[derive(Clone, Serialize)] +pub struct Identity { + instance_id: String, + kind: ExecutionKind, + source: String, + namespace: String, + package_id: String, + version: String, + vault_id: String, + permissions: BTreeSet, + execution_digest: String, +} +impl Identity { + /// Only called after launch permit/entry/context validation. + pub(crate) fn from_claims(claims: &Claims) -> Result { + let bytes = Zeroizing::new( + serde_json::to_vec(claims) + .map_err(|_| HostError::new("EXTENSION_CALL_BINDING_INVALID"))?, + ); + let mut hash = Sha256::new(); + hash.update(b"OpenNexus execution identity v1\0"); + hash.update(bytes.as_slice()); + Ok(Self { + instance_id: uuid::Uuid::new_v4().to_string(), + kind: claims.kind, + source: claims.source.clone(), + namespace: claims.namespace.clone(), + package_id: claims.package_id.clone(), + version: claims.version.clone(), + vault_id: claims.vault_id.clone(), + permissions: claims.permissions.clone(), + execution_digest: format!("{:x}", hash.finalize()), + }) + } +} +#[derive(Serialize)] +pub struct Review { + pub review_id: String, + pub identity: Identity, + pub tool: Description, + pub arguments: Value, + pub contract_digest: String, + pub valid_for_seconds: u64, +} +struct Pending { + name: String, + arguments: Value, + contract_digest: String, + expires: Instant, + bytes: usize, +} +/// An in-process, non-cloneable, non-serializable, single-consumption capability. +/// Tool name and arguments cannot be replaced after review confirmation. +pub struct ApprovedCall { + instance: String, + epoch: String, + call: Pending, +} +pub(crate) struct Invocation { + pub name: String, + pub arguments: Value, + pub contract_digest: String, +} +pub(crate) struct Gate { + identity: Identity, + epoch: String, + pending: BTreeMap, +} +impl Gate { + pub(crate) fn new(identity: Identity) -> Self { + Self { + identity, + epoch: uuid::Uuid::new_v4().to_string(), + pending: BTreeMap::new(), + } + } + pub(crate) fn invalidate(&mut self) { + self.epoch = uuid::Uuid::new_v4().to_string(); + self.pending.clear(); + } + pub(crate) fn review(&mut self, tool: &Tool, arguments: Value) -> Result { + tool.validate_arguments(&arguments)?; + self.pending + .retain(|_, pending| pending.expires > Instant::now()); + let bytes = serde_json::to_vec(&arguments) + .map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))? + .len(); + if self.pending.len() >= 64 + || self.pending.values().map(|p| p.bytes).sum::() + bytes > 2 * 1024 * 1024 + { + return Err(HostError::new("EXTENSION_CALL_REVIEW_LIMIT")); + } + let tool = tool.description(); + let contract_digest = contract_digest(&tool)?; + let review_id = uuid::Uuid::new_v4().to_string(); + self.pending.insert( + review_id.clone(), + Pending { + name: tool.name.clone(), + arguments: arguments.clone(), + contract_digest: contract_digest.clone(), + expires: Instant::now() + Duration::from_secs(120), + bytes, + }, + ); + Ok(Review { + review_id, + identity: self.identity.clone(), + tool, + arguments, + contract_digest, + valid_for_seconds: 120, + }) + } + /// The authenticated Host approval route must verify user consent first. + pub(crate) fn confirm(&mut self, review_id: &str) -> Result { + let call = self + .pending + .remove(review_id) + .ok_or_else(|| HostError::new("EXTENSION_CALL_REVIEW_UNKNOWN"))?; + if call.expires <= Instant::now() { + return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED")); + } + Ok(ApprovedCall { + instance: self.identity.instance_id.clone(), + epoch: self.epoch.clone(), + call, + }) + } + pub(crate) fn consume(&self, approved: ApprovedCall) -> Result { + if approved.instance != self.identity.instance_id { + return Err(HostError::new("EXTENSION_CALL_INSTANCE_MISMATCH")); + } + if approved.epoch != self.epoch { + return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED")); + } + if approved.call.expires <= Instant::now() { + return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED")); + } + Ok(Invocation { + name: approved.call.name, + arguments: approved.call.arguments, + contract_digest: approved.call.contract_digest, + }) + } +} +pub(crate) fn contract_digest(tool: &Description) -> Result { + let mut hash = Sha256::new(); + hash.update(b"OpenNexus MCP tool contract v1\0"); + hash.update( + serde_json::to_vec(tool).map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))?, + ); + Ok(format!("{:x}", hash.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extension_mcp_tools::Catalog; + use serde_json::json; + fn identity() -> Identity { + Identity { + instance_id: uuid::Uuid::new_v4().to_string(), + kind: ExecutionKind::Mcp, + source: "https://catalog.example/".into(), + namespace: "examples".into(), + package_id: "echo".into(), + version: "1.0.0".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + permissions: BTreeSet::new(), + execution_digest: "a".repeat(64), + } + } + fn tool() -> std::sync::Arc { + Catalog::discover(|_| { + Ok(json!({"tools":[{"name":"echo","inputSchema":{"type":"object"}}]})) + }) + .unwrap() + .tool("echo") + .unwrap() + } + #[test] + fn review_freezes_arguments_and_is_single_use_instance_epoch_and_expiry_bound() { + let mut gate = Gate::new(identity()); + let tool = tool(); + let mut review = gate.review(&tool, json!({"value":"original"})).unwrap(); + review.arguments = json!({"value":"tampered"}); + review.tool.name = "tampered".into(); + let approved = gate.confirm(&review.review_id).unwrap(); + assert_eq!( + gate.confirm(&review.review_id).err().unwrap().code, + "EXTENSION_CALL_REVIEW_UNKNOWN" + ); + let call = gate.consume(approved).unwrap(); + assert_eq!(call.name, "echo"); + assert_eq!(call.arguments, json!({"value":"original"})); + assert_eq!( + call.contract_digest, + contract_digest(&tool.description()).unwrap() + ); + let review = gate.review(&tool, json!({})).unwrap(); + let approved = gate.confirm(&review.review_id).unwrap(); + assert_eq!( + Gate::new(identity()).consume(approved).err().unwrap().code, + "EXTENSION_CALL_INSTANCE_MISMATCH" + ); + let review = gate.review(&tool, json!({})).unwrap(); + let approved = gate.confirm(&review.review_id).unwrap(); + let pending = gate.review(&tool, json!({})).unwrap(); + gate.invalidate(); + assert_eq!( + gate.consume(approved).err().unwrap().code, + "EXTENSION_CALL_CATALOG_CHANGED" + ); + assert_eq!( + gate.confirm(&pending.review_id).err().unwrap().code, + "EXTENSION_CALL_REVIEW_UNKNOWN" + ); + let review = gate.review(&tool, json!({})).unwrap(); + gate.pending.get_mut(&review.review_id).unwrap().expires = Instant::now(); + assert_eq!( + gate.confirm(&review.review_id).err().unwrap().code, + "EXTENSION_CALL_REVIEW_EXPIRED" + ); + let review = gate.review(&tool, json!({})).unwrap(); + let mut approved = gate.confirm(&review.review_id).unwrap(); + approved.call.expires = Instant::now(); + assert_eq!( + gate.consume(approved).err().unwrap().code, + "EXTENSION_CALL_REVIEW_EXPIRED" + ); + } + #[test] + fn pending_review_count_and_bytes_are_bounded_and_invalidated_slots_are_reusable() { + let tool = tool(); + let mut gate = Gate::new(identity()); + for _ in 0..64 { + gate.review(&tool, json!({})).unwrap(); + } + assert_eq!( + gate.review(&tool, json!({})).err().unwrap().code, + "EXTENSION_CALL_REVIEW_LIMIT" + ); + gate.invalidate(); + for _ in 0..10 { + gate.review(&tool, json!({"value":"x".repeat(200_000)})) + .unwrap(); + } + assert_eq!( + gate.review(&tool, json!({"value":"x".repeat(200_000)})) + .err() + .unwrap() + .code, + "EXTENSION_CALL_REVIEW_LIMIT" + ); + for value in gate.pending.values_mut() { + value.expires = Instant::now(); + } + gate.review(&tool, json!({"value":"x".repeat(200_000)})) + .unwrap(); + assert_eq!(gate.pending.len(), 1); + } +} diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index 22460ee..247ea10 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -874,6 +874,7 @@ mod tests { "mcp_pages", "mcp_bad_result", "mcp_idle_change", + "mcp_review_lock", ]; if _mcp_deadline { mcp_modes.push("mcp_deadline"); @@ -898,7 +899,7 @@ mod tests { let cancel = Arc::new(AtomicBool::new(false)); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_NOT_INITIALIZED" @@ -915,7 +916,7 @@ mod tests { ); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_CATALOG_REQUIRED" @@ -923,14 +924,14 @@ mod tests { assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo"); assert_eq!( session - .call_tool("missing", serde_json::json!({}), &cancel) + .test_call_tool("missing", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_TOOL_NOT_FOUND" ); assert_eq!( session - .call_tool("echo", serde_json::json!({"unexpected":1}), &cancel) + .test_call_tool("echo", serde_json::json!({"unexpected":1}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_ARGUMENTS_INVALID" @@ -944,9 +945,42 @@ mod tests { } else { None }; + let mut earlier = None; + if mode == "mcp" { + let review = session.review_call("echo", serde_json::json!({})).unwrap(); + let identity = serde_json::to_value(&review.identity).unwrap(); + assert_eq!(identity["package_id"], mcp.package_id); + assert_eq!(identity["vault_id"], mcp.vault_id); + assert_eq!(identity["source"], mcp.source); + assert_eq!(identity["execution_digest"].as_str().unwrap().len(), 64); + earlier = Some(session.confirm_call(&review.review_id).unwrap()); + } let tool_started = std::time::Instant::now(); - let result = session.call_tool("echo", serde_json::json!({}), &cancel); + let result = if mode == "mcp" { + let mut review = + session.review_call("echo", serde_json::json!({})).unwrap(); + review.arguments = serde_json::json!({"unexpected":1}); + let approved = session.confirm_call(&review.review_id).unwrap(); + session.call_tool(approved, &cancel) + } else if mode == "mcp_review_lock" { + let review = session.review_call("echo", serde_json::json!({})).unwrap(); + let approved = session.confirm_call(&review.review_id).unwrap(); + broker.lock(); + session.call_tool(approved, &cancel) + } else { + session.test_call_tool("echo", serde_json::json!({}), &cancel) + }; match mode { + "mcp_review_lock" => { + assert_eq!(result.unwrap_err().code, "CREDENTIALS_LOCKED"); + broker + .unlock(Zeroizing::new(b"native fixture passphrase".to_vec())) + .unwrap(); + assert_eq!( + session.refresh_tools(&cancel).err().unwrap().code, + "EXTENSION_MCP_SESSION_FAILED" + ); + } "mcp_bad_result" => assert_eq!( result.unwrap_err().code, "EXTENSION_MCP_TOOL_RESULT_INVALID" @@ -956,7 +990,7 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(200)); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_CATALOG_REQUIRED" @@ -1005,7 +1039,7 @@ mod tests { assert_eq!(result.unwrap_err().code, "EXTENSION_MCP_REMOTE_ERROR"); assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap()["content"][0]["text"], "native MCP success" ); @@ -1014,9 +1048,15 @@ mod tests { assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success"); assert!(session.take_tools_changed()); assert!(!session.take_tools_changed()); + if let Some(approved) = earlier { + assert_eq!( + session.call_tool(approved, &cancel).unwrap_err().code, + "EXTENSION_CALL_CATALOG_CHANGED" + ); + } assert_eq!( session - .call_tool("echo", serde_json::json!({}), &cancel) + .test_call_tool("echo", serde_json::json!({}), &cancel) .unwrap_err() .code, "EXTENSION_MCP_CATALOG_REQUIRED" diff --git a/frontend/src-tauri/src/extension_launch_authorization.rs b/frontend/src-tauri/src/extension_launch_authorization.rs index 8911906..8312669 100644 --- a/frontend/src-tauri/src/extension_launch_authorization.rs +++ b/frontend/src-tauri/src/extension_launch_authorization.rs @@ -22,6 +22,7 @@ pub struct Context<'a> { pub struct PreparedLaunch { data: LaunchData, lease: crate::extension_permit::Lease, + identity: crate::extension_call_authorization::Identity, path: std::path::PathBuf, entry: String, tree: String, @@ -29,6 +30,7 @@ pub struct PreparedLaunch { pub struct LeasedSuspended<'a> { process: crate::extension_process::Suspended<'a>, lease: crate::extension_permit::Lease, + identity: crate::extension_call_authorization::Identity, } impl PreparedLaunch { pub fn create_suspended<'a>( @@ -48,6 +50,7 @@ impl PreparedLaunch { Ok(LeasedSuspended { process, lease: self.lease, + identity: self.identity, }) } pub fn create_suspended_with_stdio<'a>( @@ -70,6 +73,7 @@ impl PreparedLaunch { LeasedSuspended { process, lease: self.lease, + identity: self.identity, }, io, )) @@ -80,7 +84,7 @@ impl<'a> LeasedSuspended<'a> { /// Live trust, active installation, broker and all sandbox resource policy /// requirements must also hold. A lease does not establish those conditions. pub unsafe fn resume(self) -> Result> { - unsafe { self.process.resume_with_lease(self.lease) } + unsafe { self.process.resume_with_lease(self.lease, self.identity) } } } struct EnvironmentValues(BTreeMap); @@ -153,6 +157,7 @@ impl Context<'_> { Ok(PreparedLaunch { data, lease, + identity: crate::extension_call_authorization::Identity::from_claims(claims)?, path: entry.path().to_owned(), entry: entry.relative_name().to_owned(), tree: entry.tree_sha256().to_owned(), diff --git a/frontend/src-tauri/src/extension_mcp.rs b/frontend/src-tauri/src/extension_mcp.rs index 930294d..9318d9e 100644 --- a/frontend/src-tauri/src/extension_mcp.rs +++ b/frontend/src-tauri/src/extension_mcp.rs @@ -1,5 +1,5 @@ -//! Serial MCP session over an already-authorized native instance. Package trust, -//! tool consent/schema validation and registry routing remain Host responsibilities. +//! Serial MCP session over an already-authorized native instance. The Host +//! approval route still must establish user consent and current installation/trust. use crate::{ extension_io::{Event, Pump}, extension_process::Running, @@ -93,6 +93,7 @@ pub struct Session<'a, 'p> { failed: bool, tools_changed: bool, catalog: Option, + calls: crate::extension_call_authorization::Gate, } impl<'a, 'p> Session<'a, 'p> { pub fn new(process: &'a Running<'p>, io: HostIo) -> Result { @@ -105,6 +106,7 @@ impl<'a, 'p> Session<'a, 'p> { failed: false, tools_changed: false, catalog: None, + calls: crate::extension_call_authorization::Gate::new(process.call_identity()?), }) } pub fn initialize(&mut self, cancel: &AtomicBool) -> Result { @@ -149,6 +151,7 @@ impl<'a, 'p> Session<'a, 'p> { self.require_tools()?; self.drain_pending()?; self.catalog = None; + self.calls.invalidate(); self.tools_changed = false; let started = Instant::now(); let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| { @@ -201,12 +204,62 @@ impl<'a, 'p> Session<'a, 'p> { } Ok(value) } + pub fn review_call( + &mut self, + name: &str, + arguments: Value, + ) -> Result { + self.require_tools()?; + self.drain_pending()?; + let tool = self + .catalog + .as_ref() + .ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))? + .tool(name)?; + self.calls.review(&tool, arguments) + } + /// Only invoke from an authenticated Host route after the user approved this + /// exact review. This method is not registered as a renderer/Core command. + pub fn confirm_call( + &mut self, + review_id: &str, + ) -> Result { + self.require_tools()?; + self.drain_pending()?; + self.calls.confirm(review_id) + } pub fn call_tool( + &mut self, + approved: crate::extension_call_authorization::ApprovedCall, + cancel: &AtomicBool, + ) -> Result { + self.require_tools()?; + self.drain_pending()?; + let call = self.calls.consume(approved)?; + let tool = self + .catalog + .as_ref() + .ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))? + .tool(&call.name)?; + if crate::extension_call_authorization::contract_digest(&tool.description())? + != call.contract_digest + { + return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED")); + } + self.invoke_tool(&call.name, call.arguments, cancel) + } + #[cfg(test)] + pub(crate) fn test_call_tool( &mut self, name: &str, arguments: Value, cancel: &AtomicBool, ) -> Result { + let review = self.review_call(name, arguments)?; + let approved = self.confirm_call(&review.review_id)?; + self.call_tool(approved, cancel) + } + fn invoke_tool(&mut self, name: &str, arguments: Value, cancel: &AtomicBool) -> Result { self.require_tools()?; self.drain_pending()?; if name.is_empty() @@ -256,6 +309,7 @@ impl<'a, 'p> Session<'a, 'p> { } else if method == "notifications/tools/list_changed" { self.tools_changed = true; self.catalog = None; + self.calls.invalidate(); } Ok(true) } @@ -294,6 +348,7 @@ impl<'a, 'p> Session<'a, 'p> { } fn abort(&mut self) { self.failed = true; + self.calls.invalidate(); let _ = self.process.terminate(); } fn request( diff --git a/frontend/src-tauri/src/extension_mcp_tools.rs b/frontend/src-tauri/src/extension_mcp_tools.rs index a40f612..b8abd70 100644 --- a/frontend/src-tauri/src/extension_mcp_tools.rs +++ b/frontend/src-tauri/src/extension_mcp_tools.rs @@ -77,6 +77,9 @@ pub struct Tool { output: Option, } impl Tool { + pub(crate) fn description(&self) -> Description { + self.description.clone() + } fn parse(value: &Value) -> Result { bounded(value, 192 * 1024, false)?; let name = value["name"] diff --git a/frontend/src-tauri/src/extension_process.rs b/frontend/src-tauri/src/extension_process.rs index b3f318b..0762c20 100644 --- a/frontend/src-tauri/src/extension_process.rs +++ b/frontend/src-tauri/src/extension_process.rs @@ -94,6 +94,8 @@ pub struct Running<'a> { process: Process<'a>, #[cfg(feature = "desktop")] revocation: Option, + #[cfg(feature = "desktop")] + identity: Option, } impl<'a> Suspended<'a> { /// Creates hidden, with no inherited handles and an explicit environment and @@ -244,15 +246,18 @@ impl<'a> Suspended<'a> { process: self.0, #[cfg(feature = "desktop")] revocation: None, + #[cfg(feature = "desktop")] + identity: None, }) } /// # Safety /// The same complete resource/broker/trust preconditions as resume apply. /// This additionally arms revocation monitoring before any instruction resumes. #[cfg(feature = "desktop")] - pub unsafe fn resume_with_lease( + pub(crate) unsafe fn resume_with_lease( self, lease: crate::extension_permit::Lease, + identity: crate::extension_call_authorization::Identity, ) -> Result> { let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?; watch.check()?; @@ -262,12 +267,20 @@ impl<'a> Suspended<'a> { let running = Running { process: self.0, revocation: Some(watch), + identity: Some(identity), }; running.check_authorization()?; Ok(running) } } impl Running<'_> { + #[cfg(feature = "desktop")] + pub(crate) fn call_identity(&self) -> Result { + self.check_authorization()?; + self.identity + .clone() + .ok_or_else(|| HostError::new("EXTENSION_CALL_BINDING_REQUIRED")) + } #[cfg(feature = "desktop")] pub fn start_io( &self, diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index ff3b033..d604c0c 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -90,3 +90,6 @@ pub mod extension_mcp; #[cfg(all(windows, feature = "desktop"))] pub mod extension_mcp_tools; + +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_call_authorization;