rsclaw 2026.5.20

AI Agent Engine Compatible with OpenClaw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
//! Permission gate — pre-execution consent flow.
//!
//! Protocol:
//!
//!   1. Before VlmDriver enters its loop, it calls
//!      `PermissionStore::check(...)`.
//!   2. If a persistent grant exists for this (agent_id, app) pair, return
//!      immediately.
//!   3. Else the caller emits a `PermissionRequest` event on the gateway's
//!      broadcast bus — the desktop UI subscribes via WS, surfaces a modal, and
//!      posts back a `PermissionResponse` on a new WS method which resolves a
//!      oneshot registered via `register_pending_request`.
//!   4. The driver awaits the oneshot, calls `record(...)`, and proceeds (or
//!      aborts on `Deny`).
//!
//! Bypass mode: a global `bypass_all` flag in the runtime config
//! short-circuits the check (returns `AllowAlways` immediately). Used
//! by power users / CI runs.
//!
//! Storage: redb table `computer_permissions`, keyed by
//! `{agent_id}\0{app_name}`, value JSON `{decision, granted_at}`.
//! Only `AllowAlways` writes through to redb; `AllowSession`, `Deny`,
//! and `AllowOnce` (for the duration of the call) live in the
//! in-memory session map.

use std::{
    collections::HashMap,
    pin::Pin,
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
};

use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, oneshot};
use tracing::{info, warn};

use crate::store::redb_store::RedbStore;

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantScope {
    /// Just for this single vlm_drive run.
    Once,
    /// All vlm_drive runs in this gateway session.
    Session,
    /// Persisted to redb; survives gateway restarts.
    Always,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRequest {
    pub request_id: String,
    pub agent_id: String,
    /// Target app display name, e.g. "WeChat" / "Doubao". Empty when
    /// the operator is generic-desktop and no app is identified.
    pub app: String,
    /// Plain-language summary shown in the UI modal.
    pub reason: String,
    /// Estimate of action count (`max_loop`) so the user knows the
    /// scope of what they're approving.
    pub estimated_steps: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionDecision {
    AllowOnce,
    AllowSession,
    AllowAlways,
    Deny,
}

/// One persisted "Always allow" entry, surfaced by `list_grants` for the
/// settings UI so the user can review / revoke saved decisions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedGrant {
    pub agent_id: String,
    pub app: String,
    pub decision: PermissionDecision,
    /// Unix timestamp seconds.
    pub granted_at: i64,
}

pub type CheckFut<'a> =
    Pin<Box<dyn Future<Output = Result<Option<PermissionDecision>>> + Send + 'a>>;
pub type RecordFut<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;

pub trait PermissionStore: Send + Sync {
    /// Returns the cached / persisted decision for this (agent, app)
    /// or `None` if the user has never decided. The driver then emits
    /// a request and awaits a response.
    fn check<'a>(&'a self, agent_id: &'a str, app: &'a str) -> CheckFut<'a>;

    /// Record a decision. `Once` is not persisted; `Session` is held
    /// in memory until the gateway restarts; `Always` writes to redb.
    fn record<'a>(
        &'a self,
        agent_id: &'a str,
        app: &'a str,
        decision: PermissionDecision,
    ) -> RecordFut<'a>;

    /// Revoke a persistent grant (UI "Forget this app").
    fn revoke<'a>(&'a self, agent_id: &'a str, app: &'a str) -> RecordFut<'a>;

    /// True when bypass-mode is active. Driver short-circuits when
    /// this is true.
    fn bypass_all(&self) -> bool;
}

// ---------------------------------------------------------------------------
// On-disk record (JSON-encoded value column of `computer_permissions`)
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PersistedGrant {
    decision: PermissionDecision,
    granted_at: i64,
}

// ---------------------------------------------------------------------------
// In-memory cache entry
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Copy)]
struct CachedDecision {
    decision: PermissionDecision,
    #[allow(dead_code)] // surfaced via UI later (audit log)
    scope: GrantScope,
    #[allow(dead_code)] // surfaced via UI later (audit log)
    granted_at: i64,
}

// ---------------------------------------------------------------------------
// RedbPermissionStore — session map + redb-backed persistent grants
// ---------------------------------------------------------------------------

/// Compose the redb key. NUL is used as the separator because neither
/// agent_id nor app names contain it in practice.
fn compose_key(agent_id: &str, app: &str) -> String {
    format!("{agent_id}\0{app}")
}

/// Inverse of `compose_key`. Returns None when the key does not contain
/// the NUL separator (data corruption / stray write).
fn split_key(key: &str) -> Option<(&str, &str)> {
    key.split_once('\0')
}

