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:
archipelago
2026-08-05 11:34:31 -04:00
co-authored by Claude Fable 5
parent db11c625c8
commit fc09d7a292
11 changed files with 1011 additions and 53 deletions
@@ -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
+137 -11
View File
@@ -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
+4 -1
View File
@@ -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"
);
}
}
+55 -17
View File
@@ -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
+34 -8
View File
@@ -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"
+38 -15
View File
@@ -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: {}",