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 onskald-relay-common(never oncore-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):
- relay/index.md — architecture, actors, threat model
- relay/relay-protocol.md — protobuf schema, auth, pairing, live channel, presence
- relay/framing.md — E2E plaintext framing (version + compression)
- relay/payloads.md — JSON payload schemas (inbox_request, inbox_update, …)
- relay/crypto.md — crypto contract, key derivation, AEAD, anti-replay
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 theaes_key, persisted the device as Pending, and consumed the pairing token. The plugin's event loop decides: ifrequire_device_confirmationit notifies; otherwise it callsclient.authorize(ed)and thenbroadcast_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 theRelayAgent::authorize_clienttool 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 RelayApp → client.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 → 0reuses an AES-GCM nonce under the same key (breaks confidentiality + integrity for that device).recv_counter → 0re-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
- The agent calls
mobile_start_pairing(ttl?)(gated behind approval). - The plugin generates a 32-byte
pairing_token(CSPRNG), sendspairing_start{token, ttl}to the relay, and registers an in-memory session keyed by a separate randomcode(latest-wins: any prior active session is marked Superseded). It returns the URL/api/plugin/mobile-connector/pairingqrcode?code=<code>. - 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 normativeQrCodeDataJSON (never on disk, never in the URL). - The client scans, connects as
role:"pairing", the relay consumes the token and forwardsclient_pairedto the agent. - On
client_paired: derive + cacheaes_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 (anotificationis 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 anInboxSnapshotviainbox.list_pending()and sending a sealedinbox_updateto every Authorized client. Each approval carries a humanisedsummary(fromTool::describe(Short), computed inInbox::list_pending) for the card/notification plus the rawarguments(untruncated) for the detail dialog — so the user sees the fullexecute_cmdcommand, not a truncated label. Each clarification carries itssuggested_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 inelicitation_response.content. - Clients → Inbox: inbound
messageis checked (from∈ Authorized, nonce direction + counter >recv_counter), opened, and dispatched bykind:approval_response→inbox.approve/reject,clarification_response→inbox.answer,elicitation_response→inbox.resolve_elicitation(itscontentmay be a secret — never logged/persisted in clear),hello→ persistdevice_info,inbox_request→ send a targetedinbox_updateback tofromonly (see below),logout→ revoke. After any response the Inbox is re-snapshotted.request_idis mappedstring ↔ i64(non-parsing ids are dropped). Inbox ops are idempotent byrequest_id. - Reconnect snapshot (
inbox_request): the relay does not notify the agent when a client reconnects, so the client sendsinbox_requeston the live channel (Message.live=true) after everyauth_ok(e.g. when the app is opened from a push). The agent replies with aninbox_updatesealed 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 getsPeerOfflineimmediately instead of waiting. Side-effect-free and idempotent (byrequest_id). Seedata/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.)
*_requestedarms 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.
- 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
*_resolvedbefore 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.