/// In-memory + redb-backed `PermissionStore` implementation.
///
/// Cloneable via `Arc` because `RedbStore` is held behind one and the
/// session map is `RwLock<HashMap<...>>`.
pub struct RedbPermissionStore {
    /// Session-scoped cache. Cleared on gateway restart.
    sessions: RwLock<HashMap<(String, String), CachedDecision>>,
    /// Persistent backing store (shared with the rest of the gateway).
    redb: Arc<RedbStore>,
    /// Bypass switch — when true, every `check` returns `AllowAlways`.
    /// Initial value comes from `crate::config::schema::ComputerUseConfig`;
    /// `set_bypass_all` flips it at runtime via the settings UI. Override
    /// is runtime-only — it does not persist across gateway restart.
    bypass_all: AtomicBool,
    /// Pending UI requests awaiting user decision. Keyed by
    /// `request_id` (the same value carried in `PermissionRequest`).
    /// The WS handler resolves these by calling
    /// `resolve_pending_request`.
    pending: RwLock<HashMap<String, oneshot::Sender<PermissionDecision>>>,
}

impl RedbPermissionStore {
    /// Build a new store. `bypass_all = true` short-circuits every
    /// permission check (used by `--allow-all` style power-user flags
    /// and CI).
    pub fn new(redb: Arc<RedbStore>, bypass_all: bool) -> Self {
        if bypass_all {
            warn!("computer-use permission gate: bypass_all = true (every action auto-approved)");
        }
        Self {
            sessions: RwLock::new(HashMap::new()),
            redb,
            bypass_all: AtomicBool::new(bypass_all),
            pending: RwLock::new(HashMap::new()),
        }
    }

    /// Inherent reader for the bypass switch. Mirrors the
    /// `PermissionStore::bypass_all` trait method so HTTP handlers
    /// holding an `Arc<RedbPermissionStore>` (concrete) can read it
    /// without bringing the trait into scope.
    pub fn is_bypass_all(&self) -> bool {
        self.bypass_all.load(Ordering::Relaxed)
    }

    /// Flip the bypass switch at runtime. Used by the settings UI
    /// (`PUT /api/v1/computer-use/bypass`). Does not persist — restart
    /// reloads the config-defined value.
    pub fn set_bypass_all(&self, on: bool) {
        let prev = self.bypass_all.swap(on, Ordering::SeqCst);
        if prev != on {
            warn!(
                bypass_all = on,
                "computer-use permission gate: bypass toggled"
            );
        }
    }

    /// List every persisted "Always allow" grant for the settings UI.
    /// Session-scoped grants are excluded — they are ephemeral and not
    /// useful in a manage-saved-permissions panel.
    pub async fn list_grants(&self) -> Result<Vec<SavedGrant>> {
        let raw = self.redb.permission_list_all()?;
        let mut out = Vec::with_capacity(raw.len());
        for (key, value) in raw {
            let Some((agent_id, app)) = split_key(&key) else {
                warn!(key = %key, "skipping malformed permission key");
                continue;
            };
            match serde_json::from_str::<PersistedGrant>(&value) {
                Ok(g) => out.push(SavedGrant {
                    agent_id: agent_id.to_owned(),
                    app: app.to_owned(),
                    decision: g.decision,
                    granted_at: g.granted_at,
                }),
                Err(e) => warn!(error = %e, key = %key, "skipping corrupt permission grant"),
            }
        }
        Ok(out)
    }

    /// Register a oneshot for a pending UI request. Called by the code
    /// that emits the `PermissionRequest` event right before awaiting
    /// the user's decision.
    ///
    /// TODO: wire this from the driver — the driver should:
    ///   1. mint a `request_id`
    ///   2. call `register_pending_request(request_id) -> Receiver`
    ///   3. emit the `PermissionRequest` event on the gateway bus
    ///   4. `.await` the receiver
    ///   5. call `record(...)` with the decision
    pub async fn register_pending_request(
        &self,
        request_id: &str,
    ) -> oneshot::Receiver<PermissionDecision> {
        let (tx, rx) = oneshot::channel();
        let mut pending = self.pending.write().await;
        pending.insert(request_id.to_owned(), tx);
        rx
    }

    /// Resolve a pending UI request with the user's decision. Called
    /// by the WS handler that receives `chat.permission_response` from
    /// the desktop UI.
    ///
    /// Returns true if the request_id was found and resolved, false if
    /// it was unknown (race with timeout / duplicate response).
    ///
    /// TODO: wire this from the WS dispatcher in `src/ws/`.
    pub async fn resolve_pending_request(
        &self,
        request_id: &str,
        decision: PermissionDecision,
    ) -> bool {
        let mut pending = self.pending.write().await;
        match pending.remove(request_id) {
            Some(tx) => tx.send(decision).is_ok(),
            None => false,
        }
    }

