# 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](#crate-split-skald-relay-client). - Shared crypto + protobuf: `crates/skald-relay-common` (byte-for-byte interop with the reference vectors in [relay/test-vectors.md](../relay/test-vectors.md)) - **Protocol documentation** (canonical, in English): - [relay/index.md](../relay/index.md) — architecture, actors, threat model - [relay/relay-protocol.md](../relay/relay-protocol.md) — protobuf schema, auth, pairing, live channel, presence - [relay/framing.md](../relay/framing.md) — E2E plaintext framing (version + compression) - [relay/payloads.md](../relay/payloads.md) — JSON payload schemas (inbox_request, inbox_update, …) - [relay/crypto.md](../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` payloads keyed by device pubkey and emits inbound traffic as `RelayEvent`s; 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](#delayed-push) | | `proxy.rs` | Accepts relay **pipes** of `stream_type = "http-local-proxy"` and reverse-proxies each to `127.0.0.1:`. See [HTTP reverse proxy](#http-reverse-proxy-http-local-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`): ```yaml 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](#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` 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=`. 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](#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_response` → `inbox.approve/reject`, `clarification_response` → `inbox.answer`, `elicitation_response` → `inbox.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](../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:` (`PluginContext::web_port`). Per pipe it `split`s 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](../relay/pipe.md#61-full-duplex--client-side-backpressure)). When either direction ends it cancels a shared token so the other unwinds. Invites of other `stream_type`s 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:` — 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::()`, cast to `Arc`, 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=` — 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.