First Version
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "plugin-mobile-connector"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Mobile connector plugin — bridges Skald's Inbox to mobile apps via the relay (see data/ios-app/plugin.md)"
|
||||
|
||||
[dependencies]
|
||||
# Application layer over `skald-relay-client` (the networking crate). All wire
|
||||
# transport / E2E crypto / SQLite persistence lives there; this crate keeps only
|
||||
# the Skald-specific glue (Inbox payload schemas, QR router, control tools).
|
||||
core-api = { path = "../core-api" }
|
||||
skald-relay-client = { path = "../skald-relay-client" }
|
||||
# Still used directly: `crypto::decode_hex` in tools.rs.
|
||||
skald-relay-common = { path = "../skald-relay-common" }
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt", "sync", "macros", "time", "net", "io-util"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
tracing = "0.1"
|
||||
axum = { version = "0.8" }
|
||||
rand = "0.9"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
hex = "0.4"
|
||||
qrcode = "0.14"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The `RelayAgent` control surface (plugin.md §4). This is the domain API the
|
||||
//! UI / control tools use for pairing, listing devices, and revoking. It is NOT
|
||||
//! an LLM tool itself: the three LLM tools (tools.rs) call into it.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Returned by `start_pairing`. The `code` is a random handle distinct from the
|
||||
/// `pairing_token`; it identifies the in-memory session at the QR endpoint.
|
||||
pub struct PairingHandle {
|
||||
/// e.g. `/api/plugin/mobile-connector/pairingqrcode?code=<random>`
|
||||
pub url: String,
|
||||
pub code: String,
|
||||
/// Unix ms.
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// Device authorization state, surfaced to the listing tool.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientState {
|
||||
Pending,
|
||||
Authorized,
|
||||
}
|
||||
|
||||
/// One device, surfaced for `mobile_list_devices`.
|
||||
pub struct ClientInfo {
|
||||
pub ed25519_pub: [u8; 32],
|
||||
pub x25519_pub: [u8; 32],
|
||||
pub state: ClientState,
|
||||
/// Raw device_info JSON (from `hello`), if received.
|
||||
pub device_info: Option<String>,
|
||||
pub platform: Option<String>,
|
||||
/// Unix ms of last activity, if any.
|
||||
pub last_seen: Option<i64>,
|
||||
}
|
||||
|
||||
/// The control API exposed by the plugin. Reachable via
|
||||
/// `PluginManager::get_plugin_typed::<MobileConnectorPlugin>()`.
|
||||
#[async_trait]
|
||||
pub trait RelayAgent: Send + Sync {
|
||||
/// Open the pairing window (single-window, latest-wins) and return the
|
||||
/// auto-expiring QR URL.
|
||||
async fn start_pairing(&self, ttl_secs: u32) -> anyhow::Result<PairingHandle>;
|
||||
|
||||
/// Close the pairing window.
|
||||
async fn stop_pairing(&self) -> anyhow::Result<()>;
|
||||
|
||||
/// ed25519 public key (namespace identity).
|
||||
fn agent_ed25519_pub(&self) -> [u8; 32];
|
||||
|
||||
/// Derived namespace id (hex).
|
||||
fn namespace_id(&self) -> String;
|
||||
|
||||
/// Send the current Inbox snapshot to all authorized clients.
|
||||
async fn broadcast_inbox(&self) -> anyhow::Result<()>;
|
||||
|
||||
/// Generic push notification to all authorized clients.
|
||||
async fn broadcast_notification(&self, title: &str, body: &str) -> anyhow::Result<()>;
|
||||
|
||||
/// List all known devices.
|
||||
async fn list_clients(&self) -> Vec<ClientInfo>;
|
||||
|
||||
/// Authorize a Pending device by its ed25519 pubkey.
|
||||
async fn authorize_client(&self, ed25519_pub: [u8; 32]) -> anyhow::Result<()>;
|
||||
|
||||
/// Revoke a device (lost/stolen) by its ed25519 pubkey.
|
||||
async fn revoke_client(&self, ed25519_pub: [u8; 32]) -> anyhow::Result<()>;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The application layer on top of the payload-agnostic [`RelayClient`].
|
||||
//!
|
||||
//! `RelayApp` owns the Skald-specific semantics that the transport crate
|
||||
//! deliberately knows nothing about: the E2E JSON payload schemas (`payloads`),
|
||||
//! the `InboxApi` dispatch, and the authorization policy. It consumes
|
||||
//! `client.events()` and calls `client.send(...)`; the client handles the wire,
|
||||
//! crypto, counters, and device registry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use core_api::inbox::InboxApi;
|
||||
use skald_relay_client::{ClientState, RelayClient, RelayEvent};
|
||||
|
||||
use crate::PLUGIN_ID;
|
||||
use crate::payloads::{self, ClientPayload};
|
||||
|
||||
/// Glue between the relay transport ([`RelayClient`]) and Skald's Inbox.
|
||||
pub struct RelayApp {
|
||||
client: Arc<RelayClient>,
|
||||
inbox: Arc<dyn InboxApi>,
|
||||
/// When true, a freshly paired device stays Pending until a human confirms;
|
||||
/// when false, the app auto-authorizes on `ClientPaired`.
|
||||
require_device_confirmation: bool,
|
||||
}
|
||||
|
||||
impl RelayApp {
|
||||
pub fn new(
|
||||
client: Arc<RelayClient>,
|
||||
inbox: Arc<dyn InboxApi>,
|
||||
require_device_confirmation: bool,
|
||||
) -> Self {
|
||||
Self { client, inbox, require_device_confirmation }
|
||||
}
|
||||
|
||||
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
||||
pub fn client(&self) -> &Arc<RelayClient> {
|
||||
&self.client
|
||||
}
|
||||
|
||||
// ── Inbox → clients ───────────────────────────────────────────────────────
|
||||
|
||||
/// Build the Inbox snapshot and send it (encrypted) to every Authorized
|
||||
/// client. `live=false` so the relay stores-and-forwards + pushes to offline
|
||||
/// phones.
|
||||
pub async fn broadcast_inbox(&self) -> Result<()> {
|
||||
let snapshot = self.inbox.list_pending().await;
|
||||
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
|
||||
self.broadcast_plaintext(&plaintext).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build and send a generic notification to all Authorized clients.
|
||||
pub async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
|
||||
let plaintext = serde_json::to_vec(&payloads::build_notification(title, body))?;
|
||||
self.broadcast_plaintext(&plaintext).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an opaque plaintext to every Authorized device (`live=false`).
|
||||
async fn broadcast_plaintext(&self, plaintext: &[u8]) {
|
||||
for c in self
|
||||
.client
|
||||
.list_clients()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|c| c.state == ClientState::Authorized)
|
||||
{
|
||||
if let Err(e) = self.client.send(&c.ed25519_pub, plaintext, false).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to client");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the current Inbox snapshot to a single client (the targeted reply to
|
||||
/// `inbox_request`). `live=true`: the requester is online by construction.
|
||||
async fn send_inbox_to(&self, client_ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
let snapshot = self.inbox.list_pending().await;
|
||||
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
|
||||
self.client.send(client_ed25519_pub, &plaintext, true).await
|
||||
}
|
||||
|
||||
// ── Clients → Inbox ───────────────────────────────────────────────────────
|
||||
|
||||
/// Apply a decoded client payload to the Inbox. `payload` is the clean inner
|
||||
/// JSON the client already decrypted + de-framed.
|
||||
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
||||
match payloads::parse_client_payload(payload) {
|
||||
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
|
||||
if approved {
|
||||
self.inbox.approve(request_id).await;
|
||||
} else {
|
||||
self.inbox.reject(request_id, reason.unwrap_or_default()).await;
|
||||
}
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
ClientPayload::ClarificationResponse { request_id, answer } => {
|
||||
self.inbox.answer(request_id, answer).await;
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
ClientPayload::ElicitationResponse { request_id, action, content } => {
|
||||
// `content` may hold a secret (SSH/sudo password): hand it straight
|
||||
// to the Inbox; never log/persist it in clear (payloads.md §3.1).
|
||||
self.inbox.resolve_elicitation(request_id, action, content).await;
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
ClientPayload::Hello { device_info } => {
|
||||
if let Err(e) = self.client.set_device_info(from, &device_info.to_string()).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to persist device_info");
|
||||
}
|
||||
}
|
||||
ClientPayload::InboxRequest => {
|
||||
if let Err(e) = self.send_inbox_to(from).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
||||
}
|
||||
}
|
||||
ClientPayload::Logout => {
|
||||
if let Err(e) = self.client.revoke(from).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "logout revoke failed");
|
||||
}
|
||||
}
|
||||
ClientPayload::Unknown => {
|
||||
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event loop ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Consume the client's [`RelayEvent`] stream until `cancel` fires. This is
|
||||
/// where the authorization policy and Inbox application live.
|
||||
pub async fn run_event_loop(
|
||||
self: Arc<Self>,
|
||||
mut rx: broadcast::Receiver<RelayEvent>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(RelayEvent::Message { from, payload, .. }) => {
|
||||
self.apply_client_payload(&from, &payload).await;
|
||||
}
|
||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
||||
if self.require_device_confirmation {
|
||||
debug!(
|
||||
plugin = PLUGIN_ID,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired (pending manual confirmation)"
|
||||
);
|
||||
let _ = self
|
||||
.broadcast_notification(
|
||||
"Nuovo device",
|
||||
"Un nuovo device è in attesa di conferma",
|
||||
)
|
||||
.await;
|
||||
} else if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
||||
} else {
|
||||
// Send the newly-authorized device the current snapshot.
|
||||
let _ = self.broadcast_inbox().await;
|
||||
}
|
||||
}
|
||||
Ok(RelayEvent::ClientRevoked { .. })
|
||||
| Ok(RelayEvent::Connected)
|
||||
| Ok(RelayEvent::Disconnected) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(plugin = PLUGIN_ID, skipped = n, "relay event stream lagged");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Mobile connector plugin (plugin id `mobile-connector`).
|
||||
//!
|
||||
//! Bridges Skald's Inbox (approvals + clarifications) to mobile apps over the
|
||||
//! relay. The **networking** (v2 WS transport, E2E crypto, anti-replay counters,
|
||||
//! pairing, device authorization, SQLite persistence) lives in the standalone
|
||||
//! `skald-relay-client` crate; this plugin is the thin **application** layer on
|
||||
//! top of it. See `data/iOS-app/v2/relay-protocol.md` for the wire contract and
|
||||
//! `docs/relay/` for the client/server split.
|
||||
//!
|
||||
//! Module map:
|
||||
//! - `payloads` — E2E JSON payload schemas (inbox_update, responses, …)
|
||||
//! - `app` — `RelayApp`: Inbox dispatch, auth policy, the events() loop
|
||||
//! - `router` — the QR-code HTTP endpoint
|
||||
//! - `agent` — the `RelayAgent` control trait
|
||||
//! - `tools` — `Tool` impls callable by the host (registered in the main crate)
|
||||
|
||||
mod agent;
|
||||
mod app;
|
||||
mod notifier;
|
||||
mod payloads;
|
||||
mod proxy;
|
||||
mod router;
|
||||
mod tools;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use core_api::plugin::{Plugin, PluginContext};
|
||||
use skald_relay_client::{ClientState as RelayClientState, RelayClient, RelayClientConfig, SeedSource};
|
||||
|
||||
pub use agent::{ClientInfo, ClientState, PairingHandle, RelayAgent};
|
||||
pub use tools::mobile_tools;
|
||||
|
||||
use app::RelayApp;
|
||||
use notifier::{DelayedNotifier, Kind};
|
||||
|
||||
pub(crate) const PLUGIN_ID: &str = "mobile-connector";
|
||||
const DEFAULT_TTL: u32 = 300;
|
||||
const MAX_TTL: u32 = 600;
|
||||
/// Default debounce before an unresolved Inbox item is pushed to the phone.
|
||||
const DEFAULT_NOTIFY_DELAY_SECS: u64 = 20;
|
||||
/// Seed file path (relative to the process working dir). Kept byte-identical to
|
||||
/// the historical location so existing identities/devices survive the upgrade.
|
||||
const SEED_PATH: &str = "data/relay/seed";
|
||||
|
||||
/// The mobile-connector plugin.
|
||||
pub struct MobileConnectorPlugin {
|
||||
running: AtomicBool,
|
||||
/// Live application state — present only while running. Wrapped in `Arc` so
|
||||
/// the HTTP router (built once at startup) can dynamically point to whichever
|
||||
/// `RelayApp` is current after a reconfigure (plugin#reload → new state).
|
||||
inner: Arc<Mutex<Option<Arc<RelayApp>>>>,
|
||||
cancel: Mutex<Option<CancellationToken>>,
|
||||
handles: Mutex<Vec<JoinHandle<()>>>,
|
||||
/// Debounces Inbox pushes to the phone; present only while running.
|
||||
notifier: Mutex<Option<Arc<DelayedNotifier>>>,
|
||||
}
|
||||
|
||||
impl MobileConnectorPlugin {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
running: AtomicBool::new(false),
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
cancel: Mutex::new(None),
|
||||
handles: Mutex::new(Vec::new()),
|
||||
notifier: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the live app, if running. Used by the `RelayAgent` impl and the
|
||||
/// router accessor.
|
||||
async fn app(&self) -> Option<Arc<RelayApp>> {
|
||||
self.inner.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Start the runloop and the bus subscriber with the given config.
|
||||
async fn start_with(&self, config: Value, ctx: &PluginContext) -> Result<()> {
|
||||
let relay_url = config["relay_url"].as_str().unwrap_or("").to_string();
|
||||
if relay_url.is_empty() {
|
||||
warn!(plugin = PLUGIN_ID, "relay_url not configured; plugin idle");
|
||||
}
|
||||
let pairing_ttl = config["pairing_ttl"]
|
||||
.as_u64()
|
||||
.map(|v| (v as u32).min(MAX_TTL))
|
||||
.unwrap_or(DEFAULT_TTL);
|
||||
let require_device_confirmation = config["require_device_confirmation"]
|
||||
.as_bool()
|
||||
.unwrap_or(true);
|
||||
let notify_delay = std::time::Duration::from_secs(
|
||||
config["notify_delay_secs"]
|
||||
.as_u64()
|
||||
.unwrap_or(DEFAULT_NOTIFY_DELAY_SECS),
|
||||
);
|
||||
|
||||
// Build the transport client (derives identity, inits the DB table).
|
||||
let client = Arc::new(
|
||||
RelayClient::new(
|
||||
Arc::clone(&ctx.db),
|
||||
RelayClientConfig {
|
||||
relay_url,
|
||||
pairing_ttl,
|
||||
seed: SeedSource::Path(SEED_PATH.into()),
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
info!(
|
||||
plugin = PLUGIN_ID,
|
||||
namespace = client.namespace_id_hex(),
|
||||
"mobile-connector identity loaded"
|
||||
);
|
||||
client.start().await?;
|
||||
|
||||
let app = Arc::new(RelayApp::new(
|
||||
Arc::clone(&client),
|
||||
Arc::clone(&ctx.inbox),
|
||||
require_device_confirmation,
|
||||
));
|
||||
|
||||
let notifier = DelayedNotifier::new(Arc::clone(&app), notify_delay);
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Event loop: apply inbound payloads + authorization policy.
|
||||
{
|
||||
let app2 = Arc::clone(&app);
|
||||
let rx = client.events();
|
||||
let c = cancel.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
app2.run_event_loop(rx, c).await;
|
||||
}));
|
||||
}
|
||||
|
||||
// Bus subscriber: route the four Inbox events through the debouncer.
|
||||
// `*Requested` arms a delayed push; `*Resolved` cancels it (or refreshes
|
||||
// the phone if the push already went out).
|
||||
{
|
||||
let notifier = Arc::clone(¬ifier);
|
||||
let c = cancel.clone();
|
||||
let mut rx = ctx.chat_hub.events(PLUGIN_ID);
|
||||
handles.push(tokio::spawn(async move {
|
||||
use core_api::events::ServerEvent::*;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = c.cancelled() => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(ge) => match ge.event {
|
||||
ApprovalRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Approval, request_id)).await;
|
||||
}
|
||||
ApprovalResolved { request_id, .. } => {
|
||||
notifier.on_resolved((Kind::Approval, request_id)).await;
|
||||
}
|
||||
ClarificationRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Clarification, request_id)).await;
|
||||
}
|
||||
ClarificationResolved { request_id } => {
|
||||
notifier.on_resolved((Kind::Clarification, request_id)).await;
|
||||
}
|
||||
ElicitationRequested { request_id, .. } => {
|
||||
notifier.on_requested((Kind::Elicitation, request_id)).await;
|
||||
}
|
||||
ElicitationResolved { request_id } => {
|
||||
notifier.on_resolved((Kind::Elicitation, request_id)).await;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(plugin = PLUGIN_ID, skipped = n, "event bus lagged");
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// HTTP reverse proxy: bridge `http-local-proxy` pipes to the local web
|
||||
// server so the native app can render the web UI over the relay (no NAT
|
||||
// hole / Tailscale). Pinned to 127.0.0.1:<web_port>; access gated by the
|
||||
// relay's pipe auth (only authorized namespace members).
|
||||
{
|
||||
let client2 = Arc::clone(&client);
|
||||
let c = cancel.clone();
|
||||
let port = ctx.web_port;
|
||||
handles.push(tokio::spawn(async move {
|
||||
crate::proxy::run_proxy_loop(client2, port, c).await;
|
||||
}));
|
||||
}
|
||||
|
||||
*self.notifier.lock().await = Some(notifier);
|
||||
*self.inner.lock().await = Some(app);
|
||||
*self.cancel.lock().await = Some(cancel);
|
||||
*self.handles.lock().await = handles;
|
||||
self.running.store(true, Ordering::Relaxed);
|
||||
info!(plugin = PLUGIN_ID, "mobile-connector started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_inner(&self) {
|
||||
if let Some(c) = self.cancel.lock().await.take() {
|
||||
c.cancel();
|
||||
}
|
||||
// Cancel any armed (not-yet-fired) push timers.
|
||||
if let Some(notifier) = self.notifier.lock().await.take() {
|
||||
notifier.cancel_all().await;
|
||||
}
|
||||
// Shut down the transport (cancels + joins the WS loop) before dropping
|
||||
// the app.
|
||||
if let Some(app) = self.inner.lock().await.take() {
|
||||
app.client().shutdown().await;
|
||||
}
|
||||
for h in self.handles.lock().await.drain(..) {
|
||||
let _ = h.await;
|
||||
}
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
info!(plugin = PLUGIN_ID, "mobile-connector stopped");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MobileConnectorPlugin {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for MobileConnectorPlugin {
|
||||
fn id(&self) -> &str { PLUGIN_ID }
|
||||
fn name(&self) -> &str { "Mobile Connector" }
|
||||
fn description(&self) -> &str {
|
||||
"Connects mobile apps to this Skald instance via the relay: bridges the \
|
||||
Inbox (approvals + clarifications) to phones with end-to-end encryption."
|
||||
}
|
||||
fn is_running(&self) -> bool { self.running.load(Ordering::Relaxed) }
|
||||
|
||||
fn config_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"relay_url": {
|
||||
"type": "string",
|
||||
"title": "Relay URL",
|
||||
"description": "wss:// URL of the relay (e.g. wss://relay.skaldagent.net/v1/ws).",
|
||||
},
|
||||
"pairing_ttl": {
|
||||
"type": "integer",
|
||||
"default": DEFAULT_TTL,
|
||||
"maximum": MAX_TTL,
|
||||
"title": "Pairing TTL (seconds)",
|
||||
"description": "How long a pairing window stays open. Max 600.",
|
||||
},
|
||||
"require_device_confirmation": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"title": "Require device confirmation",
|
||||
"description": "Require manual confirmation before a newly paired device is authorized (recommended).",
|
||||
},
|
||||
"notify_delay_secs": {
|
||||
"type": "integer",
|
||||
"default": DEFAULT_NOTIFY_DELAY_SECS,
|
||||
"minimum": 0,
|
||||
"title": "Notification delay (seconds)",
|
||||
"description": "Wait this long before pushing an approval/clarification to the phone. If you answer on the computer within the window, no phone notification is sent. Set 0 to push immediately. (MCP elicitations are Inbox-only and always pushed immediately, regardless of this setting.)",
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_status(&self) -> Option<Value> {
|
||||
if !self.running.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
// Synchronous status: report connection flag from the live client.
|
||||
let connected = self
|
||||
.inner
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|app| app.client().is_connected()))
|
||||
.unwrap_or(false);
|
||||
Some(json!({ "connected": connected }))
|
||||
}
|
||||
|
||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
|
||||
match (enabled, self.is_running()) {
|
||||
(true, false) => self.start_with(config, &ctx).await,
|
||||
(false, true) => { self.stop_inner().await; Ok(()) }
|
||||
(true, true) => { self.stop_inner().await; self.start_with(config, &ctx).await }
|
||||
(false, false) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(&self, _ctx: PluginContext) -> Result<()> {
|
||||
// Lifecycle is driven by reload(enabled, ...); nothing to do here.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<()> {
|
||||
self.stop_inner().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_router(&self) -> Option<axum::Router> {
|
||||
// The router is collected once at WebFrontend startup, but the inner
|
||||
// `RelayApp` may be replaced on reconfigure. We hand over the shared
|
||||
// `Arc<Mutex<…>>` so the QR route always resolves the *current* app.
|
||||
Some(router::build(Arc::clone(&self.inner)))
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any { self }
|
||||
fn as_arc_any(self: Arc<Self>) -> Arc<dyn std::any::Any + Send + Sync> { self }
|
||||
}
|
||||
|
||||
// ── RelayAgent control surface ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl RelayAgent for MobileConnectorPlugin {
|
||||
async fn start_pairing(&self, ttl_secs: u32) -> Result<PairingHandle> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
let ttl = if ttl_secs == 0 { 0 } else { ttl_secs.min(MAX_TTL) };
|
||||
let started = app.client().start_pairing(ttl).await?;
|
||||
Ok(PairingHandle {
|
||||
url: format!("/api/plugin/{PLUGIN_ID}/pairingqrcode?code={}", started.code),
|
||||
code: started.code,
|
||||
expires_at: started.expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stop_pairing(&self) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.client().stop_pairing().await
|
||||
}
|
||||
|
||||
fn agent_ed25519_pub(&self) -> [u8; 32] {
|
||||
self.inner
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|app| app.client().agent_ed25519_pub()))
|
||||
.unwrap_or([0u8; 32])
|
||||
}
|
||||
|
||||
fn namespace_id(&self) -> String {
|
||||
self.inner
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|app| app.client().namespace_id_hex()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn broadcast_inbox(&self) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.broadcast_inbox().await
|
||||
}
|
||||
|
||||
async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.broadcast_notification(title, body).await
|
||||
}
|
||||
|
||||
async fn list_clients(&self) -> Vec<ClientInfo> {
|
||||
let Some(app) = self.app().await else { return Vec::new() };
|
||||
app.client()
|
||||
.list_clients()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| ClientInfo {
|
||||
ed25519_pub: r.ed25519_pub,
|
||||
x25519_pub: r.x25519_pub,
|
||||
state: match r.state {
|
||||
RelayClientState::Authorized => ClientState::Authorized,
|
||||
RelayClientState::Pending => ClientState::Pending,
|
||||
},
|
||||
device_info: r.device_info,
|
||||
platform: r.platform,
|
||||
last_seen: r.last_seen,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn authorize_client(&self, ed25519_pub: [u8; 32]) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.client().authorize(&ed25519_pub).await?;
|
||||
// Send the current Inbox snapshot to the newly-authorized device
|
||||
// (payload-agnostic client doesn't do this itself).
|
||||
let _ = app.broadcast_inbox().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_client(&self, ed25519_pub: [u8; 32]) -> Result<()> {
|
||||
let app = self.app().await.ok_or_else(|| anyhow::anyhow!("plugin not running"))?;
|
||||
app.client().revoke(&ed25519_pub).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Delayed push notifier.
|
||||
//!
|
||||
//! The mobile push for an Inbox item is only valuable when the user is *away*
|
||||
//! from the computer. When they're sitting at the chat, every approval /
|
||||
//! clarification would otherwise fire an instant — and pointless — phone
|
||||
//! notification, since they'll answer on the computer within seconds.
|
||||
//!
|
||||
//! `DelayedNotifier` debounces that: when a request enters the Inbox it starts a
|
||||
//! timer; only if the request is still unresolved after `delay` does it push
|
||||
//! (`broadcast_inbox`). If the user resolves it on the computer first, the timer
|
||||
//! is cancelled and **no** push is sent. Once a push *has* gone out, the eventual
|
||||
//! resolution is broadcast so the phone clears the item.
|
||||
//!
|
||||
//! Elicitations are the exception: they live only in the Inbox (never inline in
|
||||
//! the chat), so there is no computer-side answer to debounce against and they
|
||||
//! are pushed immediately regardless of `delay`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::PLUGIN_ID;
|
||||
use crate::app::RelayApp;
|
||||
|
||||
/// Which Inbox manager a `request_id` belongs to. Approvals and clarifications
|
||||
/// use independent atomic counters, so the id alone is not unique.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Kind {
|
||||
Approval,
|
||||
Clarification,
|
||||
Elicitation,
|
||||
}
|
||||
|
||||
/// `(kind, request_id)` — unique across both Inbox managers.
|
||||
type Key = (Kind, i64);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Requested, timer armed, push not yet sent.
|
||||
pending: HashMap<Key, CancellationToken>,
|
||||
/// Push already sent, awaiting the resolution that clears the phone item.
|
||||
notified: HashSet<Key>,
|
||||
}
|
||||
|
||||
/// Debounces Inbox pushes to the phone. Cheap to clone (`Arc` inside).
|
||||
pub struct DelayedNotifier {
|
||||
app: Arc<RelayApp>,
|
||||
delay: Duration,
|
||||
state: Mutex<State>,
|
||||
}
|
||||
|
||||
impl DelayedNotifier {
|
||||
pub fn new(app: Arc<RelayApp>, delay: Duration) -> Arc<Self> {
|
||||
Arc::new(Self { app, delay, state: Mutex::new(State::default()) })
|
||||
}
|
||||
|
||||
/// A request entered the Inbox: arm a timer. If `delay` elapses before a
|
||||
/// matching `on_resolved`, push the Inbox to the phone.
|
||||
///
|
||||
/// Exception: elicitations (e.g. MCP password prompts) live *only* in the
|
||||
/// Inbox — never inline in the chat — so there is no computer-side answer to
|
||||
/// debounce against. Waiting `delay` would just delay a push that is always
|
||||
/// warranted, so they are pushed immediately (marked *notified* so the
|
||||
/// eventual `on_resolved` refreshes the phone to clear the item).
|
||||
pub async fn on_requested(self: &Arc<Self>, key: Key) {
|
||||
if key.0 == Kind::Elicitation {
|
||||
let newly_notified = {
|
||||
let mut st = self.state.lock().await;
|
||||
// Drop any stale armed timer, then mark notified.
|
||||
if let Some(old) = st.pending.remove(&key) {
|
||||
old.cancel();
|
||||
}
|
||||
st.notified.insert(key)
|
||||
};
|
||||
if newly_notified {
|
||||
if let Err(e) = self.app.broadcast_inbox().await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "immediate elicitation push failed");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let token = CancellationToken::new();
|
||||
{
|
||||
let mut st = self.state.lock().await;
|
||||
// Re-arming an already-pending key: cancel the stale timer first.
|
||||
if let Some(old) = st.pending.insert(key, token.clone()) {
|
||||
old.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
let this = Arc::clone(self);
|
||||
let delay = self.delay;
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = token.cancelled() => {} // resolved within the window — send nothing
|
||||
_ = sleep(delay) => {
|
||||
// Promote pending → notified under the lock, then push.
|
||||
let still_armed = {
|
||||
let mut st = this.state.lock().await;
|
||||
if st.pending.remove(&key).is_some() {
|
||||
st.notified.insert(key);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if still_armed {
|
||||
if let Err(e) = this.app.broadcast_inbox().await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "delayed inbox push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A request was resolved (approved / rejected / answered). Suppress the push
|
||||
/// if it was still pending; otherwise refresh the phone so it clears the item.
|
||||
pub async fn on_resolved(&self, key: Key) {
|
||||
let broadcast = {
|
||||
let mut st = self.state.lock().await;
|
||||
if let Some(token) = st.pending.remove(&key) {
|
||||
token.cancel(); // push hadn't fired yet — suppress entirely
|
||||
false
|
||||
} else {
|
||||
// Push already went out, or key untracked: refresh the snapshot.
|
||||
st.notified.remove(&key);
|
||||
true
|
||||
}
|
||||
};
|
||||
if broadcast {
|
||||
if let Err(e) = self.app.broadcast_inbox().await {
|
||||
warn!(plugin = PLUGIN_ID, error = %e, "inbox broadcast failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every armed timer (called on plugin stop).
|
||||
pub async fn cancel_all(&self) {
|
||||
let mut st = self.state.lock().await;
|
||||
for (_, token) in st.pending.drain() {
|
||||
token.cancel();
|
||||
}
|
||||
st.notified.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! E2E payload schemas (payloads.md). These are the JSON plaintexts sealed into
|
||||
//! the `ciphertext` field of a `message` envelope; the relay never sees them.
|
||||
//!
|
||||
//! `request_id` travels as a decimal STRING of the i64 rowid (plugin.md §2): we
|
||||
//! serialize i64 → string outbound, and parse string → i64 inbound, dropping
|
||||
//! anything that does not parse.
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
|
||||
use core_api::inbox::InboxSnapshot;
|
||||
|
||||
/// Generate a v4-ish UUID string for the payload `id` field. We avoid pulling in
|
||||
/// the `uuid` crate: a 16-byte CSPRNG value formatted as a UUID is sufficient
|
||||
/// for dedup/ack purposes (payloads.md §1).
|
||||
fn new_id() -> String {
|
||||
use rand::RngCore;
|
||||
let mut b = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut b);
|
||||
// Set version (4) and variant bits for a well-formed UUID.
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
format!(
|
||||
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15],
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert an ISO-8601 UTC timestamp string into unix milliseconds. Falls back
|
||||
/// to "now" if the string fails to parse (payloads.md wants an int ms field).
|
||||
fn iso_to_ms(iso: &str) -> i64 {
|
||||
chrono::DateTime::parse_from_rfc3339(iso)
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.unwrap_or_else(|_| Utc::now().timestamp_millis())
|
||||
}
|
||||
|
||||
// ── Agent → Client ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the `inbox_update` snapshot payload (payloads.md §3.1) from an
|
||||
/// `InboxSnapshot`. Mirrors the Inbox 1:1; `badge` = total pending.
|
||||
pub fn build_inbox_update(snapshot: &InboxSnapshot) -> Value {
|
||||
let approvals: Vec<Value> = snapshot
|
||||
.approvals
|
||||
.iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"request_id": a.request_id.to_string(),
|
||||
"tool_name": a.tool_name,
|
||||
"agent_label": "Skald",
|
||||
// Short human label for card/notification; raw args for the detail
|
||||
// dialog (untruncated — e.g. the full `execute_cmd` command).
|
||||
"summary": a.summary,
|
||||
"arguments": a.arguments,
|
||||
"created_at": iso_to_ms(&a.created_at),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let clarifications: Vec<Value> = snapshot
|
||||
.clarifications
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"request_id": c.request_id.to_string(),
|
||||
"question": c.question,
|
||||
"context": c.context_label,
|
||||
"suggested_answers": c.suggested_answers,
|
||||
"agent_label": "Skald",
|
||||
"created_at": iso_to_ms(&c.created_at),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// MCP server-initiated input requests (e.g. an SSH/sudo password). We ship
|
||||
// only the prompt metadata — never the value; the value is supplied by the
|
||||
// device in `elicitation_response.content` and travels E2E (payloads.md §3.1).
|
||||
let elicitations: Vec<Value> = snapshot
|
||||
.elicitations
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"request_id": e.request_id.to_string(),
|
||||
"server_name": e.server_name,
|
||||
"message": e.message,
|
||||
"field_name": e.field_name, // Option<String> → null if absent
|
||||
"sensitive": e.sensitive,
|
||||
"is_confirmation": e.is_confirmation,
|
||||
"created_at": iso_to_ms(&e.created_at),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"v": 1,
|
||||
"kind": "inbox_update",
|
||||
"id": new_id(),
|
||||
"ts": Utc::now().timestamp_millis(),
|
||||
"badge": snapshot.total,
|
||||
"approvals": approvals,
|
||||
"clarifications": clarifications,
|
||||
"elicitations": elicitations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a generic `notification` payload (payloads.md §3.2).
|
||||
pub fn build_notification(title: &str, body: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"v": 1,
|
||||
"kind": "notification",
|
||||
"id": new_id(),
|
||||
"ts": Utc::now().timestamp_millis(),
|
||||
"title": title,
|
||||
"body": body,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Client → Agent ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A decoded client→agent payload (payloads.md §4). Only the fields the agent
|
||||
/// acts on are modeled; unknown kinds become [`ClientPayload::Unknown`].
|
||||
#[derive(Debug)]
|
||||
pub enum ClientPayload {
|
||||
/// `hello`: device_info carried E2E.
|
||||
Hello { device_info: Value },
|
||||
/// `approval_response`.
|
||||
ApprovalResponse { request_id: i64, approved: bool, reason: Option<String> },
|
||||
/// `clarification_response`.
|
||||
ClarificationResponse { request_id: i64, answer: String },
|
||||
/// `elicitation_response`: the device's reply to an MCP elicitation. `action`
|
||||
/// is `"accept"`/`"decline"`/`"cancel"`; `content` (present only for `accept`)
|
||||
/// is an object keyed by `field_name` whose value may be a secret — never log it.
|
||||
ElicitationResponse { request_id: i64, action: String, content: Option<Value> },
|
||||
/// `inbox_request`: client asks for the current Inbox snapshot (payloads.md
|
||||
/// §4.6). Sent after every `auth_ok`; the agent replies with a targeted
|
||||
/// `inbox_update`. No fields beyond the common envelope.
|
||||
InboxRequest,
|
||||
/// `logout`: device removes itself.
|
||||
Logout,
|
||||
/// Anything else (ack, unknown kind, malformed request_id) — ignored.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Parse a decrypted client payload. Enforces `v == 1` and required-field
|
||||
/// presence; on malformed input returns [`ClientPayload::Unknown`] (never panics,
|
||||
/// payloads.md §6).
|
||||
pub fn parse_client_payload(plaintext: &[u8]) -> ClientPayload {
|
||||
let Ok(v) = serde_json::from_slice::<Value>(plaintext) else {
|
||||
return ClientPayload::Unknown;
|
||||
};
|
||||
if v.get("v").and_then(Value::as_u64) != Some(1) {
|
||||
return ClientPayload::Unknown;
|
||||
}
|
||||
let kind = v.get("kind").and_then(Value::as_str).unwrap_or("");
|
||||
match kind {
|
||||
"hello" => match v.get("device_info") {
|
||||
Some(di) if di.is_object() => ClientPayload::Hello { device_info: di.clone() },
|
||||
_ => ClientPayload::Unknown,
|
||||
},
|
||||
"approval_response" => {
|
||||
let Some(rid) = parse_request_id(&v) else { return ClientPayload::Unknown };
|
||||
match v.get("decision").and_then(Value::as_str) {
|
||||
Some("approved") => ClientPayload::ApprovalResponse { request_id: rid, approved: true, reason: None },
|
||||
Some("rejected") => ClientPayload::ApprovalResponse {
|
||||
request_id: rid,
|
||||
approved: false,
|
||||
reason: v.get("reason").and_then(Value::as_str).map(str::to_string),
|
||||
},
|
||||
_ => ClientPayload::Unknown,
|
||||
}
|
||||
}
|
||||
"clarification_response" => {
|
||||
let Some(rid) = parse_request_id(&v) else { return ClientPayload::Unknown };
|
||||
match v.get("answer").and_then(Value::as_str) {
|
||||
Some(answer) => ClientPayload::ClarificationResponse { request_id: rid, answer: answer.to_string() },
|
||||
None => ClientPayload::Unknown,
|
||||
}
|
||||
}
|
||||
"elicitation_response" => {
|
||||
let Some(rid) = parse_request_id(&v) else { return ClientPayload::Unknown };
|
||||
let action = match v.get("action").and_then(Value::as_str) {
|
||||
Some(a @ ("accept" | "decline" | "cancel")) => a.to_string(),
|
||||
_ => return ClientPayload::Unknown,
|
||||
};
|
||||
// `content` is meaningful only for `accept` and must be an object
|
||||
// (keyed by `field_name`); anything else is dropped.
|
||||
let content = v.get("content").filter(|c| c.is_object()).cloned();
|
||||
ClientPayload::ElicitationResponse { request_id: rid, action, content }
|
||||
}
|
||||
"inbox_request" => ClientPayload::InboxRequest,
|
||||
"logout" => ClientPayload::Logout,
|
||||
_ => ClientPayload::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `request_id` decimal string into an i64 (plugin.md §2).
|
||||
fn parse_request_id(v: &Value) -> Option<i64> {
|
||||
v.get("request_id").and_then(Value::as_str)?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `accept` carries the secret under `content`, keyed by `field_name`.
|
||||
#[test]
|
||||
fn elicitation_response_accept_with_content() {
|
||||
let raw = br#"{
|
||||
"v": 1, "kind": "elicitation_response", "id": "abc", "ts": 1750000000000,
|
||||
"request_id": "123", "action": "accept",
|
||||
"content": { "password": "hunter2" }
|
||||
}"#;
|
||||
match parse_client_payload(raw) {
|
||||
ClientPayload::ElicitationResponse { request_id, action, content } => {
|
||||
assert_eq!(request_id, 123);
|
||||
assert_eq!(action, "accept");
|
||||
let content = content.expect("accept must carry content");
|
||||
assert_eq!(content["password"], "hunter2");
|
||||
}
|
||||
other => panic!("expected ElicitationResponse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `decline`/`cancel` have no `content`; a missing object yields `None`.
|
||||
#[test]
|
||||
fn elicitation_response_decline_without_content() {
|
||||
let raw = br#"{
|
||||
"v": 1, "kind": "elicitation_response", "id": "abc", "ts": 1750000000000,
|
||||
"request_id": "7", "action": "decline"
|
||||
}"#;
|
||||
match parse_client_payload(raw) {
|
||||
ClientPayload::ElicitationResponse { request_id, action, content } => {
|
||||
assert_eq!(request_id, 7);
|
||||
assert_eq!(action, "decline");
|
||||
assert!(content.is_none());
|
||||
}
|
||||
other => panic!("expected ElicitationResponse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-object `content` is dropped rather than forwarded.
|
||||
#[test]
|
||||
fn elicitation_response_non_object_content_dropped() {
|
||||
let raw = br#"{
|
||||
"v": 1, "kind": "elicitation_response", "id": "abc", "ts": 1750000000000,
|
||||
"request_id": "9", "action": "accept", "content": "not-an-object"
|
||||
}"#;
|
||||
match parse_client_payload(raw) {
|
||||
ClientPayload::ElicitationResponse { content, .. } => assert!(content.is_none()),
|
||||
other => panic!("expected ElicitationResponse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown `action` is rejected as `Unknown` (no resolution attempted).
|
||||
#[test]
|
||||
fn elicitation_response_bad_action_is_unknown() {
|
||||
let raw = br#"{
|
||||
"v": 1, "kind": "elicitation_response", "id": "abc", "ts": 1750000000000,
|
||||
"request_id": "1", "action": "approve"
|
||||
}"#;
|
||||
assert!(matches!(parse_client_payload(raw), ClientPayload::Unknown));
|
||||
}
|
||||
|
||||
/// A missing/non-string `request_id` is rejected as `Unknown`.
|
||||
#[test]
|
||||
fn elicitation_response_missing_request_id_is_unknown() {
|
||||
let raw = br#"{
|
||||
"v": 1, "kind": "elicitation_response", "id": "abc", "ts": 1750000000000,
|
||||
"action": "accept", "content": { "x": "y" }
|
||||
}"#;
|
||||
assert!(matches!(parse_client_payload(raw), ClientPayload::Unknown));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! HTTP reverse proxy over the relay pipe (`docs/relay/pipe.md`).
|
||||
//!
|
||||
//! A remote client (the native app) opens a relayed byte-stream pipe with
|
||||
//! `stream_type = "http-local-proxy"` and treats it as a raw TCP connection to
|
||||
//! Skald's local web server. This loop accepts those pipes and splices each one,
|
||||
//! byte-for-byte, to a fresh `127.0.0.1:<web_port>` connection — a transparent
|
||||
//! tunnel (we never parse HTTP, so HTTP/1.1 keep-alive, parallel connections, and
|
||||
//! the chat WebSocket upgrade all work). The destination is pinned to the local
|
||||
//! web port: the client cannot choose host/port, so this never becomes an open
|
||||
//! proxy to other local services.
|
||||
//!
|
||||
//! Access is already gated by the relay: only the namespace agent or an authorized
|
||||
//! client can establish a pipe (`pipe.md §3.1`).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use skald_relay_client::{IncomingPipe, PipeConnection, RelayClient};
|
||||
|
||||
use crate::PLUGIN_ID;
|
||||
|
||||
/// Pipe `stream_type` this loop handles. The native app sets the same value when
|
||||
/// opening the pipe.
|
||||
pub(crate) const HTTP_LOCAL_PROXY_STREAM_TYPE: &str = "http-local-proxy";
|
||||
|
||||
/// Read buffer for the local→remote direction. Bounded so a pipe can't buffer
|
||||
/// unboundedly (the relay also caps the data-plane frame size).
|
||||
const READ_BUF: usize = 64 * 1024;
|
||||
|
||||
/// Monotonic per-connection id for log correlation across concurrent tunnels.
|
||||
static CONN_SEQ: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Subscribe to inbound pipe invites and reverse-proxy `http-local-proxy` pipes
|
||||
/// to the local web server. One spawned task per accepted pipe.
|
||||
pub(crate) async fn run_proxy_loop(
|
||||
client: Arc<RelayClient>,
|
||||
web_port: u16,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
let mut rx = client.incoming_pipes();
|
||||
debug!(plugin = PLUGIN_ID, web_port, "http-local-proxy: listening for pipe invites");
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(incoming) => {
|
||||
trace!(
|
||||
plugin = PLUGIN_ID,
|
||||
stream_type = %incoming.stream_type,
|
||||
from = %hex::encode(incoming.from),
|
||||
"http-local-proxy: pipe invite received",
|
||||
);
|
||||
// Ignore (don't reject) other stream_types: `incoming_pipes` is a
|
||||
// broadcast, so a future consumer may legitimately want them.
|
||||
if incoming.stream_type != HTTP_LOCAL_PROXY_STREAM_TYPE {
|
||||
trace!(plugin = PLUGIN_ID, stream_type = %incoming.stream_type,
|
||||
"http-local-proxy: stream_type not ours, ignoring");
|
||||
continue;
|
||||
}
|
||||
let client = Arc::clone(&client);
|
||||
let child = cancel.child_token();
|
||||
tokio::spawn(async move {
|
||||
accept_and_proxy(client, incoming, web_port, child).await;
|
||||
});
|
||||
}
|
||||
Err(RecvError::Lagged(n)) => {
|
||||
warn!(plugin = PLUGIN_ID, skipped = n, "incoming pipes lagged");
|
||||
}
|
||||
Err(RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(plugin = PLUGIN_ID, "http-local-proxy: invite loop stopped");
|
||||
}
|
||||
|
||||
/// Accept one invite, then proxy it. Runs in its own task.
|
||||
async fn accept_and_proxy(
|
||||
client: Arc<RelayClient>,
|
||||
incoming: IncomingPipe,
|
||||
web_port: u16,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
let conn = CONN_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
debug!(plugin = PLUGIN_ID, conn, from = %hex::encode(incoming.from),
|
||||
"http-local-proxy: accepting pipe");
|
||||
let pipe = match client.accept_pipe(&incoming).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(plugin = PLUGIN_ID, conn, error = %e, "http-local-proxy: accept_pipe failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
debug!(plugin = PLUGIN_ID, conn, "http-local-proxy: pipe accepted, opening local connection");
|
||||
proxy_one(conn, pipe, web_port, cancel).await;
|
||||
}
|
||||
|
||||
/// Splice a pipe to a fresh local TCP connection until either side closes.
|
||||
///
|
||||
/// Full-duplex: the pipe is `split` into independent send/receive halves, each
|
||||
/// driven by its own task, so the two directions never block each other (a
|
||||
/// stalled write on one side can't hold up the other). `PipeSender::send`
|
||||
/// applies backpressure internally (it blocks only when the pipe's send buffer
|
||||
/// is full), and `recv`/`read` are cancel-safe. When either direction ends it
|
||||
/// cancels the shared token so the other unwinds; dropping both pipe halves
|
||||
/// closes the socket.
|
||||
async fn proxy_one(conn: u64, pipe: PipeConnection, port: u16, cancel: CancellationToken) {
|
||||
let tcp = match TcpStream::connect(("127.0.0.1", port)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(plugin = PLUGIN_ID, conn, port, error = %e, "http-local-proxy: local connect failed");
|
||||
pipe.close().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
debug!(plugin = PLUGIN_ID, conn, port, "http-local-proxy: local connection established");
|
||||
let (mut rd, mut wr) = tcp.into_split();
|
||||
let (mut tx, mut rx) = pipe.split();
|
||||
let to_local = Arc::new(AtomicU64::new(0)); // remote → local bytes
|
||||
let to_remote = Arc::new(AtomicU64::new(0)); // local → remote bytes
|
||||
|
||||
// remote → local: decrypted client bytes forwarded to the web server.
|
||||
let mut rl = {
|
||||
let cancel = cancel.clone();
|
||||
let to_local = Arc::clone(&to_local);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => return "cancelled",
|
||||
r = rx.recv() => match r {
|
||||
Ok(Some(bytes)) => {
|
||||
trace!(plugin = PLUGIN_ID, conn, n = bytes.len(), "http-local-proxy: remote→local");
|
||||
if let Err(e) = wr.write_all(&bytes).await {
|
||||
debug!(plugin = PLUGIN_ID, conn, error = %e, "http-local-proxy: local write failed");
|
||||
return "local write error";
|
||||
}
|
||||
to_local.fetch_add(bytes.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
Ok(None) => return "remote closed",
|
||||
Err(e) => {
|
||||
debug!(plugin = PLUGIN_ID, conn, error = %e, "http-local-proxy: pipe recv error");
|
||||
return "pipe recv error";
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// local → remote: web-server bytes sealed back over the pipe.
|
||||
let mut lr = {
|
||||
let cancel = cancel.clone();
|
||||
let to_remote = Arc::clone(&to_remote);
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; READ_BUF];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => return "cancelled",
|
||||
r = rd.read(&mut buf) => match r {
|
||||
Ok(0) => return "local EOF",
|
||||
Ok(n) => {
|
||||
trace!(plugin = PLUGIN_ID, conn, n, "http-local-proxy: local→remote");
|
||||
if let Err(e) = tx.send(&buf[..n]).await {
|
||||
debug!(plugin = PLUGIN_ID, conn, error = %e, "http-local-proxy: pipe send failed");
|
||||
return "pipe send error";
|
||||
}
|
||||
to_remote.fetch_add(n as u64, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(plugin = PLUGIN_ID, conn, error = %e, "http-local-proxy: local read error");
|
||||
return "local read error";
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// First direction to finish decides the reason; cancel the survivor and join
|
||||
// **only** it. The handle resolved inside `select!` is already complete and
|
||||
// must not be polled again (`JoinHandle polled after completion`). Joining
|
||||
// the survivor lets both pipe halves drop so the socket closes cleanly.
|
||||
let reason = tokio::select! {
|
||||
r = &mut rl => {
|
||||
cancel.cancel();
|
||||
let _ = lr.await;
|
||||
r.unwrap_or("remote→local task panic")
|
||||
}
|
||||
r = &mut lr => {
|
||||
cancel.cancel();
|
||||
let _ = rl.await;
|
||||
r.unwrap_or("local→remote task panic")
|
||||
}
|
||||
};
|
||||
debug!(plugin = PLUGIN_ID, conn,
|
||||
to_local = to_local.load(Ordering::Relaxed),
|
||||
to_remote = to_remote.load(Ordering::Relaxed),
|
||||
reason, "http-local-proxy: pipe closed");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! The single HTTP route the plugin contributes: the runtime QR-code endpoint
|
||||
//! (plugin.md §5). Mounted by the main `WebFrontend` under
|
||||
//! `/api/plugin/mobile-connector/` behind Skald's normal auth. No QR is ever
|
||||
//! written to disk — the PNG is rendered on demand from the in-memory session.
|
||||
//!
|
||||
//! The router receives the plugin's shared state cell
|
||||
//! (`Arc<Mutex<Option<Arc<RelayState>>>>`) so that every request resolves the
|
||||
//! **current** `RelayState` — the same one the LLM tools use. This avoids the
|
||||
//! classic stale-Arc bug when the plugin is reconfigured (reload stops the old
|
||||
//! runloop + creates a fresh `RelayState`, but the router is only built once).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use skald_relay_client::SessionState;
|
||||
|
||||
use crate::app::RelayApp;
|
||||
|
||||
/// Shared cell type: an `Arc` to a `Mutex` holding the (optional) live app.
|
||||
/// Cloned cheaply and safely shared between the plugin and the router.
|
||||
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QrQuery {
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
/// Build the plugin's router. Takes the shared state cell so each request
|
||||
/// resolves the *current* `RelayState` — not a snapshot from startup.
|
||||
pub fn build(state_cell: StateCell) -> Router {
|
||||
Router::new()
|
||||
.route("/pairingqrcode", get(pairing_qr))
|
||||
.with_state(state_cell)
|
||||
}
|
||||
|
||||
/// `GET /pairingqrcode?code=<random>` → PNG of the QR while active, else a
|
||||
/// placeholder PNG (plugin.md §5 table).
|
||||
async fn pairing_qr(
|
||||
State(cell): State<StateCell>,
|
||||
Query(q): Query<QrQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let Some(code) = q.code else {
|
||||
return png_response(render_placeholder("QR non valido"));
|
||||
};
|
||||
|
||||
// Dynamically resolve the *current* RelayApp (same one tools use).
|
||||
let app = match cell.lock().await.as_ref() {
|
||||
Some(s) => Arc::clone(s),
|
||||
None => return png_response(render_placeholder("Plugin non attivo")),
|
||||
};
|
||||
|
||||
match app.client().lookup_pairing(&code) {
|
||||
Some((qr, SessionState::Active)) => {
|
||||
// Encode the normative QrCodeData JSON into the QR.
|
||||
match serde_json::to_string(&qr) {
|
||||
Ok(json) => match render_qr(&json) {
|
||||
Ok(png) => png_response(png),
|
||||
Err(_) => png_response(render_placeholder("Errore QR")),
|
||||
},
|
||||
Err(_) => png_response(render_placeholder("Errore QR")),
|
||||
}
|
||||
}
|
||||
Some((_, SessionState::Consumed)) => png_response(render_placeholder("QR già usato")),
|
||||
Some((_, SessionState::Superseded)) => png_response(render_placeholder("QR scaduto")),
|
||||
None => png_response(render_placeholder("QR scaduto")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap PNG bytes in a no-cache image response.
|
||||
fn png_response(png: Vec<u8>) -> axum::response::Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/png"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
],
|
||||
png,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Render `payload` as a QR PNG (qrcode + image, all in memory).
|
||||
fn render_qr(payload: &str) -> anyhow::Result<Vec<u8>> {
|
||||
use image::{ImageFormat, Luma};
|
||||
let code = qrcode::QrCode::new(payload.as_bytes())?;
|
||||
let img = code.render::<Luma<u8>>().min_dimensions(512, 512).build();
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, ImageFormat::Png)?;
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Render a simple placeholder PNG carrying `msg` as a small QR (renders text so
|
||||
/// a browser shows *something*; no disk I/O). Falls back to a blank image if the
|
||||
/// text encode fails.
|
||||
fn render_placeholder(msg: &str) -> Vec<u8> {
|
||||
render_qr(msg).unwrap_or_else(|_| blank_png())
|
||||
}
|
||||
|
||||
/// 1×1 white PNG, used only if QR rendering itself fails.
|
||||
fn blank_png() -> Vec<u8> {
|
||||
use image::{ImageBuffer, ImageFormat, Luma};
|
||||
let img: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_pixel(1, 1, Luma([255u8]));
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
let _ = img.write_to(&mut buf, ImageFormat::Png);
|
||||
buf.into_inner()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The three LLM-callable control tools (plugin.md §11).
|
||||
//!
|
||||
//! These implement `core_api::tool::Tool` and close over the plugin's
|
||||
//! `RelayAgent`. The host (main crate) registers them in its `ToolRegistry` via
|
||||
//! [`mobile_tools`]. `mobile_start_pairing` is gated behind the approval engine
|
||||
//! the same way `execute_cmd`/`restart` are: the host seeds a `require` rule for
|
||||
//! the tool name (see docs), so opening a pairing window is always a deliberate
|
||||
//! human action and never triggerable by prompt injection.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use core_api::tool::{Tool, ToolCategory};
|
||||
|
||||
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_REVOKE_DEVICE: &str = "mobile_revoke_device";
|
||||
|
||||
/// Build the plugin's LLM tools, bound to a `RelayAgent`. The host calls this
|
||||
/// and registers the result in its `ToolRegistry`.
|
||||
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(RevokeDeviceTool { agent }),
|
||||
]
|
||||
}
|
||||
|
||||
// ── mobile_start_pairing ──────────────────────────────────────────────────────
|
||||
|
||||
struct StartPairingTool {
|
||||
agent: Arc<dyn RelayAgent>,
|
||||
}
|
||||
|
||||
impl Tool for StartPairingTool {
|
||||
fn name(&self) -> &str { TOOL_START_PAIRING }
|
||||
fn description(&self) -> &str {
|
||||
"Open a pairing window so a new mobile device can be added, returning a URL \
|
||||
that renders a QR code to scan. The window auto-expires. Requires user approval."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ttl": {
|
||||
"type": "integer",
|
||||
"description": "Optional window lifetime in seconds (max 600). Defaults to the configured value."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn category(&self) -> ToolCategory { ToolCategory::Config }
|
||||
fn interactive_only(&self) -> bool { true }
|
||||
|
||||
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 ttl = args.get("ttl").and_then(Value::as_u64).unwrap_or(0) as u32;
|
||||
let handle = self.agent.start_pairing(ttl).await?;
|
||||
Ok(format!(
|
||||
"Pairing window open. Show this QR to the device:\n\n\n\n\
|
||||
The window expires automatically.",
|
||||
handle.url
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── mobile_list_devices ───────────────────────────────────────────────────────
|
||||
|
||||
struct ListDevicesTool {
|
||||
agent: Arc<dyn RelayAgent>,
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({ "type": "object", "properties": {} })
|
||||
}
|
||||
fn category(&self) -> ToolCategory { ToolCategory::Introspection }
|
||||
|
||||
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 clients = self.agent.list_clients().await;
|
||||
if clients.is_empty() {
|
||||
return Ok("No paired devices.".to_string());
|
||||
}
|
||||
let items: Vec<Value> = clients
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let device_info: Option<Value> =
|
||||
c.device_info.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||
json!({
|
||||
"ed25519_pub": hex::encode(c.ed25519_pub),
|
||||
"state": match c.state {
|
||||
ClientState::Authorized => "authorized",
|
||||
ClientState::Pending => "pending",
|
||||
},
|
||||
"platform": c.platform,
|
||||
"device_info": device_info,
|
||||
"last_seen": c.last_seen,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_string_pretty(&json!({ "devices": items }))?)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── mobile_revoke_device ──────────────────────────────────────────────────────
|
||||
|
||||
struct RevokeDeviceTool {
|
||||
agent: Arc<dyn RelayAgent>,
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pubkey": {
|
||||
"type": "string",
|
||||
"description": "The device's ed25519 public key, hex-encoded (64 chars)."
|
||||
}
|
||||
},
|
||||
"required": ["pubkey"]
|
||||
})
|
||||
}
|
||||
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_revoke_device: missing `pubkey`"))?;
|
||||
let ed = skald_relay_common::crypto::decode_hex::<32>(pubkey)
|
||||
.ok_or_else(|| anyhow::anyhow!("mobile_revoke_device: `pubkey` is not 32-byte hex"))?;
|
||||
self.agent.revoke_client(ed).await?;
|
||||
Ok(format!("Device {pubkey} revoked."))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user