Files
Skald-Circle/docs/plugins/mobile-connector.md
T
2026-07-10 15:02:09 +01:00

16 KiB

Mobile Connector Plugin (mobile-connector)

Bridges Skald's Inbox (approvals + clarifications + MCP elicitations) to mobile apps over the relay, implementing the agent role of the v2 relay protocol. The plugin is the namespace owner and the sole authority over authorized devices. Skald is never exposed on the internet: only this plugin connects out, and only to the relay.

  • Crate: crates/plugin-mobile-connector — the application layer (thin).
  • Networking: crates/skald-relay-client — the standalone, payload-agnostic relay client (WS v2 transport, E2E crypto, anti-replay counters, pairing, device authorization, SQLite persistence). It depends only on skald-relay-common (never on core-api), so it is reusable and unit/ integration-tested in isolation. See Crate split.
  • Shared crypto + protobuf: crates/skald-relay-common (byte-for-byte interop with the reference vectors in relay/test-vectors.md)
  • Protocol documentation (canonical, in English):

Crate split: skald-relay-client

The plugin is the application layer; all networking lives in the standalone skald-relay-client crate. The boundary is payload-agnostic: the client exchanges opaque decrypted Vec<u8> payloads keyed by device pubkey and emits inbound traffic as RelayEvents; it never interprets the JSON. The plugin consumes client.events(), applies the JSON schemas (payloads.rs) and the InboxApi, and calls client.send(dest, bytes, live).

Consequences of the split:

  • Authorization policy stays in the plugin. On RelayEvent::ClientPaired, the client has already derived the aes_key, persisted the device as Pending, and consumed the pairing token. The plugin's event loop decides: if require_device_confirmation it notifies; otherwise it calls client.authorize(ed) and then broadcast_inbox().
  • client.authorize() is payload-agnostic — it does NOT push an Inbox snapshot. The plugin sends the snapshot after authorizing (both the auto path and the RelayAgent::authorize_client tool path).
  • v2 framing (compress/decompress_payload) is transport — handled inside the client, so the plugin only ever sees clean JSON.
  • Identity seed is injected via SeedSource::Path("data/relay/seed") (same relative path as before) so existing identities/devices survive the upgrade.

Module map — skald-relay-client (networking)

Module Role
config.rs RelayClientConfig + SeedSource (Bytes / Path)
events.rs RelayEvent (Connected/Disconnected/Message/ClientPaired/ClientRevoked), broadcast
identity.rs Seed load/generate (0600, injected path) + derived Ed25519/X25519 keys + namespace_id
db.rs relay_clients table — devices + anti-replay counters (atomic counter helpers, delete_all)
pairing.rs In-memory single-window pairing sessions (code → session) + QrCodeData
state.rs Networking-only runtime: per-client aes_key cache, seal/open, counters, emits events
ws.rs Permanent reconnecting agent WebSocket (v2 binary transport). Challenge → Auth → role dispatch → forward loop
client.rs RelayClient — the public façade (new/start/shutdown/send/pairing/authorize/revoke/clear_all/events)

Module map — plugin-mobile-connector (application)

Module Role
payloads.rs E2E JSON payload schemas (inbox_update, notification, client responses incl. inbox_request). Zlib-compressible per v2 framing.md
app.rs RelayApp: Inbox dispatch (broadcast_inbox/apply_client_payload), authorization policy, the events() consumer loop
notifier.rs DelayedNotifier: debounces phone pushes (notify_delay_secs) so resolving on the computer suppresses the push. See Delayed push
proxy.rs Accepts relay pipes of stream_type = "http-local-proxy" and reverse-proxies each to 127.0.0.1:<web_port>. See HTTP reverse proxy
router.rs The QR-code HTTP endpoint (/pairingqrcode), resolves the current RelayAppclient.lookup_pairing
agent.rs RelayAgent control trait (pairing, list, authorize, revoke)
tools.rs The three LLM tools, registered in the main crate's ToolRegistry
lib.rs MobileConnectorPlugin (Plugin + RelayAgent), lifecycle, bus subscriber, RelayClient/RelayApp wiring

Configuration

Stored in the plugins table (JSON, edited via the plugin UI / configure_plugin):

relay_url: "wss://relay.skaldagent.net/v1/ws"  # empty ⇒ plugin idle (no WS)
pairing_ttl: 300                                # seconds, max 600
require_device_confirmation: true               # manual confirm new devices (recommended)
notify_delay_secs: 20                           # debounce before pushing to the phone (0 = immediate)

enabled (the standard plugin flag) starts/stops the runloop.