    /// Read-through: redb → session cache → return.
    async fn load_persistent(
        &self,
        agent_id: &str,
        app: &str,
    ) -> Result<Option<PermissionDecision>> {
        let key = compose_key(agent_id, app);
        let raw = self.redb.permission_get(&key)?;
        let Some(json) = raw else {
            return Ok(None);
        };
        let grant: PersistedGrant = match serde_json::from_str(&json) {
            Ok(g) => g,
            Err(e) => {
                warn!(error = %e, key = %key, "corrupt permission grant in redb, ignoring");
                return Ok(None);
            }
        };
        // Cache it so subsequent checks skip redb.
        let mut sessions = self.sessions.write().await;
        sessions.insert(
            (agent_id.to_owned(), app.to_owned()),
            CachedDecision {
                decision: grant.decision,
                scope: GrantScope::Always,
                granted_at: grant.granted_at,
            },
        );
        Ok(Some(grant.decision))
    }
}

impl PermissionStore for RedbPermissionStore {
    fn check<'a>(&'a self, agent_id: &'a str, app: &'a str) -> CheckFut<'a> {
        Box::pin(async move {
            if self.bypass_all.load(Ordering::Relaxed) {
                return Ok(Some(PermissionDecision::AllowAlways));
            }

            // 1. Session cache hit? Once-scoped entries are consumed on read so the next
            //    call re-prompts as documented. We take a write lock to support the consume
            //    path; the extra contention is negligible at human-scale latency.
            {
                let mut sessions = self.sessions.write().await;
                let key = (agent_id.to_owned(), app.to_owned());
                if let Some(cached) = sessions.get(&key) {
                    let decision = cached.decision;
                    if cached.scope == GrantScope::Once {
                        sessions.remove(&key);
                    }
                    return Ok(Some(decision));
                }
            }

            // 2. redb fallback (writes through to session cache on hit).
            self.load_persistent(agent_id, app).await
        })
    }

    fn record<'a>(
        &'a self,
        agent_id: &'a str,
        app: &'a str,
        decision: PermissionDecision,
    ) -> RecordFut<'a> {
        Box::pin(async move {
            let now = chrono::Utc::now().timestamp();
            match decision {
                PermissionDecision::AllowOnce => {
                    // Cache as Once-scoped so the driver's polling
                    // permission_gate wakes up on the next `check()`.
                    // The check() implementation removes Once entries
                    // after the first read (consume-once), preserving
                    // the "re-prompt next call" semantics: this run
                    // sees Some(AllowOnce) → proceeds; subsequent
                    // runs find an empty cache → re-emit the prompt.
                    let mut sessions = self.sessions.write().await;
                    sessions.insert(
                        (agent_id.to_owned(), app.to_owned()),
                        CachedDecision {
                            decision,
                            scope: GrantScope::Once,
                            granted_at: now,
                        },
                    );
                    info!(
                        agent_id,
                        app, "permission: allow_once (cached, consume-on-read)"
                    );
                }
                PermissionDecision::AllowSession => {
                    let mut sessions = self.sessions.write().await;
                    sessions.insert(
                        (agent_id.to_owned(), app.to_owned()),
                        CachedDecision {
                            decision,
                            scope: GrantScope::Session,
                            granted_at: now,
                        },
                    );
                    info!(agent_id, app, "permission: allow_session (cached)");
                }
                PermissionDecision::AllowAlways => {
                    let key = compose_key(agent_id, app);
                    let value = serde_json::to_string(&PersistedGrant {
                        decision,
                        granted_at: now,
                    })?;
                    self.redb.permission_put(&key, &value)?;
                    let mut sessions = self.sessions.write().await;
                    sessions.insert(
                        (agent_id.to_owned(), app.to_owned()),
                        CachedDecision {
                            decision,
                            scope: GrantScope::Always,
                            granted_at: now,
                        },
                    );
                    info!(agent_id, app, "permission: allow_always (persisted)");
                }
                PermissionDecision::Deny => {
                    // Cached so we don't keep re-prompting in this
                    // session, but NOT persisted — a fresh gateway
                    // process should re-ask.
                    let mut sessions = self.sessions.write().await;
                    sessions.insert(
                        (agent_id.to_owned(), app.to_owned()),
                        CachedDecision {
                            decision,
                            scope: GrantScope::Session,
                            granted_at: now,
                        },
                    );
                    info!(agent_id, app, "permission: deny (cached for session)");
                }
            }
            Ok(())
        })
    }

    fn revoke<'a>(&'a self, agent_id: &'a str, app: &'a str) -> RecordFut<'a> {
        Box::pin(async move {
            let key = compose_key(agent_id, app);
            self.redb.permission_delete(&key)?;
            let mut sessions = self.sessions.write().await;
            sessions.remove(&(agent_id.to_owned(), app.to_owned()));
            info!(agent_id, app, "permission: revoked");
            Ok(())
        })
    }

    fn bypass_all(&self) -> bool {
        self.bypass_all.load(Ordering::Relaxed)
    }
}

// ---------------------------------------------------------------------------
// UI specification (Tauri / Next.js half — tracked separately, not
// implemented in this file). See `src/computer/permission_ui.md` for
// the full spec consumed by the ui-dev role.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::MemoryTier;

    fn open_store(bypass: bool) -> (RedbPermissionStore, Arc<RedbStore>, tempfile::TempDir) {
        let dir = tempfile::tempdir().expect("tempdir");
        let redb = Arc::new(
            RedbStore::open(&dir.path().join("perm.redb"), MemoryTier::Low).expect("open redb"),
        );
        let store = RedbPermissionStore::new(redb.clone(), bypass);
        (store, redb, dir)
    }

    #[tokio::test]
    async fn fresh_store_returns_none() {
        let (store, _redb, _dir) = open_store(false);
        let got = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(got, None);
    }

    #[tokio::test]
    async fn allow_session_is_cached_in_memory() {
        let (store, _redb, _dir) = open_store(false);
        store
            .record("agent:a", "WeChat", PermissionDecision::AllowSession)
            .await
            .expect("record");
        let got = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(got, Some(PermissionDecision::AllowSession));
    }

    #[tokio::test]
    async fn allow_once_is_consume_on_read() {
        // record(AllowOnce) caches with Once-scope so the driver's
        // polling permission_gate wakes up on the next check(); after
        // that consume-on-read fires and a second check() returns None
        // (preserving the "re-prompt next call" semantics).
        let (store, _redb, _dir) = open_store(false);
        store
            .record("agent:a", "WeChat", PermissionDecision::AllowOnce)
            .await
            .expect("record");
        let first = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(first, Some(PermissionDecision::AllowOnce));
        let second = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(second, None);
    }

    #[tokio::test]
    async fn allow_always_persists_across_store_instances() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("perm.redb");

        // First store — record AllowAlways.
        {
            let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 1"));
            let store = RedbPermissionStore::new(redb, false);
            store
                .record("agent:a", "WeChat", PermissionDecision::AllowAlways)
                .await
                .expect("record");
        }

        // Second store — fresh process, must read it from disk.
        let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 2"));
        let store = RedbPermissionStore::new(redb, false);
        let got = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(got, Some(PermissionDecision::AllowAlways));
    }

    #[tokio::test]
    async fn revoke_clears_session_and_persistent() {
        let (store, _redb, _dir) = open_store(false);
        store
            .record("agent:a", "WeChat", PermissionDecision::AllowAlways)
            .await
            .expect("record");
        store.revoke("agent:a", "WeChat").await.expect("revoke");
        let got = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(got, None);
    }

    #[tokio::test]
    async fn bypass_all_short_circuits() {
        let (store, _redb, _dir) = open_store(true);
        let got = store.check("agent:a", "WeChat").await.expect("check");
        assert_eq!(got, Some(PermissionDecision::AllowAlways));
        assert!(store.bypass_all());
    }

    #[tokio::test]
    async fn deny_is_cached_for_session_but_not_persisted() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("perm.redb");

        {
            let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 1"));
            let store = RedbPermissionStore::new(redb, false);
            store
                .record("agent:a", "WeChat", PermissionDecision::Deny)
                .await
                .expect("record");
            // Same instance: deny is cached.
            assert_eq!(
                store.check("agent:a", "WeChat").await.expect("check 1"),
                Some(PermissionDecision::Deny)
            );
        }

        // Fresh instance: deny was NOT persisted.
        let redb = Arc::new(RedbStore::open(&path, MemoryTier::Low).expect("open 2"));
        let store = RedbPermissionStore::new(redb, false);
        assert_eq!(
            store.check("agent:a", "WeChat").await.expect("check 2"),
            None
        );
    }

    #[tokio::test]
    async fn pending_request_round_trip() {
        let (store, _redb, _dir) = open_store(false);
        let req_id = "req-123";
        let rx = store.register_pending_request(req_id).await;
        let resolved = store
            .resolve_pending_request(req_id, PermissionDecision::AllowOnce)
            .await;
        assert!(resolved);
        let got = rx.await.expect("recv");
        assert_eq!(got, PermissionDecision::AllowOnce);

        // Second resolve is a no-op.
        let again = store
            .resolve_pending_request(req_id, PermissionDecision::Deny)
            .await;
        assert!(!again);
    }
}