Files
Skald-Circle/crates/plugin-mobile-connector/src/payloads.rs
T
dguiducci ba911ae8cb feat(plugins): plugin pages, per-user config, capabilities gate, mobile/telegram refactors
- Plugin HTTP routes + web pages (plugin-page-host, plugin-catalog, plugin-detail)
- Plugin access grants + per-user config (DB tables + API + frontend forms)
- Capabilities-based guard (caps.rs) replacing role-id checks
- Mobile connector: message routing, payload types, router refactor
- Telegram bot: auth flow, event handling improvements
- Honcho plugin: substantial rework
- Sidebar: plugin pages integration, role-driven visibility
- i18n: new strings for plugins, connectors, capabilities
- Remove unused mascot asset
2026-07-19 20:47:09 +01:00

360 lines
15 KiB
Rust

//! 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). Part of the wire
/// contract; retained for the protocol surface even though the current per-user
/// flow pushes Inbox updates rather than free-form notifications.
#[allow(dead_code)]
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,
})
}
/// Build a `bind_result` payload — the agent's reply to a `bind_request`
/// (self-service device binding). `ok=true` carries the bound `user`; `ok=false`
/// carries an `error` string (invalid/expired session, bind failure). The device
/// uses it to confirm the pairing or to prompt the user to sign in again.
pub fn build_bind_result(ok: bool, user: Option<&str>, error: Option<&str>) -> Value {
serde_json::json!({
"v": 1,
"kind": "bind_result",
"id": new_id(),
"ts": Utc::now().timestamp_millis(),
"ok": ok,
"user": user, // Option<&str> → null when absent
"error": error,
})
}
/// Build a `needs_unlock` payload — sent when a device acts for a user whose
/// database is locked (§9). It tells the app to run the login/unlock handshake
/// (`POST /api/auth/login` through the loopback proxy) rather than treating the
/// dropped request as a hard failure.
pub fn build_needs_unlock() -> Value {
serde_json::json!({
"v": 1,
"kind": "needs_unlock",
"id": new_id(),
"ts": Utc::now().timestamp_millis(),
})
}
// ── 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,
/// `bind_request`: self-service device binding. The device presents the
/// `session_token` it obtained from `POST /api/auth/login`; the agent
/// resolves it to a user and binds this device's pubkey to them (no admin
/// step). The token is a bearer credential — never log it.
BindRequest { session_token: String },
/// `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,
"bind_request" => match v.get("session_token").and_then(Value::as_str) {
Some(tok) if !tok.is_empty() => ClientPayload::BindRequest { session_token: tok.to_string() },
_ => ClientPayload::Unknown,
},
"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));
}
/// `bind_request` carries the session token the device logged in with.
#[test]
fn bind_request_parses_session_token() {
let raw = br#"{
"v": 1, "kind": "bind_request", "id": "abc", "ts": 1750000000000,
"session_token": "tok-123"
}"#;
match parse_client_payload(raw) {
ClientPayload::BindRequest { session_token } => assert_eq!(session_token, "tok-123"),
other => panic!("expected BindRequest, got {other:?}"),
}
}
/// A missing or empty `session_token` is rejected as `Unknown` (never binds).
#[test]
fn bind_request_missing_or_empty_token_is_unknown() {
let missing = br#"{ "v": 1, "kind": "bind_request", "id": "a", "ts": 1 }"#;
let empty = br#"{ "v": 1, "kind": "bind_request", "id": "a", "ts": 1, "session_token": "" }"#;
assert!(matches!(parse_client_payload(missing), ClientPayload::Unknown));
assert!(matches!(parse_client_payload(empty), ClientPayload::Unknown));
}
/// `bind_result` shape: ok carries the user; failure carries the error.
#[test]
fn bind_result_shape() {
let ok = build_bind_result(true, Some("u1"), None);
assert_eq!(ok["kind"], "bind_result");
assert_eq!(ok["ok"], true);
assert_eq!(ok["user"], "u1");
assert!(ok["error"].is_null());
let err = build_bind_result(false, None, Some("invalid or expired session"));
assert_eq!(err["ok"], false);
assert!(err["user"].is_null());
assert_eq!(err["error"], "invalid or expired session");
}
/// `needs_unlock` is a bare envelope the app reacts to by (re)logging in.
#[test]
fn needs_unlock_shape() {
let p = build_needs_unlock();
assert_eq!(p["kind"], "needs_unlock");
assert_eq!(p["v"], 1);
}
}