notify_delay_secs debounces the phone push for a new approval/clarification (see Delayed push). The mobile push is only useful when you're away from the computer; if you answer at the computer within the window, no phone notification is sent. Set 0 to push immediately.


Persistence (plugin.md §9)

Data Location Why
seed (32 B) filesystem data/relay/seed, 0600 the only persistent secret; keys + namespace_id are derived at runtime
Pairing session in-memory only transient (≤ TTL); lost on restart ⇒ just re-pair
Devices + send/recv_counter DB relay_clients must survive restarts

Why counters live in the DB

Skald self-restarts by design. If counters reset to 0 on restart:

  • send_counter → 0 reuses an AES-GCM nonce under the same key (breaks confidentiality + integrity for that device).
  • recv_counter → 0 re-opens the replay window.

So send_counter is incremented and persisted before sealing/sending (db::next_send_counter, a transaction), and recv_counter is persisted only after a valid open.

aes_key cache

The per-client AES-256-GCM key is HKDF(X25519(seed_x_priv, client_x_pub)). It is derived once and cached in memory (HashMap<ed25519_pub, aes_key> in RelayState), never persisted; on a cache miss it is re-derived from the client's stored x25519_pub. The cache entry is dropped on revoke.


Pairing flow

  1. The agent calls mobile_start_pairing(ttl?) (gated behind approval).
  2. The plugin generates a 32-byte pairing_token (CSPRNG), sends pairing_start{token, ttl} to the relay, and registers an in-memory session keyed by a separate random code (latest-wins: any prior active session is marked Superseded). It returns the URL /api/plugin/mobile-connector/pairingqrcode?code=<code>.
  3. The copilot renders the URL as an image. The endpoint serves a PNG of the QR while the session is Active, else a placeholder (QR scaduto / QR già usato). The QR payload is the normative QrCodeData JSON (never on disk, never in the URL).
  4. The client scans, connects as role:"pairing", the relay consumes the token and forwards client_paired to the agent.
  5. On client_paired: derive + cache aes_key, persist the client as Pending (counters 0), mark the session Consumed, then apply the policy:
    • require_device_confirmation = false ⇒ auto-authorize.
    • require_device_confirmation = true ⇒ leave Pending; the human authorizes via the control surface (a notification is pushed to existing devices).

authorize always reflects the full local set (replacement semantics): adding a device sends the complete list including it; revoking sends it without.


Message flows

  • Inbox → clients: the bus subscriber reacts to the six Inbox events (approval_requested, approval_resolved, clarification_requested, clarification_resolved, elicitation_requested, elicitation_resolved) and routes them through the debouncer (see Delayed push) before building an InboxSnapshot via inbox.list_pending() and sending a sealed inbox_update to every Authorized client. Each approval carries a humanised summary (from Tool::describe(Short), computed in Inbox::list_pending) for the card/notification plus the raw arguments (untruncated) for the detail dialog — so the user sees the full execute_cmd command, not a truncated label. Each clarification carries its suggested_answers. Each elicitation carries only its prompt metadata (server_name, message, field_name, sensitive, is_confirmation) — never a value; the value is supplied by the device in elicitation_response.content.
  • Clients → Inbox: inbound message is checked (from ∈ Authorized, nonce direction + counter > recv_counter), opened, and dispatched by kind: approval_responseinbox.approve/reject, clarification_responseinbox.answer, elicitation_responseinbox.resolve_elicitation (its content may be a secret — never logged/persisted in clear), hello → persist device_info, inbox_request → send a targeted inbox_update back to from only (see below), logout → revoke. After any response the Inbox is re-snapshotted. request_id is mapped string ↔ i64 (non-parsing ids are dropped). Inbox ops are idempotent by request_id.
  • Reconnect snapshot (inbox_request): the relay does not notify the agent when a client reconnects, so the client sends inbox_request on the live channel (Message.live=true) after every auth_ok (e.g. when the app is opened from a push). The agent replies with an inbox_update sealed to the requester only — not a broadcast — so other devices are not needlessly re-aligned. A pull of stale state is useless, so the live channel is correct: if the agent is offline, the client gets PeerOffline immediately instead of waiting. Side-effect-free and idempotent (by request_id). See data/ios-app/v2/relay-protocol.md §3.1.

HTTP reverse proxy (http-local-proxy)

So a remote device can reach Skald's web UI without a VPN/Tailscale or an open port, the plugin reuses the relay pipe (relayed E2E byte-stream, see relay/pipe.md) as a reverse proxy to the local HTTP server.

