wip(13-08): checkpoint before operator session restart — Task 1 GREEN (28/28), Task 2 in progress
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
db11c625c8
commit
fc09d7a292
@@ -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<Self>) -> Result<serde_json::Value> {
|
||||
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<Self>,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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
|
||||
|
||||
@@ -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<BackendTurn>;
|
||||
async fn send(
|
||||
&self,
|
||||
system: &str,
|
||||
tools: &[ToolDef],
|
||||
history: &[ChatMessage],
|
||||
) -> Result<BackendTurn>;
|
||||
}
|
||||
|
||||
/// D-04's backend chain: local Ollama first (node data never leaves the
|
||||
|
||||
@@ -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<ConfirmGate> {
|
||||
/// 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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<done>` 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
|
||||
|
||||
@@ -140,7 +140,11 @@ pub struct ToolExecCtx {
|
||||
}
|
||||
|
||||
impl ToolExecCtx {
|
||||
pub fn new(registry: tools::ToolRegistry, caller: CallerScope, handler: Arc<RpcHandler>) -> Self {
|
||||
pub fn new(
|
||||
registry: tools::ToolRegistry,
|
||||
caller: CallerScope,
|
||||
handler: Arc<RpcHandler>,
|
||||
) -> 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<RpcHandler>, caller: CallerScope, user_text: String) -> Result<String> {
|
||||
pub async fn chat(
|
||||
handler: Arc<RpcHandler>,
|
||||
caller: CallerScope,
|
||||
user_text: String,
|
||||
) -> Result<String> {
|
||||
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<RpcHandler>, 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"
|
||||
|
||||
@@ -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<ToolArgs> {
|
||||
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: {}",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
data-testid="tool-confirm-overlay"
|
||||
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||
@click="dismiss"
|
||||
>
|
||||
<div
|
||||
data-testid="tool-confirm-backdrop"
|
||||
class="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
></div>
|
||||
<div ref="modalRef" @click.stop class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-white">Approve this action?</h3>
|
||||
<button
|
||||
@click="dismiss"
|
||||
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The description is node-authored: fetched by the host page over
|
||||
its own authenticated RPC session (assistant.pending), never
|
||||
received from the AIUI iframe and never model text. Plain
|
||||
interpolation only — peer-influenced argument values must render
|
||||
as inert text, so the raw-HTML directive is banned in this file.
|
||||
There is deliberately NO code path in this component that reads
|
||||
from the frame's message channel.
|
||||
-->
|
||||
<div class="bg-black/20 rounded-xl border border-white/10 p-4 mb-4">
|
||||
<p class="text-white text-sm leading-relaxed whitespace-pre-wrap">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<p class="text-white/40 text-xs mb-4">
|
||||
The assistant asked to do this. Nothing happens unless you approve — closing this
|
||||
window decides nothing, and the request expires on its own.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
data-testid="tool-confirm-deny"
|
||||
@click="deny"
|
||||
class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
data-testid="tool-confirm-approve"
|
||||
@click="approve"
|
||||
class="glass-button flex-1 py-2.5 rounded-lg text-sm font-medium text-orange-400 border-orange-400/30"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
description: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: []
|
||||
deny: []
|
||||
/** Closed without a decision: nothing is sent anywhere — the node's own
|
||||
* timeout declines the pending action. Never treated as an approval. */
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useModalKeyboard(
|
||||
modalRef,
|
||||
computed(() => props.show),
|
||||
() => emit('dismiss'),
|
||||
)
|
||||
|
||||
function approve() {
|
||||
emit('approve')
|
||||
}
|
||||
|
||||
function deny() {
|
||||
emit('deny')
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
emit('dismiss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .glass-card,
|
||||
.modal-leave-active .glass-card {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .glass-card {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-leave-to .glass-card {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -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<HTMLIFrameElement | null>
|
||||
let mockPostMessage: ReturnType<typeof vi.fn>
|
||||
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<HTMLIFrameElement | null>({
|
||||
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<never>
|
||||
}
|
||||
if (opts.method === 'assistant.pending') {
|
||||
return Promise.resolve({ pending }) as Promise<never>
|
||||
}
|
||||
return Promise.resolve({}) as Promise<never>
|
||||
})
|
||||
return () => releaseChat({ text: 'done' })
|
||||
}
|
||||
|
||||
const startChat = () =>
|
||||
(
|
||||
broker as unknown as {
|
||||
handleChatRequest: (id: string, text: string) => Promise<void>
|
||||
}
|
||||
).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<never>
|
||||
}
|
||||
if (opts.method === 'assistant.pending') {
|
||||
return Promise.resolve({ pending: currentPending }) as Promise<never>
|
||||
}
|
||||
if (opts.method === 'assistant.confirm-tool') {
|
||||
return Promise.resolve({ resolved: true }) as Promise<never>
|
||||
}
|
||||
return Promise.resolve({}) as Promise<never>
|
||||
})
|
||||
|
||||
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 <body> 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 <Transition>, so tolerate that one wrapper between
|
||||
// the overlay and <body> — 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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setInterval> | 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<string>()
|
||||
|
||||
constructor(iframe: Ref<HTMLIFrameElement | null>, 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
|
||||
|
||||
@@ -70,6 +70,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 13-08 (D-11): the destructive-tool confirmation dialog — trusted
|
||||
chrome, mounted as a SIBLING of the iframe, never inside it. The
|
||||
component Teleports to body with a full-screen backdrop, so it
|
||||
covers the whole viewport including the area over the iframe, and
|
||||
no ancestor transform can trap its position: fixed. Its text is
|
||||
node-authored, fetched by the ContextBroker over the page's own
|
||||
RPC session — nothing the iframe sends can open or resolve it. -->
|
||||
<ToolConfirmModal
|
||||
:show="!!toolConfirm"
|
||||
:description="toolConfirm?.description ?? ''"
|
||||
@approve="resolveToolConfirm(true)"
|
||||
@deny="resolveToolConfirm(false)"
|
||||
@dismiss="dismissToolConfirm"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user