feat(mobile): per-user device bindings, multi-user Inbox routing, and admin-mediated authorization

- Device→user bindings persisted in config table (auth.rs), loaded at plugin start
- RelayApp now routes Inbox responses per-user via UserChannelApi, never globally
- New mobile_bind_device LLM tool for admin-mediated device→user assignment
- Per-user event forwarders (events.rs) with per-user debounced notifiers
- Config listener (auth::config_listener) refreshes bindings cache reactively
- Reconcile loop catches users who unlock after boot
- Hello/Logout treated as device-registry ops (no user resolution needed)
- Unbound device payloads are silently dropped
- RelayAgent::authorize_client → bind_device (atomic bind + authorize)
- Approval rules seed mobile_bind_device/revoke_device as require
This commit is contained in:
2026-07-11 11:18:35 +01:00
parent a847dda88f
commit 2c54778116
15 changed files with 856 additions and 221 deletions
+4
View File
@@ -235,6 +235,10 @@ impl ApprovalManager {
// Opening a mobile pairing window emits a secret (the QR) into chat:
// it must be a deliberate human action, not LLM-triggerable (plugin.md §11).
("mobile_start_pairing", "require"),
// Binding/revoking a device assigns a phone to a user — a security
// decision (who receives whose Inbox), so it must be human-gated too.
("mobile_bind_device", "require"),
("mobile_revoke_device", "require"),
];
// NOTE: file-write tools are NOT seeded here as per-tool `require` rules.
// Filesystem gating is owned by the "File System" category — path-scoped
@@ -32,6 +32,7 @@ use tokio_util::sync::CancellationToken;
use core_api::approval::ApprovalApi;
use core_api::chat_hub::ChatHubApi;
use core_api::events::GlobalEvent;
use core_api::inbox::InboxApi;
use core_api::system_bus::SystemEventBus;
use core_api::user_channel::UserChannelHandle;
@@ -292,6 +293,12 @@ impl UserChannelHandle for UserContextHandle {
Arc::clone(&self.ctx.approval) as Arc<dyn ApprovalApi>
}
fn inbox(&self) -> Arc<dyn InboxApi> {
// `Inbox` is a cheap facade over the interaction-stack `Arc`s (Clone);
// the clone shares the same pending state as the context's own inbox.
Arc::new(self.ctx.inbox.clone()) as Arc<dyn InboxApi>
}
fn subscribe(&self) -> broadcast::Receiver<GlobalEvent> {
self.ctx.global_tx.subscribe()
}