proxy.rs subscribes to RelayClient::incoming_pipes() and, for each invite with stream_type == "http-local-proxy", accepts the pipe and splices it byte-for-byte to a fresh TcpStream to 127.0.0.1:<web_port> (PluginContext::web_port). Per pipe it splits the connection into independent send/receive halves and runs each direction in its own task (full-duplex, so neither blocks the other; PipeSender::send is backpressured by the pipe's ~10 MiB send buffer, and recv/read are cancel-safe — see relay/pipe.md §6.1). When either direction ends it cancels a shared token so the other unwinds. Invites of other stream_types are ignored (not rejected) since incoming_pipes is a broadcast shared with possible future consumers.

The native app side (later) opens one pipe per outbound connection and points a WebView at it; because the tunnel is a transparent TCP splice, HTTP/1.1 keep-alive, parallel connections, and the chat WebSocket upgrade all work unchanged.

Security.

  • The destination is pinned to 127.0.0.1:<web_port> — the client cannot pick host/port, so this is not an open localhost proxy (no SSRF to other local services).
  • Access is gated by the relay's pipe auth (pipe.md §3.1): only the namespace agent or an authorized client can establish a pipe.
  • It exposes the full local web UI remotely — that is the intent; pair/authorize devices accordingly.

Relay tuning (env, pipe.md §3.3): a browser opens several connections, so RELAY_PIPE_MAX_PER_NS (default 8) may need raising; an idle chat-WS pipe can be reaped at RELAY_PIPE_IDLE_TIMEOUT_SECS (120 s) — the frontend auto-reconnects.

Teardown: proxy_one takes a child of the plugin cancel token, so plugin stop closes active tunnels; stop_inner also shutdown()s the relay client.


Delayed push

A phone push is only valuable when the user is away from the computer. When they're at the chat, every approval/clarification would otherwise fire an instant — and pointless — notification, since they answer on the computer within seconds. DelayedNotifier (notifier.rs) debounces this between the bus events and broadcast_inbox(). (Elicitations are not chat-inline, so they are exempt — see below.)

  • *_requested arms a timer (notify_delay_secs, default 20s) keyed by (kind, request_id) — approvals, clarifications, and elicitations use independent id counters, so the kind is part of the key. If the timer elapses unresolved, the key is marked notified and the Inbox is pushed (broadcast_inbox, live=false → store-and-forward / offline push).
    • Elicitations are the exception: they live only in the Inbox (never inline in the chat, unlike approvals/clarifications), so there is no computer-side answer to debounce against. They skip the timer and are pushed immediately, regardless of notify_delay_secs.
  • *_resolved before the timer fires ⇒ the timer is cancelled and nothing is sent. If the push already went out, the resolution is broadcast so the phone clears the item. (Untracked ids fall back to a broadcast for snapshot freshness.)

This only affects the phone: the desktop/web approval UI runs over the per-session WebSocket (ApprovalRequired/AgentQuestion) and is never delayed. Phone-driven responses (apply_client_payload) still broadcast_inbox() immediately; the subsequent *_resolved bus event is handled idempotently. Armed timers are cancelled on plugin stop (cancel_all). Set notify_delay_secs: 0 for the previous instant-push behaviour.


LLM tools (plugin.md §11)

Tool Effect Approval
mobile_start_pairing(ttl?) Open the pairing window, return the QR URL Gated (a default require rule is seeded, like execute_cmd/restart)
mobile_list_devices() List devices (state, platform, device_info, last_seen) read-only
mobile_revoke_device(pubkey) Revoke a device by hex ed25519 pubkey Config category

These tools are not contributed through the Plugin trait (which has no tools() method). They are registered in Tools::build (src/core/skald/bundles.rs): the plugin is fetched via get_plugin_typed::<MobileConnectorPlugin>(), cast to Arc<dyn RelayAgent>, and bound into the tools via plugin_mobile_connector::mobile_tools(agent)ToolRegistry::register_arc.

mobile_start_pairing's approval gate is the default rule seeded in ApprovalManager::seed_defaults (src/core/approval/mod.rs): opening a window emits a secret (the QR) into chat, so it must be a deliberate human action, not LLM-triggerable via prompt injection.


HTTP endpoint

GET /api/plugin/mobile-connector/pairingqrcode?code=<random> — runtime PNG of the QR (or placeholder), behind Skald's normal auth. Mounted by WebFrontend via Plugin::http_router() (the router closes over the live RelayState). The code is a non-enumerable capability; a URL leaked into chat_history self-revokes once the window closes.