feat(mobile): per-user device bindings, multi-user Inbox routing, and admin-mediated authorization
- Device→user bindings persisted in config table (auth.rs), loaded at plugin start - RelayApp now routes Inbox responses per-user via UserChannelApi, never globally - New mobile_bind_device LLM tool for admin-mediated device→user assignment - Per-user event forwarders (events.rs) with per-user debounced notifiers - Config listener (auth::config_listener) refreshes bindings cache reactively - Reconcile loop catches users who unlock after boot - Hello/Logout treated as device-registry ops (no user resolution needed) - Unbound device payloads are silently dropped - RelayAgent::authorize_client → bind_device (atomic bind + authorize) - Approval rules seed mobile_bind_device/revoke_device as require
This commit is contained in:
@@ -19,6 +19,7 @@ use crate::agent::{ClientState, RelayAgent};
|
||||
/// Tool name constants (also the patterns the host uses for approval rules).
|
||||
pub const TOOL_START_PAIRING: &str = "mobile_start_pairing";
|
||||
pub const TOOL_LIST_DEVICES: &str = "mobile_list_devices";
|
||||
pub const TOOL_BIND_DEVICE: &str = "mobile_bind_device";
|
||||
pub const TOOL_REVOKE_DEVICE: &str = "mobile_revoke_device";
|
||||
|
||||
/// Build the plugin's LLM tools, bound to a `RelayAgent`. The host calls this
|
||||
@@ -27,6 +28,7 @@ pub fn mobile_tools(agent: Arc<dyn RelayAgent>) -> Vec<Arc<dyn Tool>> {
|
||||
vec![
|
||||
Arc::new(StartPairingTool { agent: Arc::clone(&agent) }),
|
||||
Arc::new(ListDevicesTool { agent: Arc::clone(&agent) }),
|
||||
Arc::new(BindDeviceTool { agent: Arc::clone(&agent) }),
|
||||
Arc::new(RevokeDeviceTool { agent }),
|
||||
]
|
||||
}
|
||||
@@ -82,7 +84,8 @@ struct ListDevicesTool {
|
||||
impl Tool for ListDevicesTool {
|
||||
fn name(&self) -> &str { TOOL_LIST_DEVICES }
|
||||
fn description(&self) -> &str {
|
||||
"List paired mobile devices: state (pending/authorized), platform, device info, last seen."
|
||||
"List paired mobile devices: state (pending/authorized), bound user, platform, device info, last seen. \
|
||||
Use the `ed25519_pub` of a pending device with mobile_bind_device to assign it to a user."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({ "type": "object", "properties": {} })
|
||||
@@ -109,6 +112,7 @@ impl Tool for ListDevicesTool {
|
||||
ClientState::Authorized => "authorized",
|
||||
ClientState::Pending => "pending",
|
||||
},
|
||||
"bound_user": c.bound_user,
|
||||
"platform": c.platform,
|
||||
"device_info": device_info,
|
||||
"last_seen": c.last_seen,
|
||||
@@ -120,6 +124,63 @@ impl Tool for ListDevicesTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── mobile_bind_device ────────────────────────────────────────────────────────
|
||||
|
||||
struct BindDeviceTool {
|
||||
agent: Arc<dyn RelayAgent>,
|
||||
}
|
||||
|
||||
impl Tool for BindDeviceTool {
|
||||
fn name(&self) -> &str { TOOL_BIND_DEVICE }
|
||||
fn description(&self) -> &str {
|
||||
"Bind a paired mobile device to a Skald user and authorize it. The device then \
|
||||
receives that user's Inbox (approvals/clarifications/elicitations) and push \
|
||||
notifications — and only that user's. Requires user approval."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": {
|
||||
"type": "string",
|
||||
"description": "The device's ed25519 public key, hex-encoded (64 chars), from mobile_list_devices."
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "The Skald user id to bind this device to."
|
||||
},
|
||||
"display": {
|
||||
"type": "string",
|
||||
"description": "Optional human-friendly label for the binding."
|
||||
}
|
||||
},
|
||||
"required": ["pubkey", "user_id"]
|
||||
})
|
||||
}
|
||||
fn category(&self) -> ToolCategory { ToolCategory::Config }
|
||||
|
||||
fn execute_async<'a>(
|
||||
&'a self,
|
||||
args: Value,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let pubkey = args
|
||||
.get("pubkey")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("mobile_bind_device: missing `pubkey`"))?;
|
||||
let user_id = args
|
||||
.get("user_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("mobile_bind_device: missing `user_id`"))?;
|
||||
let display = args.get("display").and_then(Value::as_str).map(str::to_string);
|
||||
let ed = skald_relay_common::crypto::decode_hex::<32>(pubkey)
|
||||
.ok_or_else(|| anyhow::anyhow!("mobile_bind_device: `pubkey` is not 32-byte hex"))?;
|
||||
self.agent.bind_device(ed, user_id.to_string(), display).await?;
|
||||
Ok(format!("Device {pubkey} bound to user {user_id} and authorized."))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── mobile_revoke_device ──────────────────────────────────────────────────────
|
||||
|
||||
struct RevokeDeviceTool {
|
||||
@@ -129,7 +190,8 @@ struct RevokeDeviceTool {
|
||||
impl Tool for RevokeDeviceTool {
|
||||
fn name(&self) -> &str { TOOL_REVOKE_DEVICE }
|
||||
fn description(&self) -> &str {
|
||||
"Revoke a paired mobile device by its ed25519 public key (hex). The device loses access immediately."
|
||||
"Revoke a paired mobile device by its ed25519 public key (hex) and drop its user binding. \
|
||||
The device loses access immediately."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
|
||||
Reference in New Issue
Block a user