First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+27
View File
@@ -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<()>;
}
+179
View File
@@ -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,
}
}
}
}
}
+402
View File
@@ -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(&notifier);
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));
}
}
+204
View File
@@ -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()
}
+163
View File
@@ -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![pairing QR]({})\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."))
})
}
}