From fc09d7a292820c3349062b83daee9ce19ad1b4a6 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 11:34:31 -0400 Subject: [PATCH] =?UTF-8?q?wip(13-08):=20checkpoint=20before=20operator=20?= =?UTF-8?q?session=20restart=20=E2=80=94=20Task=201=20GREEN=20(28/28),=20T?= =?UTF-8?q?ask=202=20in=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executor stopped deliberately for a session restart (bypass-permissions relaunch). Executor's final report: 'cargo test assistant confirm-gate suite 28/28 green, individual nonce test passes; committing Task 1 next — first verify the tools.rs/grants.rs/backends diffs are formatting-only.' Task 1 (D-07/D-11 confirm gate, backend) is implemented and test-green but this checkpoint is verbatim-uncommitted-state, NOT the reviewed atomic Task 1 commit: continuation executor should verify diffs, then reset --soft or commit-on-top into proper feat(13-08) task commits. Task 2 (ToolConfirmModal.vue trusted chrome, Chat.vue + contextBroker.ts wiring) is partially built, tests written. Co-Authored-By: Claude Fable 5 --- .../archipelago/src/api/rpc/assistant_chat.rs | 58 +++ .../archipelago/src/assistant/backends/mod.rs | 7 +- core/archipelago/src/assistant/confirm.rs | 148 +++++++- core/archipelago/src/assistant/grants.rs | 5 +- core/archipelago/src/assistant/loop_.rs | 72 +++- core/archipelago/src/assistant/mod.rs | 42 +- core/archipelago/src/assistant/tools.rs | 53 ++- neode-ui/src/components/ToolConfirmModal.vue | 134 +++++++ .../services/__tests__/toolConfirm.test.ts | 358 ++++++++++++++++++ neode-ui/src/services/contextBroker.ts | 136 +++++++ neode-ui/src/views/Chat.vue | 51 +++ 11 files changed, 1011 insertions(+), 53 deletions(-) create mode 100644 neode-ui/src/components/ToolConfirmModal.vue create mode 100644 neode-ui/src/services/__tests__/toolConfirm.test.ts diff --git a/core/archipelago/src/api/rpc/assistant_chat.rs b/core/archipelago/src/api/rpc/assistant_chat.rs index 82432436..dc33eff0 100644 --- a/core/archipelago/src/api/rpc/assistant_chat.rs +++ b/core/archipelago/src/api/rpc/assistant_chat.rs @@ -28,10 +28,68 @@ impl RpcHandler { "assistant.list-tools" => self.handle_assistant_list_tools().await, "assistant.grants-get" => self.handle_assistant_grants_get().await, "assistant.grants-set" => self.handle_assistant_grants_set(params).await, + "assistant.pending" => self.handle_assistant_pending().await, + "assistant.confirm-tool" => self.handle_assistant_confirm_tool(params).await, other => anyhow::bail!("no such assistant method: {other}"), } } + /// assistant.pending — the current pending destructive-tool + /// confirmation, if any: the node-authored description and the + /// node-minted nonce. This is how the trusted chrome *fetches* the + /// dialog text over the authenticated RPC session rather than + /// receiving it from the iframe (D-11) — the iframe has no path into + /// this text and no way to answer it. + async fn handle_assistant_pending(self: &Arc) -> Result { + let gate = crate::assistant::confirm::global(); + Ok(match gate.peek() { + Some(p) => serde_json::json!({ + "pending": { + "req_id": p.req_id, + "nonce": p.nonce, + "description": p.description, + "tool_name": p.tool_name, + } + }), + None => serde_json::json!({ "pending": null }), + }) + } + + /// assistant.confirm-tool — resolve a pending confirmation. Params: + /// `{ "req_id": string, "nonce": string, "approved": bool }`. The + /// nonce must be the node-minted one for exactly that pending action + /// (S-02); a mismatched or replayed nonce is refused loudly — a + /// mismatch can only mean a replay attempt or a trusted-chrome bug, so + /// it is logged at error level and surfaced to the caller as an error, + /// not swallowed as a toast. + async fn handle_assistant_confirm_tool( + self: &Arc, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let req_id = params + .get("req_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing req_id"))?; + let nonce = params + .get("nonce") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing nonce"))?; + let approved = params + .get("approved") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("Missing approved"))?; + + let gate = crate::assistant::confirm::global(); + match gate.resolve(req_id, nonce, approved) { + Ok(()) => Ok(serde_json::json!({ "resolved": true, "approved": approved })), + Err(refusal) => { + tracing::error!(%req_id, %refusal, "assistant.confirm-tool refused"); + anyhow::bail!("confirmation refused: {refusal}") + } + } + } + /// assistant.list-tools — the tools currently visible to the local /// operator (i.e. whose category is currently granted), each with its /// category and destructive flag, so neode-ui can render an honest diff --git a/core/archipelago/src/assistant/backends/mod.rs b/core/archipelago/src/assistant/backends/mod.rs index 9c0366df..6c71a2a2 100644 --- a/core/archipelago/src/assistant/backends/mod.rs +++ b/core/archipelago/src/assistant/backends/mod.rs @@ -21,7 +21,12 @@ pub enum BackendTurn { #[async_trait] pub trait Backend: Send + Sync { - async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result; + async fn send( + &self, + system: &str, + tools: &[ToolDef], + history: &[ChatMessage], + ) -> Result; } /// D-04's backend chain: local Ollama first (node data never leaves the diff --git a/core/archipelago/src/assistant/confirm.rs b/core/archipelago/src/assistant/confirm.rs index 7bcb0485..9da381a5 100644 --- a/core/archipelago/src/assistant/confirm.rs +++ b/core/archipelago/src/assistant/confirm.rs @@ -23,7 +23,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; +use rand::RngCore; use serde_json::json; +use sha2::{Digest, Sha256}; use tokio::sync::oneshot; use tokio::time::Instant; @@ -113,16 +115,67 @@ impl ConfirmGate { /// wait times out). Holds the internal lock only around map edits — /// never across the human-speed await. pub async fn request(&self, call_id: &str, tool: &ToolDef, args: &ToolArgs) -> Confirmed { - let _ = (call_id, tool, args); - todo!("13-08 Task 1: suspend on a nonce-bound pending confirmation") + let validated_args = canonical_args(args); + let nonce = mint_nonce(tool.name, &validated_args); + let description = build_description(tool, args); + let req_id = format!("confirm-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let (responder, decision) = oneshot::channel(); + { + // Lock held only for the insert — never across the wait below. + let mut map = self.pending.lock().expect("confirm gate mutex poisoned"); + map.insert( + req_id.clone(), + PendingConfirmation { + call_id: call_id.to_string(), + tool_name: tool.name.to_string(), + validated_args, + description, + nonce, + created_at: Instant::now(), + responder, + }, + ); + } + match tokio::time::timeout(CONFIRM_TIMEOUT, decision).await { + Ok(Ok(true)) => Confirmed::Yes, + Ok(Ok(false)) => Confirmed::No, + // The gate went away without an answer — decline, never execute. + Ok(Err(_)) => Confirmed::No, + Err(_) => { + // Timed out: remove the entry so a late "yes" is refused, + // and so an abandoned dialog leaks nothing (T-13-51). + self.pending + .lock() + .expect("confirm gate mutex poisoned") + .remove(&req_id); + Confirmed::TimedOut + } + } } /// Resolve a pending confirmation by `req_id`, carrying the node-minted /// nonce back. Refuses a mismatched nonce (neither action executes) and /// a replay of an already-resolved entry. pub fn resolve(&self, req_id: &str, nonce: &str, approved: bool) -> Result<(), ResolveRefusal> { - let _ = (req_id, nonce, approved); - todo!("13-08 Task 1: nonce-checked resolution") + let mut map = self.pending.lock().expect("confirm gate mutex poisoned"); + let Some(entry) = map.get(req_id) else { + return Err(ResolveRefusal::NoSuchPending); + }; + if entry.nonce != nonce { + // Loud and sticky: this can only be a replay attempt or a bug + // in the trusted chrome (S-02) — never a normal user path. + tracing::error!( + req_id, + tool = %entry.tool_name, + "assistant confirm nonce mismatch — refusing resolution (possible replay or trusted-chrome bug)" + ); + return Err(ResolveRefusal::NonceMismatch); + } + let entry = map.remove(req_id).expect("entry present — just checked"); + drop(map); + // The waiter may have timed out concurrently; a dead receiver is fine. + let _ = entry.responder.send(approved); + Ok(()) } /// The oldest pending confirmation, if any — what `assistant.pending` @@ -168,8 +221,14 @@ pub fn global() -> Arc { /// run, not what the model sent), plus per-mint randomness (so yesterday's /// nonce for the same action never matches today's pending entry). pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String { - let _ = (tool_name, validated_args); - todo!("13-08 Task 1: hash(tool_name, validated_args) + per-mint randomness") + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + let mut hasher = Sha256::new(); + hasher.update(salt); + hasher.update(tool_name.as_bytes()); + hasher.update([0u8]); + hasher.update(validated_args.as_bytes()); + hex::encode(hasher.finalize()) } /// Canonical, deterministic form of the validated arguments — the byte @@ -190,8 +249,66 @@ fn canonical_args(args: &ToolArgs) -> String { /// (S-03). Clear-signing: name the resource verbatim (S-08), the concrete /// effect, and the boundary of what is *not* affected. pub fn build_description(tool: &ToolDef, args: &ToolArgs) -> String { - let _ = (tool, args); - todo!("13-08 Task 1: node-authored, resource-distinct dialog text") + match (tool.name, args) { + ("app_start", ToolArgs::AppId(a)) => format!( + "Start the app \"{id}\". It will begin running on this node and \ + be reachable again. Only \"{id}\" is affected — no other apps, \ + and none of your funds or files, are touched.", + id = a.app_id + ), + ("app_stop", ToolArgs::AppId(a)) => format!( + "Stop the app \"{id}\". It will shut down and stay unavailable \ + until it is started again. Only \"{id}\" is affected — no other \ + apps, and none of your funds or files, are touched.", + id = a.app_id + ), + ("app_restart", ToolArgs::AppId(a)) => { + // The timing caveat the node actually knows (13-08 Task 1): a + // bitcoin restart pauses — but does not lose — sync progress. + let caveat = if a.app_id.contains("bitcoin") { + " If this node is still syncing the blockchain, the restart \ + pauses that sync briefly but none of its progress is lost." + } else { + "" + }; + format!( + "Restart the app \"{id}\". It will shut down and start again, \ + and be unavailable for a short moment while it does.{caveat} \ + Only \"{id}\" is affected — no other apps, and none of your \ + funds or files, are touched.", + id = a.app_id + ) + } + ("settings_set", ToolArgs::SettingsSet(a)) => format!( + "Change the node setting \"{key}\" to {value}. The change takes \ + effect immediately. Only this one setting changes — no other \ + settings, apps, funds or files are affected.", + key = a.key, + value = human_value(&a.value) + ), + // A destructive tool added later without its own hand-written arm + // above still gets node-authored text (the tool's own definition + // plus the validated argument values) — never model text. Its + // author should add a proper clear-signing arm here; this fallback + // keeps the provenance property, not the copy quality. + _ => format!( + "{} Requested with: {}", + tool.description, + canonical_args(args) + ), + } +} + +/// Render one validated argument value for the dialog in plain language — +/// quoted strings and bare booleans/numbers, never raw JSON syntax for the +/// common cases. +fn human_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => format!("\"{s}\""), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + other => other.to_string(), + } } #[cfg(test)] @@ -317,9 +434,18 @@ mod tests { let tool = app_restart_tool(); let desc_a = build_description(&tool, &restart_args("immich")); let desc_b = build_description(&tool, &restart_args("gitea")); - assert_ne!(desc_a, desc_b, "two resources must not read interchangeably"); - assert!(desc_a.contains("immich") && !desc_a.contains("gitea"), "{desc_a}"); - assert!(desc_b.contains("gitea") && !desc_b.contains("immich"), "{desc_b}"); + assert_ne!( + desc_a, desc_b, + "two resources must not read interchangeably" + ); + assert!( + desc_a.contains("immich") && !desc_a.contains("gitea"), + "{desc_a}" + ); + assert!( + desc_b.contains("gitea") && !desc_b.contains("immich"), + "{desc_b}" + ); } /// S-09: the daemon-restart analogue. Dropping the gate takes every diff --git a/core/archipelago/src/assistant/grants.rs b/core/archipelago/src/assistant/grants.rs index 961ed3d6..aff16401 100644 --- a/core/archipelago/src/assistant/grants.rs +++ b/core/archipelago/src/assistant/grants.rs @@ -104,7 +104,10 @@ mod tests { "a fresh node with no grants file must grant nothing" ); for category in PermissionCategory::ALL { - assert!(!grants.allows(category), "{category:?} must be closed by default"); + assert!( + !grants.allows(category), + "{category:?} must be closed by default" + ); } } diff --git a/core/archipelago/src/assistant/loop_.rs b/core/archipelago/src/assistant/loop_.rs index c4c1b1f9..6062cd6d 100644 --- a/core/archipelago/src/assistant/loop_.rs +++ b/core/archipelago/src/assistant/loop_.rs @@ -6,15 +6,17 @@ //! Concurrency discipline inherited from `mesh/listener/assist.rs`'s own //! doc comment ("Spawned off the radio loop so it never blocks"): never //! hold a shared lock across a `.await` that can block for human-response -//! time. `execute_tool` below holds no lock at all in this tracer — there -//! is nothing yet to hold one across (13-08's confirm gate is what -//! introduces that discipline requirement for real). +//! time. The confirm-gate wait in `execute_tool` below is exactly such an +//! await — it can suspend for minutes while a human decides — and it is +//! reached holding no lock at all: the gate's own internal lock is scoped +//! to map edits inside `confirm.rs`, and nothing here wraps the call in a +//! guard. Keep it that way (AI-SPEC §4b.2). use anyhow::Result; use super::backends::{Backend, BackendTurn}; -use super::tools::{ChatMessage, Role, ToolCall, ToolResult}; use super::tools::ToolDef; +use super::tools::{ChatMessage, Role, ToolCall, ToolResult}; use super::ToolExecCtx; /// Hard stop — a looping model must never spin unbounded (D-05). @@ -63,7 +65,9 @@ pub async fn run_loop( } } } - anyhow::bail!("assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever") + anyhow::bail!( + "assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever" + ) } /// The single choke point every tool call passes through, regardless of @@ -72,9 +76,9 @@ pub async fn run_loop( /// category grants — re-checked here even though the system prompt already /// omits ungranted tools; never trust that as the only enforcement layer), /// schema validation (never coerce, never guess — AI-SPEC §4b.1), and D-07 -/// (every destructive tool suspends for confirmation — 13-08 fills that -/// branch in; there are no reachable destructive tools yet, so it is -/// unreachable today even though the registry now has some). +/// (every destructive tool suspends on the confirm gate before execution — +/// only a matching human "yes" releases it; a decline or timeout returns a +/// declined error result and executes nothing). /// /// `pub(crate)` (not private) so `assistant::tools`'s own test module can /// exercise this exact choke point directly for S-05/S-07 — the point of @@ -120,7 +124,9 @@ pub(crate) async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResu // "not yet implemented" placeholder instead of being refused with the // real reason (13-05 Task 1's `` criterion). This performs no // mutation itself — only a read-only id lookup for the app tools. - if let Err(msg) = super::tools::validate_business_rules(&call.name, &args, ctx.handler.as_ref()).await { + if let Err(msg) = + super::tools::validate_business_rules(&call.name, &args, ctx.handler.as_ref()).await + { return ToolResult { call_id: call.id.clone(), is_error: true, @@ -129,11 +135,23 @@ pub(crate) async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResu } if tool.destructive { - return ToolResult { - call_id: call.id.clone(), - is_error: true, - content: "destructive tool execution is not yet implemented".to_string(), - }; + // D-07/D-11: the loop suspends here. The gate publishes a + // NODE-AUTHORED description (never model text) that neode-ui's + // trusted chrome fetches over the authenticated RPC session, and + // approval binds to a node-minted nonce over the tool name and the + // validated args — so what executes below is exactly what the + // human read (S-02). This await is human-speed (up to + // CONFIRM_TIMEOUT); no lock is held across it — see the module doc. + match ctx.confirm.request(&call.id, tool, &args).await { + super::confirm::Confirmed::Yes => {} + super::confirm::Confirmed::No | super::confirm::Confirmed::TimedOut => { + return ToolResult { + call_id: call.id.clone(), + is_error: true, + content: "the user declined this action — nothing was changed".to_string(), + }; + } + } } // D-06: dispatch is a per-tool, hand-written decision recorded in @@ -156,10 +174,10 @@ pub(crate) async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResu #[cfg(test)] mod tests { use super::*; + use crate::api::rpc::RpcHandler; use crate::assistant::backends::scripted::ScriptedBackend; use crate::assistant::tools::{registry, system_disk_status_tool}; use crate::assistant::{CallerScope, PermissionCategory}; - use crate::api::rpc::RpcHandler; use serde_json::json; use std::sync::Arc; @@ -228,7 +246,23 @@ mod tests { }; let result = execute_tool(&call, &ctx).await; assert!(!result.is_error, "tool call errored: {}", result.content); - assert_eq!(result.content, direct.to_string()); + // Same handler, same shape — but the two calls sample live statvfs + // figures at two different moments, and on a busy node (this test + // box hosts a live one) free/used byte counters drift between the + // samples. Compare the stable fields byte-for-byte instead of the + // whole payload; identical `partition`/`total_bytes`/`encrypted` + // still proves this is the SAME handler, not a parallel AI-only + // code path. + let direct_v: serde_json::Value = direct.clone(); + let result_v: serde_json::Value = + serde_json::from_str(&result.content).expect("tool result is the handler's JSON"); + for field in ["partition", "total_bytes", "encrypted"] { + assert_eq!( + result_v.get(field), + direct_v.get(field), + "field {field} must come from the same handler" + ); + } assert!(result.content.contains("total_bytes")); // Exercise the whole loop: a ScriptedBackend that names the tool, @@ -258,7 +292,11 @@ mod tests { }; let result = execute_tool(&call, &ctx).await; assert!(result.is_error); - assert!(result.content.contains("no such tool"), "{}", result.content); + assert!( + result.content.contains("no such tool"), + "{}", + result.content + ); } /// Phase-10 hard constraint: `assistant.*` must never be reachable diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index 7b23b917..8b4b6a19 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -140,7 +140,11 @@ pub struct ToolExecCtx { } impl ToolExecCtx { - pub fn new(registry: tools::ToolRegistry, caller: CallerScope, handler: Arc) -> Self { + pub fn new( + registry: tools::ToolRegistry, + caller: CallerScope, + handler: Arc, + ) -> Self { Self::with_confirm_gate(registry, caller, handler, confirm::global()) } @@ -231,7 +235,11 @@ pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String { /// Builds the visible-tool set from the caller's granted categories only /// (D-16 — the model should never even see a tool it can't use), selects a /// backend (Claude only, in this tracer), and runs it to a final answer. -pub async fn chat(handler: Arc, caller: CallerScope, user_text: String) -> Result { +pub async fn chat( + handler: Arc, + caller: CallerScope, + user_text: String, +) -> Result { let registry = tools::registry(); let grants = caller.granted_categories(handler.data_dir()).await; let visible_tools = registry.visible_to(&grants); @@ -249,7 +257,14 @@ pub async fn chat(handler: Arc, caller: CallerScope, user_text: Stri let ctx = ToolExecCtx::new(registry, caller, handler); - loop_::run_loop(backend.as_ref(), &system_prompt, &visible_tools, history, &ctx).await + loop_::run_loop( + backend.as_ref(), + &system_prompt, + &visible_tools, + history, + &ctx, + ) + .await } #[cfg(test)] @@ -286,7 +301,9 @@ mod tests { /// (`container-list`) finds it — the same cached-state path the real /// node serves, no orchestrator or podman needed. fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry { - use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles}; + use crate::data_model::{ + Description, Manifest, PackageDataEntry, PackageState, StaticFiles, + }; PackageDataEntry { state: PackageState::Running, health: None, @@ -367,7 +384,9 @@ mod tests { } tokio::time::sleep(std::time::Duration::from_millis(5)).await; } - panic!("no pending confirmation appeared — the destructive branch did not suspend on the gate"); + panic!( + "no pending confirmation appeared — the destructive branch did not suspend on the gate" + ); } /// S-01 / D-07: a destructive tool call suspends before any execution @@ -465,7 +484,10 @@ mod tests { session_id: "s".to_string(), }; let granted = caller.granted_categories(handler.data_dir()).await; - assert!(granted.is_empty(), "fresh node must grant nothing: {granted:?}"); + assert!( + granted.is_empty(), + "fresh node must grant nothing: {granted:?}" + ); } /// The system prompt built for a caller must never mention a tool @@ -494,7 +516,9 @@ mod tests { // Sanity: at least one granted tool IS present, so this isn't // trivially passing because the prompt is empty. assert!( - reg.visible_to(&grants).iter().any(|t| prompt.contains(t.name)), + reg.visible_to(&grants) + .iter() + .any(|t| prompt.contains(t.name)), "expected at least one granted-category tool name in the prompt" ); } @@ -560,7 +584,9 @@ mod tests { peer_id: "peer-2".to_string(), authorized: false, }; - let peer_grants = unauthorized_peer.granted_categories(handler.data_dir()).await; + let peer_grants = unauthorized_peer + .granted_categories(handler.data_dir()) + .await; assert!( peer_grants.is_empty(), "an unauthorized mesh peer must resolve to no authority regardless of what the operator granted" diff --git a/core/archipelago/src/assistant/tools.rs b/core/archipelago/src/assistant/tools.rs index 685027ac..24728395 100644 --- a/core/archipelago/src/assistant/tools.rs +++ b/core/archipelago/src/assistant/tools.rs @@ -118,8 +118,11 @@ pub const SETTABLE_KEYS: &[&str] = &[ /// `SETTABLE_KEYS` (a key can be safely readable, like whether the Claude /// key is *set*, without being safely writable or without exposing the /// key material itself). -pub const READABLE_SETTINGS_KEYS: &[&str] = - &["network_visibility", "kiosk_display_preset", "claude_api_key_set"]; +pub const READABLE_SETTINGS_KEYS: &[&str] = &[ + "network_visibility", + "kiosk_display_preset", + "claude_api_key_set", +]; /// Args for tools that take no parameters at all /// (`system_disk_status`, `system_stats`, `apps_list`, `bitcoin_status`, @@ -180,9 +183,11 @@ impl ToolDef { pub fn validate(&self, raw: &Value) -> Result { match self.name { "system_disk_status" | "system_stats" | "apps_list" | "bitcoin_status" - | "network_status" | "mesh_status" | "content_list" => serde_json::from_value(raw.clone()) - .map(ToolArgs::Empty) - .context("tool arguments did not match the declared schema"), + | "network_status" | "mesh_status" | "content_list" => { + serde_json::from_value(raw.clone()) + .map(ToolArgs::Empty) + .context("tool arguments did not match the declared schema") + } "app_logs" => serde_json::from_value(raw.clone()) .map(ToolArgs::AppLogs) .context("tool arguments did not match the declared schema"), @@ -540,7 +545,11 @@ async fn resolve_installed_app_id(app_id: &str, handler: &RpcHandler) -> Result< /// below also calls this first, so it stays correct and self-contained /// once 13-08 wires the real confirm-then-execute flow in and this stops /// being called from two places. -pub async fn validate_business_rules(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Result<(), String> { +pub async fn validate_business_rules( + name: &str, + args: &ToolArgs, + handler: &RpcHandler, +) -> Result<(), String> { match name { "settings_set" => { let ToolArgs::SettingsSet(a) = args else { @@ -585,7 +594,9 @@ pub async fn validate_business_rules(name: &str, args: &ToolArgs, handler: &RpcH let ToolArgs::AppId(a) = args else { return Err(format!("internal error: args/tool mismatch for {name}")); }; - resolve_installed_app_id(&a.app_id, handler).await.map(|_| ()) + resolve_installed_app_id(&a.app_id, handler) + .await + .map(|_| ()) } _ => Ok(()), } @@ -714,10 +725,9 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu .map_err(|e| format!("tool execution failed: {e}")) } "kiosk_display_preset" => { - let preset = a - .value - .as_str() - .ok_or_else(|| "kiosk_display_preset's value must be a string".to_string())?; + let preset = a.value.as_str().ok_or_else(|| { + "kiosk_display_preset's value must be a string".to_string() + })?; handler .assistant_dispatch_tool( "system.kiosk-display.set", @@ -744,7 +754,10 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu return Err("bitcoin_relay_settings's value must be an object".to_string()); } handler - .assistant_dispatch_tool("bitcoin.relay-update-settings", Some(a.value.clone())) + .assistant_dispatch_tool( + "bitcoin.relay-update-settings", + Some(a.value.clone()), + ) .await .map_err(|e| format!("tool execution failed: {e}")) } @@ -850,7 +863,11 @@ mod tests { .and_then(|r| r.as_array()) .cloned() .unwrap_or_default(); - let properties = tool.parameters.get("properties").cloned().unwrap_or_else(|| json!({})); + let properties = tool + .parameters + .get("properties") + .cloned() + .unwrap_or_else(|| json!({})); let mut obj = serde_json::Map::new(); for key in &required { @@ -914,7 +931,10 @@ mod tests { arguments: json!({ "key": "wifi_radio", "value": true }), }; let result = execute_tool(&call, &ctx).await; - assert!(result.is_error, "settings_set must be refused when System is not granted"); + assert!( + result.is_error, + "settings_set must be refused when System is not granted" + ); assert!( result.content.contains("not permitted"), "expected a not-permitted refusal, got: {}", @@ -971,7 +991,10 @@ mod tests { arguments: json!({ "app_id": "definitely-not-installed" }), }; let result = execute_tool(&call, &ctx).await; - assert!(result.is_error, "an unknown app id must be refused, not guessed at"); + assert!( + result.is_error, + "an unknown app id must be refused, not guessed at" + ); assert!( result.content.contains("no such app id"), "got: {}", diff --git a/neode-ui/src/components/ToolConfirmModal.vue b/neode-ui/src/components/ToolConfirmModal.vue new file mode 100644 index 00000000..aaf835bc --- /dev/null +++ b/neode-ui/src/components/ToolConfirmModal.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/neode-ui/src/services/__tests__/toolConfirm.test.ts b/neode-ui/src/services/__tests__/toolConfirm.test.ts new file mode 100644 index 00000000..55e79fbb --- /dev/null +++ b/neode-ui/src/services/__tests__/toolConfirm.test.ts @@ -0,0 +1,358 @@ +// 13-08 Task 2: the trusted-chrome tool-confirmation flow (D-07/D-11). +// The dialog text is RPC-fetched from the node (assistant.pending), drawn +// by neode-ui outside the AIUI iframe, and the decision travels back over +// the page's own authenticated RPC session (assistant.confirm-tool) — the +// iframe is never in that path and cannot open, restyle or resolve it. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ref, type Ref } from 'vue' +import { setActivePinia, createPinia } from 'pinia' +import { mount } from '@vue/test-utils' + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + call: vi.fn(), + }, +})) + +vi.mock('@/api/filebrowser-client', () => ({ + fileBrowserClient: { + login: vi.fn(), + isAuthenticated: false, + getUsage: vi.fn(), + listDirectory: vi.fn(), + readFileAsText: vi.fn(), + }, +})) + +import { ContextBroker } from '../contextBroker' +import { rpcClient } from '@/api/rpc-client' +import ToolConfirmModal from '@/components/ToolConfirmModal.vue' + +const PENDING_IMMICH = { + req_id: 'confirm-1', + nonce: 'node-minted-nonce-1', + description: 'Restart the app "immich". Only "immich" is affected.', + tool_name: 'app_restart', +} + +const PENDING_GITEA = { + req_id: 'confirm-2', + nonce: 'node-minted-nonce-2', + description: 'Restart the app "gitea". Only "gitea" is affected.', + tool_name: 'app_restart', +} + +describe('tool confirmation — ContextBroker half', () => { + let broker: ContextBroker + let iframeRef: Ref + let mockPostMessage: ReturnType + let confirmRequests: CustomEvent[] + const captureRequest = (e: Event) => confirmRequests.push(e as CustomEvent) + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.useFakeTimers() + confirmRequests = [] + window.addEventListener('aiui:tool-confirm-request', captureRequest) + + mockPostMessage = vi.fn() + iframeRef = ref({ + contentWindow: { postMessage: mockPostMessage }, + } as unknown as HTMLIFrameElement) + broker = new ContextBroker(iframeRef, 'http://localhost:8100') + }) + + afterEach(() => { + window.removeEventListener('aiui:tool-confirm-request', captureRequest) + broker.stop() + vi.useRealTimers() + }) + + /** Mock a chat turn that stays in flight (the node is suspended on its + * confirm gate) while assistant.pending reports `pending`. */ + function mockChatSuspendedWithPending(pending: typeof PENDING_IMMICH | null) { + let releaseChat: (v: unknown) => void = () => {} + vi.mocked(rpcClient.call).mockImplementation((opts: { method: string }) => { + if (opts.method === 'assistant.chat') { + return new Promise((resolve) => { + releaseChat = resolve + }) as Promise + } + if (opts.method === 'assistant.pending') { + return Promise.resolve({ pending }) as Promise + } + return Promise.resolve({}) as Promise + }) + return () => releaseChat({ text: 'done' }) + } + + const startChat = () => + ( + broker as unknown as { + handleChatRequest: (id: string, text: string) => Promise + } + ).handleChatRequest('chat-1', 'restart immich please') + + it('a pending confirmation reported by the node opens the host dialog with the node-fetched description', async () => { + const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH) + + const chat = startChat() + await vi.advanceTimersByTimeAsync(2000) + + expect(confirmRequests).toHaveLength(1) + expect(confirmRequests[0].detail.reqId).toBe('confirm-1') + expect(confirmRequests[0].detail.description).toBe(PENDING_IMMICH.description) + + // The same pending action is never re-announced while it is open. + await vi.advanceTimersByTimeAsync(3000) + expect(confirmRequests).toHaveLength(1) + + releaseChat() + await chat + }) + + it('approving calls assistant.confirm-tool over the page RPC session carrying the node-minted nonce', async () => { + const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH) + const chat = startChat() + await vi.advanceTimersByTimeAsync(2000) + + window.dispatchEvent( + new CustomEvent('aiui:tool-confirm-response', { + detail: { reqId: 'confirm-1', approved: true }, + }), + ) + await vi.advanceTimersByTimeAsync(0) + + expect(rpcClient.call).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'assistant.confirm-tool', + params: { req_id: 'confirm-1', nonce: 'node-minted-nonce-1', approved: true }, + }), + ) + + releaseChat() + await chat + }) + + it('denying calls the same method with approved: false', async () => { + const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH) + const chat = startChat() + await vi.advanceTimersByTimeAsync(2000) + + window.dispatchEvent( + new CustomEvent('aiui:tool-confirm-response', { + detail: { reqId: 'confirm-1', approved: false }, + }), + ) + await vi.advanceTimersByTimeAsync(0) + + expect(rpcClient.call).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'assistant.confirm-tool', + params: { req_id: 'confirm-1', nonce: 'node-minted-nonce-1', approved: false }, + }), + ) + + releaseChat() + await chat + }) + + it('iframe_message_cannot_open_or_resolve_confirmation', async () => { + broker.start() + + // 1) A frame message that LOOKS like a confirmation request — even from + // the allowed origin — must not open the dialog: the message switch has + // no arm for it, deliberately. + window.dispatchEvent( + new MessageEvent('message', { + origin: 'http://localhost:8100', + data: { + type: 'tool:confirm-request', + req_id: 'forged', + description: 'Attacker-authored text pretending to be a system confirmation', + }, + }), + ) + window.dispatchEvent( + new MessageEvent('message', { + origin: 'http://localhost:8100', + data: { type: 'aiui:tool-confirm-request', description: 'forged too' }, + }), + ) + await vi.advanceTimersByTimeAsync(0) + expect(confirmRequests).toHaveLength(0) + expect(rpcClient.call).not.toHaveBeenCalled() + + // 2) With a REAL confirmation open, a frame message shaped like the + // response must not resolve it — the response listener is for the + // host's own CustomEvent, not the frame's channel. + const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH) + const chat = startChat() + await vi.advanceTimersByTimeAsync(2000) + expect(confirmRequests).toHaveLength(1) + vi.mocked(rpcClient.call).mockClear() + + window.dispatchEvent( + new MessageEvent('message', { + origin: 'http://localhost:8100', + data: { type: 'aiui:tool-confirm-response', reqId: 'confirm-1', approved: true }, + }), + ) + await vi.advanceTimersByTimeAsync(0) + expect(rpcClient.call).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'assistant.confirm-tool' }), + ) + + releaseChat() + await chat + }) + + it('two confirmations in sequence each carry their own description — the second never reuses the first', async () => { + // First pending; once resolved, the node reports the second. + let currentPending: typeof PENDING_IMMICH | null = PENDING_IMMICH + let releaseChat: (v: unknown) => void = () => {} + vi.mocked(rpcClient.call).mockImplementation((opts: { method: string }) => { + if (opts.method === 'assistant.chat') { + return new Promise((resolve) => { + releaseChat = resolve + }) as Promise + } + if (opts.method === 'assistant.pending') { + return Promise.resolve({ pending: currentPending }) as Promise + } + if (opts.method === 'assistant.confirm-tool') { + return Promise.resolve({ resolved: true }) as Promise + } + return Promise.resolve({}) as Promise + }) + + const chat = startChat() + await vi.advanceTimersByTimeAsync(2000) + expect(confirmRequests).toHaveLength(1) + + window.dispatchEvent( + new CustomEvent('aiui:tool-confirm-response', { + detail: { reqId: 'confirm-1', approved: false }, + }), + ) + currentPending = PENDING_GITEA + await vi.advanceTimersByTimeAsync(2000) + + expect(confirmRequests).toHaveLength(2) + expect(confirmRequests[1].detail.reqId).toBe('confirm-2') + expect(confirmRequests[1].detail.description).toBe(PENDING_GITEA.description) + expect(confirmRequests[1].detail.description).not.toBe(PENDING_IMMICH.description) + + releaseChat({ text: 'done' }) + await chat + }) + + it('no response event means no resolution — the action stays pending for the node to time out, never silently approved', async () => { + const releaseChat = mockChatSuspendedWithPending(PENDING_IMMICH) + const chat = startChat() + await vi.advanceTimersByTimeAsync(2000) + expect(confirmRequests).toHaveLength(1) + vi.mocked(rpcClient.call).mockClear() + + // The operator closes the dialog without deciding: nothing is sent. + await vi.advanceTimersByTimeAsync(10_000) + expect(rpcClient.call).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'assistant.confirm-tool' }), + ) + + releaseChat() + await chat + }) +}) + +describe('tool confirmation — ToolConfirmModal (trusted chrome)', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('renders as a direct child of document.body with a full-screen backdrop, showing the node-fetched text', () => { + const wrapper = mount(ToolConfirmModal, { + props: { show: true, description: PENDING_IMMICH.description }, + }) + + // Teleported: the overlay renders at level, OUTSIDE the + // component's own DOM subtree, so no ancestor transform (glass-panel + // or otherwise) can trap its position: fixed. The test environment + // globally stubs , so tolerate that one wrapper between + // the overlay and — nothing else may sit in between. + const overlay = document.body.querySelector('[data-testid="tool-confirm-overlay"]') + expect(overlay).toBeTruthy() + expect(wrapper.element.contains(overlay)).toBe(false) + const parent = overlay?.parentElement + const attachPoint = + parent && parent.tagName.toLowerCase() === 'transition-stub' + ? parent.parentElement + : parent + expect(attachPoint).toBe(document.body) + expect(overlay?.className).toContain('fixed') + expect(overlay?.className).toContain('inset-0') + + const backdrop = document.body.querySelector('[data-testid="tool-confirm-backdrop"]') + expect(backdrop).toBeTruthy() + expect(backdrop?.className).toContain('inset-0') + + expect(document.body.textContent).toContain(PENDING_IMMICH.description) + wrapper.unmount() + }) + + it('two sequential confirmations render their two different descriptions', async () => { + const wrapper = mount(ToolConfirmModal, { + props: { show: true, description: PENDING_IMMICH.description }, + }) + expect(document.body.textContent).toContain('immich') + + await wrapper.setProps({ description: PENDING_GITEA.description }) + expect(document.body.textContent).toContain('gitea') + expect(document.body.textContent).not.toContain('immich') + wrapper.unmount() + }) + + it('Approve emits approve, Deny emits deny — and nothing else', async () => { + const wrapper = mount(ToolConfirmModal, { + props: { show: true, description: PENDING_IMMICH.description }, + }) + + const approve = document.body.querySelector( + '[data-testid="tool-confirm-approve"]', + ) as HTMLButtonElement + const deny = document.body.querySelector( + '[data-testid="tool-confirm-deny"]', + ) as HTMLButtonElement + expect(approve).toBeTruthy() + expect(deny).toBeTruthy() + + approve.click() + expect(wrapper.emitted('approve')).toHaveLength(1) + expect(wrapper.emitted('deny')).toBeUndefined() + + deny.click() + expect(wrapper.emitted('deny')).toHaveLength(1) + wrapper.unmount() + }) + + it('closing without a decision emits dismiss — never approve, never deny', async () => { + const wrapper = mount(ToolConfirmModal, { + props: { show: true, description: PENDING_IMMICH.description }, + }) + + const backdrop = document.body.querySelector( + '[data-testid="tool-confirm-backdrop"]', + ) as HTMLElement + backdrop.click() + + expect(wrapper.emitted('dismiss')).toHaveLength(1) + expect(wrapper.emitted('approve')).toBeUndefined() + expect(wrapper.emitted('deny')).toBeUndefined() + wrapper.unmount() + }) +}) diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 63b2c0cb..96aad24b 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -49,6 +49,19 @@ function normalizeOwnedItem(owned: OwnedRpcItem): ArchyContentItem { } } +/** Wire shape of `assistant.pending`'s response payload: the node-authored + * description and the node-minted nonce for the one pending destructive + * action (13-08, D-07/D-11). The description is drawn by the HOST chrome + * (ToolConfirmModal.vue), never by AIUI — and it reaches the page over the + * authenticated RPC session only, never over the iframe's postMessage + * channel, so the frame cannot forge or restyle it. */ +interface PendingToolConfirm { + req_id: string + nonce: string + description: string + tool_name?: string +} + function emptyBundle(): ArchyContentBundle { return { films: [], songs: [], podcasts: [] } } @@ -82,6 +95,24 @@ export class ContextBroker { * of posted, so a slow response can never overwrite fresher grid data. */ private contentRequestSeq = 0 + /** 13-08: how often the broker asks the node for a pending destructive- + * tool confirmation while a chat turn is in flight. The node's loop is + * suspended on its confirm gate during that window, so this poll is what + * turns "the node is waiting on a human" into a visible dialog. */ + private static readonly CONFIRM_POLL_MS = 1200 + + /** How long the one-shot aiui:tool-confirm-response listener stays armed + * before being cleaned up — slightly beyond the node's own CONFIRM_TIMEOUT + * (120s), after which the node has already declined the action itself. */ + private static readonly CONFIRM_LISTENER_TTL_MS = 130_000 + + private confirmPollTimer: ReturnType | null = null + /** Chat turns currently in flight — polling runs while > 0. */ + private activeChatTurns = 0 + /** req_ids already announced to the host chrome, so one pending action is + * dialogued exactly once no matter how many polls observe it. */ + private announcedConfirmReqIds = new Set() + constructor(iframe: Ref, aiuiUrl: string) { this.iframe = iframe try { @@ -102,6 +133,11 @@ export class ContextBroker { window.removeEventListener('message', this.listener) this.listener = null } + if (this.confirmPollTimer) { + clearInterval(this.confirmPollTimer) + this.confirmPollTimer = null + } + this.activeChatTurns = 0 } sendPermissionsUpdate() { @@ -145,6 +181,12 @@ export class ContextBroker { case 'content:request': this.handleContentRequest(msg.id, msg.kind, msg.scope) break + // Deliberately NO arm for anything confirmation-shaped (13-08, + // D-11): a frame message whose `type` resembles a tool confirmation + // falls through here and is ignored. The confirmation dialog is + // opened only from assistant.pending's RPC response and resolved + // only via the host's own aiui:tool-confirm-response CustomEvent — + // asserted by iframe_message_cannot_open_or_resolve_confirmation. } } @@ -155,6 +197,10 @@ export class ContextBroker { // divergent security model D-02 exists to prevent. Do not "helpfully" // add a permission check back into this handler. private async handleChatRequest(id: string, text: string) { + // 13-08: while this turn is in flight the node may suspend on its + // confirm gate waiting for a human — poll assistant.pending so the + // trusted chrome can draw the dialog (see handleToolConfirmRequest). + this.beginConfirmPolling() try { const result = await rpcClient.call<{ text: string }>({ method: 'assistant.chat', @@ -173,9 +219,99 @@ export class ContextBroker { success: false, error: err instanceof Error ? err.message : 'Chat request failed', } satisfies ArchyChatResponse) + } finally { + this.endConfirmPolling() } } + private beginConfirmPolling() { + this.activeChatTurns += 1 + if (this.confirmPollTimer) return + this.confirmPollTimer = setInterval(() => { + void this.checkPendingConfirmation() + }, ContextBroker.CONFIRM_POLL_MS) + } + + private endConfirmPolling() { + this.activeChatTurns = Math.max(0, this.activeChatTurns - 1) + if (this.activeChatTurns === 0 && this.confirmPollTimer) { + clearInterval(this.confirmPollTimer) + this.confirmPollTimer = null + } + } + + private async checkPendingConfirmation() { + try { + const res = await rpcClient.call<{ pending: PendingToolConfirm | null }>({ + method: 'assistant.pending', + }) + if (res?.pending) this.handleToolConfirmRequest(res.pending) + } catch { + // Transient RPC failure — the next poll retries; the node's own + // timeout is the backstop, and it declines rather than approves. + } + } + + /** + * 13-08 (D-07/D-11): announce one node-reported pending confirmation to + * the trusted chrome, and arm a one-shot listener for the host's answer. + * + * Anti-spoofing invariants, all load-bearing: + * - The description and nonce arrive here ONLY from assistant.pending's + * RPC response — never from the iframe (there is no handleMessage arm + * for anything confirmation-shaped, deliberately). + * - The CustomEvent pair (`aiui:tool-confirm-request` / + * `aiui:tool-confirm-response`) is NEW and distinct from the + * install-app pair — an install confirmation and a tool confirmation + * must never be interchangeable. + * - The response listener listens for the host page's own CustomEvent on + * window. An iframe cannot dispatch that (its postMessage arrives as a + * MessageEvent, which this method never reads), so the decision path + * is host-only. + * - The user's decision travels to the node over the authenticated RPC + * session (assistant.confirm-tool) carrying the node-minted nonce — + * never back through the frame. + * - No response is ever synthesized: if the host closes the dialog + * without deciding, nothing is sent, and the node's own timeout + * declines the action. + */ + handleToolConfirmRequest(pending: PendingToolConfirm) { + if (!pending?.req_id || !pending.nonce || typeof pending.description !== 'string') return + if (this.announcedConfirmReqIds.has(pending.req_id)) return + this.announcedConfirmReqIds.add(pending.req_id) + + const reqId = pending.req_id + const nonce = pending.nonce + + const responseHandler = (e: Event) => { + const detail = (e as CustomEvent).detail as { reqId?: string; approved?: boolean } + if (detail?.reqId !== reqId) return + window.removeEventListener('aiui:tool-confirm-response', responseHandler) + void rpcClient + .call({ + method: 'assistant.confirm-tool', + params: { req_id: reqId, nonce, approved: detail.approved === true }, + }) + .catch(() => { + // A refused resolution (stale nonce, already timed out) is the + // node protecting itself — nothing to retry from here. + }) + } + window.addEventListener('aiui:tool-confirm-response', responseHandler) + setTimeout(() => { + window.removeEventListener('aiui:tool-confirm-response', responseHandler) + this.announcedConfirmReqIds.delete(reqId) + }, ContextBroker.CONFIRM_LISTENER_TTL_MS) + + // Only node-fetched values travel to the chrome — and not the nonce: + // it stays in this closure and reappears only on the RPC call above. + window.dispatchEvent( + new CustomEvent('aiui:tool-confirm-request', { + detail: { reqId, description: pending.description, toolName: pending.tool_name }, + }), + ) + } + // Content surfaces (D-12/D-14, AIUI-03) — a single generic channel with a // `kind` discriminator rather than one channel per content type, so // 13-11's music-library wave can extend `kind` without touching this diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index 58c72865..64553e18 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -70,6 +70,21 @@ + + + @@ -78,6 +93,7 @@ import { ref, computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { ContextBroker } from '@/services/contextBroker' +import ToolConfirmModal from '@/components/ToolConfirmModal.vue' import { IS_DEMO } from '@/composables/useDemoIntro' const { t } = useI18n() @@ -170,6 +186,37 @@ function closeChat() { } } +// 13-08 (D-11): the pending destructive-tool confirmation the trusted +// chrome is currently showing. Set ONLY from the ContextBroker's +// aiui:tool-confirm-request CustomEvent, whose payload is node-fetched +// over the page's own RPC session — never from anything the iframe posts. +const toolConfirm = ref<{ reqId: string; description: string } | null>(null) + +function onToolConfirmRequest(e: Event) { + const detail = (e as CustomEvent).detail as { reqId?: string; description?: string } + if (!detail?.reqId || typeof detail.description !== 'string') return + toolConfirm.value = { reqId: detail.reqId, description: detail.description } +} + +function resolveToolConfirm(approved: boolean) { + const current = toolConfirm.value + toolConfirm.value = null + if (!current) return + // The decision travels back to the broker (and from there to the node + // over the authenticated RPC session) — never through the iframe. + window.dispatchEvent( + new CustomEvent('aiui:tool-confirm-response', { + detail: { reqId: current.reqId, approved }, + }), + ) +} + +function dismissToolConfirm() { + // Closed without a decision: send nothing. The action stays pending on + // the node until its own timeout declines it — never silently approved. + toolConfirm.value = null +} + function onAiuiMessage(event: MessageEvent) { if (!aiuiUrl.value) return // Validate origin — only accept messages from AIUI @@ -197,6 +244,8 @@ function onAiuiMessage(event: MessageEvent) { function armChatLive() { window.removeEventListener('message', onAiuiMessage) window.addEventListener('message', onAiuiMessage) + window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) + window.addEventListener('aiui:tool-confirm-request', onToolConfirmRequest) broker?.stop() broker = null if (aiuiUrl.value) { @@ -216,6 +265,7 @@ onActivated(() => armChatLive()) onDeactivated(() => { window.removeEventListener('message', onAiuiMessage) + window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) broker?.stop() broker = null if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null } @@ -229,6 +279,7 @@ onMounted(() => armChatLive()) onBeforeUnmount(() => { window.removeEventListener('message', onAiuiMessage) + window.removeEventListener('aiui:tool-confirm-request', onToolConfirmRequest) broker?.stop() broker = null if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }