Skip to main content

mlua_swarm/store/operator_session/
sqlite.rs

1//! `SqliteOperatorSessionStore` — SQLite-backed [`OperatorSessionStore`]
2//! using [`rusqlite-isle`].
3//!
4//! The `Connection` is confined to a dedicated OS thread by `AsyncIsle`;
5//! every call is a typed closure dispatched over a bounded channel.
6//! `capability_manifest` and the 記名's observed log are stored as JSON
7//! blobs — neither is queried relationally; the boot-time `list()`
8//! rehydration decodes them back into their Rust shapes.
9//!
10//! ## The file holds no bearer secret
11//!
12//! `token_digest` is `hex(SHA-256(bearer))`, never the bearer itself (see
13//! [`OperatorSessionRecord`]'s type doc). Two further measures back that up:
14//!
15//! - the file is `chmod 0600` on unix ([`harden_file_permissions`]) —
16//!   best-effort, and skipped entirely on other platforms;
17//! - a pre-release database carrying the old plaintext `token` column is
18//!   **dropped and recreated** on open ([`purge_legacy_plaintext_table`])
19//!   rather than migrated, so no plaintext residue survives the upgrade.
20//!   The cost is one forced re-login for sessions minted by a dev build.
21//!
22//! ## Schema
23//!
24//! ```sql
25//! CREATE TABLE IF NOT EXISTS operator_sessions (
26//!   sid                       TEXT PRIMARY KEY,
27//!   token_digest              TEXT NOT NULL,  -- hex(SHA-256(bearer)), never the bearer
28//!   capability_manifest_json  TEXT,           -- JSON-encoded manifest, NULL when unset
29//!   joined_at_secs            INTEGER NOT NULL,
30//!   join_desc                 TEXT,           -- 記名 confirmed part (D1), NULL when unwritten
31//!   observed_json             TEXT,           -- 記名 observed part (D2), JSON array
32//!   observed_total            INTEGER NOT NULL DEFAULT 0
33//! );
34//! ```
35//!
36//! The last three columns arrived with the 記名 (model §4.2) and are added
37//! to an older file by [`migrate_add_column_if_missing`], the same way the
38//! `runs` table grows a column.
39//!
40//! A column also *left*: `roles_json` held the role aliases a session
41//! claimed at join, back when a join claimed any. Role declaration moved
42//! onto the Run, so an older file has the column dropped on open by
43//! [`migrate_drop_column_if_present`] — see that function for why the
44//! column cannot simply be ignored.
45
46use super::{
47    ObservedAssignment, OperatorSessionRecord, OperatorSessionStore, OperatorSessionStoreError,
48    SessionId,
49};
50use crate::AgentProviderManifest;
51use async_trait::async_trait;
52use rusqlite::params;
53use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
54use std::path::Path;
55
56const SCHEMA_SQL: &str = "\
57CREATE TABLE IF NOT EXISTS operator_sessions (\
58  sid                       TEXT PRIMARY KEY, \
59  token_digest              TEXT NOT NULL, \
60  capability_manifest_json  TEXT, \
61  joined_at_secs            INTEGER NOT NULL, \
62  join_desc                 TEXT, \
63  observed_json             TEXT, \
64  observed_total            INTEGER NOT NULL DEFAULT 0\
65);\
66";
67
68/// Idempotently ensure a column exists on `operator_sessions`, adding it
69/// via `ALTER TABLE … ADD COLUMN` when the file predates it.
70///
71/// Same shape as `crate::store::run::sqlite`'s namesake, and the same
72/// reason: [`SCHEMA_SQL`] only runs `CREATE TABLE IF NOT EXISTS`, so a file
73/// created before a column existed never gains it otherwise. Unlike
74/// [`purge_legacy_plaintext_table`] this migrates rather than drops — these
75/// columns hold no secret and an existing session losing its 記名 would be
76/// a session nobody can identify, which is the opposite of the point.
77fn migrate_add_column_if_missing(
78    conn: &rusqlite::Connection,
79    column: &str,
80    decl: &str,
81) -> rusqlite::Result<()> {
82    let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
83    let has_column = stmt
84        .query_map([], |row| row.get::<_, String>(1))?
85        .collect::<Result<Vec<String>, _>>()?
86        .iter()
87        .any(|name| name == column);
88    if !has_column {
89        conn.execute_batch(&format!(
90            "ALTER TABLE operator_sessions ADD COLUMN {column} {decl};"
91        ))?;
92    }
93    Ok(())
94}
95
96/// Drop `column` from `operator_sessions` when a file predates its removal.
97///
98/// The mirror image of [`migrate_add_column_if_missing`], and needed for a
99/// reason that one does not have: a column this build no longer writes is
100/// not merely untidy when it is `NOT NULL` with no default. `roles_json`
101/// was declared exactly that way, so leaving it in place would make every
102/// `INSERT` from this build fail the constraint on any file created before
103/// the removal — the sessions would stop persisting, and only on upgraded
104/// installs.
105///
106/// Dropping rather than back-filling a placeholder: the value would be a
107/// fiction (this build has no roles to write), and a fiction in a column
108/// nothing reads is the kind of residue the next reader has to work out
109/// the meaning of. `ALTER TABLE … DROP COLUMN` needs SQLite ≥ 3.35, which
110/// the bundled `libsqlite3-sys` is well past.
111fn migrate_drop_column_if_present(
112    conn: &rusqlite::Connection,
113    column: &str,
114) -> rusqlite::Result<()> {
115    let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
116    let has_column = stmt
117        .query_map([], |row| row.get::<_, String>(1))?
118        .collect::<Result<Vec<String>, _>>()?
119        .iter()
120        .any(|name| name == column);
121    if has_column {
122        conn.execute_batch(&format!(
123            "ALTER TABLE operator_sessions DROP COLUMN {column};"
124        ))?;
125    }
126    Ok(())
127}
128
129/// The open-time schema work, shared by the file and in-memory
130/// constructors so the two can never drift apart.
131fn init_schema(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
132    conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
133    purge_legacy_plaintext_table(conn)?;
134    conn.execute_batch(SCHEMA_SQL)?;
135    migrate_add_column_if_missing(conn, "join_desc", "TEXT")?;
136    migrate_add_column_if_missing(conn, "observed_json", "TEXT")?;
137    migrate_add_column_if_missing(conn, "observed_total", "INTEGER NOT NULL DEFAULT 0")?;
138    // Last, and after the adds: a file from before the 記名 columns is
139    // brought up to the current shape first, then loses the one column the
140    // current shape does not have.
141    migrate_drop_column_if_present(conn, "roles_json")
142}
143
144/// Drop a pre-release `operator_sessions` table that still carries the
145/// plaintext `token` column, so the upgrade leaves no bearer on disk.
146///
147/// Deliberately a drop rather than an `ALTER TABLE` migration: digesting
148/// the existing values in place would rewrite the rows but leave the
149/// plaintext recoverable from freed pages, and this column shape only ever
150/// existed in unreleased builds. Losing the rows costs one re-login.
151///
152/// Runs before [`SCHEMA_SQL`], which then recreates the table in the
153/// current shape. A file that never had the legacy column (fresh, or
154/// already upgraded) is untouched.
155fn purge_legacy_plaintext_table(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
156    let mut stmt = conn.prepare("PRAGMA table_info(operator_sessions)")?;
157    let columns = stmt
158        .query_map([], |row| row.get::<_, String>(1))?
159        .collect::<Result<Vec<String>, _>>()?;
160    if columns.iter().any(|name| name == "token") {
161        tracing::warn!(
162            "operator session store: dropping a pre-release table that stored bearer \
163             tokens in plaintext; sessions it held are cleared and must re-login"
164        );
165        conn.execute_batch("DROP TABLE operator_sessions;")?;
166    }
167    Ok(())
168}
169
170/// Restrict the database file to owner-only access (`0600`) on unix.
171///
172/// Best-effort defence in depth: the file already holds digests rather
173/// than bearers, so a failure here is logged and swallowed instead of
174/// failing the open. Without it the mode is whatever the process umask
175/// yields — commonly `0644`, i.e. world-readable on a shared host.
176///
177/// `#[cfg(unix)]`-gated, and a no-op elsewhere: the unconditional use of a
178/// unix-only API is exactly what broke the v0.1.1 Windows build.
179fn harden_file_permissions(path: &Path) {
180    #[cfg(unix)]
181    {
182        use std::os::unix::fs::PermissionsExt;
183        if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
184            tracing::warn!(
185                path = %path.display(),
186                %error,
187                "operator session store: could not restrict file permissions to 0600"
188            );
189        }
190    }
191    #[cfg(not(unix))]
192    {
193        let _ = path;
194    }
195}
196
197/// SQLite-backed persistent [`OperatorSessionStore`].
198///
199/// Open with [`SqliteOperatorSessionStore::open`] (file path) or
200/// [`SqliteOperatorSessionStore::open_in_memory`] (tests). Both return the
201/// store plus an [`AsyncIsleDriver`] the caller must `shutdown().await`
202/// when done — same contract as every sibling sqlite store.
203pub struct SqliteOperatorSessionStore {
204    isle: AsyncIsle,
205}
206
207impl SqliteOperatorSessionStore {
208    /// Open (or create) a SQLite database file and run the schema setup.
209    ///
210    /// Also purges a pre-release plaintext-token table
211    /// ([`purge_legacy_plaintext_table`]) and restricts the file to `0600`
212    /// on unix ([`harden_file_permissions`]).
213    pub async fn open(
214        path: impl AsRef<Path>,
215    ) -> Result<(Self, AsyncIsleDriver), OperatorSessionStoreError> {
216        let path = path.as_ref().to_path_buf();
217        let (isle, driver) = AsyncIsle::spawn(path.clone(), init_schema)
218            .await
219            .map_err(map_isle_err)?;
220        // After the open: SQLite creates the file with umask-derived
221        // permissions, so the tightening has to follow it.
222        harden_file_permissions(&path);
223        Ok((Self { isle }, driver))
224    }
225
226    /// Open an ephemeral in-memory database (tests, doctests).
227    pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), OperatorSessionStoreError> {
228        let (isle, driver) = AsyncIsle::open_in_memory(init_schema)
229            .await
230            .map_err(map_isle_err)?;
231        Ok((Self { isle }, driver))
232    }
233}
234
235fn map_isle_err(e: IsleError) -> OperatorSessionStoreError {
236    OperatorSessionStoreError::Other(format!("sqlite: {e}"))
237}
238
239/// One `operator_sessions` SELECT row in column order: sid, token_digest,
240/// capability_manifest_json, joined_at_secs, join_desc, observed_json,
241/// observed_total.
242type SessionRow = (
243    String,
244    String,
245    Option<String>,
246    i64,
247    Option<String>,
248    Option<String>,
249    i64,
250);
251
252const SESSION_SELECT_COLUMNS: &str = "sid, token_digest, capability_manifest_json, \
253     joined_at_secs, join_desc, observed_json, observed_total";
254
255/// Why one `operator_sessions` row could not be turned into an
256/// [`OperatorSessionRecord`].
257///
258/// Deliberately *not* an [`OperatorSessionStoreError`]: a bad row is not a
259/// store failure, and conflating the two is what let a single row abort
260/// [`OperatorSessionStore::list`] (and with it the boot that calls it).
261/// This type exists so `list` can report the row and carry on.
262struct RowDecodeError {
263    /// The row's `sid` column verbatim — the only handle on a row whose sid
264    /// is itself what failed to decode, so it is kept as the raw string.
265    raw_sid: String,
266    /// Which column's decode failed: `sid`, `capability_manifest`, or
267    /// `observed`.
268    column: &'static str,
269    /// The decoder's own message.
270    detail: String,
271}
272
273fn row_to_record(row: SessionRow) -> Result<OperatorSessionRecord, RowDecodeError> {
274    let (
275        raw_sid,
276        token_digest,
277        capability_manifest_json,
278        joined_at_secs,
279        desc,
280        observed_json,
281        observed_total,
282    ) = row;
283    // All three decodes below fail the same way and get the same treatment:
284    // no column is special-cased, because special-casing one only moves the
285    // boot-stopper to the next.
286    let fail = |column: &'static str, detail: String| RowDecodeError {
287        raw_sid: raw_sid.clone(),
288        column,
289        detail,
290    };
291    let sid = SessionId::parse(raw_sid.clone()).map_err(|e| fail("sid", e.to_string()))?;
292    let capability_manifest: Option<AgentProviderManifest> = match capability_manifest_json {
293        Some(text) => Some(
294            serde_json::from_str(&text).map_err(|e| fail("capability_manifest", e.to_string()))?,
295        ),
296        None => None,
297    };
298    // The observed part decodes under the same regime as the other three:
299    // a log that will not decode drops the session rather than coming back
300    // silently emptied, which would read as "this operator has handled
301    // nothing" — the one thing the 記名 exists to answer.
302    let observed: Vec<ObservedAssignment> = match observed_json {
303        Some(text) => serde_json::from_str(&text).map_err(|e| fail("observed", e.to_string()))?,
304        None => Vec::new(),
305    };
306    Ok(OperatorSessionRecord {
307        sid,
308        token_digest,
309        capability_manifest,
310        joined_at_secs: joined_at_secs as u64,
311        desc,
312        observed,
313        observed_total: observed_total.max(0) as u64,
314    })
315}
316
317#[async_trait]
318impl OperatorSessionStore for SqliteOperatorSessionStore {
319    fn name(&self) -> &str {
320        "sqlite"
321    }
322
323    async fn put(&self, record: OperatorSessionRecord) -> Result<(), OperatorSessionStoreError> {
324        let sid = record.sid.to_string();
325        let token_digest = record.token_digest.clone();
326        let capability_manifest_json = record
327            .capability_manifest
328            .as_ref()
329            .map(serde_json::to_string)
330            .transpose()
331            .map_err(|e| {
332                OperatorSessionStoreError::Other(format!("encode capability_manifest: {e}"))
333            })?;
334        let joined_at_secs = record.joined_at_secs as i64;
335        let desc = record.desc.clone();
336        let observed_json = serde_json::to_string(&record.observed)
337            .map_err(|e| OperatorSessionStoreError::Other(format!("encode observed: {e}")))?;
338        let observed_total = record.observed_total as i64;
339
340        self.isle
341            .call(move |conn| {
342                conn.execute(
343                    "INSERT OR REPLACE INTO operator_sessions \
344                     (sid, token_digest, capability_manifest_json, joined_at_secs, \
345                      join_desc, observed_json, observed_total) \
346                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
347                    params![
348                        sid,
349                        token_digest,
350                        capability_manifest_json,
351                        joined_at_secs,
352                        desc,
353                        observed_json,
354                        observed_total
355                    ],
356                )
357            })
358            .await
359            .map_err(map_isle_err)?;
360        Ok(())
361    }
362
363    async fn delete(&self, sid: &SessionId) -> Result<(), OperatorSessionStoreError> {
364        let sid_str = sid.to_string();
365        let sid_for_notfound = sid.clone();
366        let n = self
367            .isle
368            .call(move |conn| {
369                conn.execute(
370                    "DELETE FROM operator_sessions WHERE sid = ?1",
371                    params![sid_str],
372                )
373            })
374            .await
375            .map_err(map_isle_err)?;
376        if n == 0 {
377            Err(OperatorSessionStoreError::NotFound(sid_for_notfound))
378        } else {
379            Ok(())
380        }
381    }
382
383    async fn list(&self) -> Result<Vec<OperatorSessionRecord>, OperatorSessionStoreError> {
384        let rows = self
385            .isle
386            .call(move |conn| {
387                // `rowid` breaks `joined_at_secs` ties in insertion order,
388                // keeping rehydration deterministic within one second.
389                let mut stmt = conn.prepare(&format!(
390                    "SELECT {SESSION_SELECT_COLUMNS} FROM operator_sessions \
391                     ORDER BY joined_at_secs ASC, rowid ASC"
392                ))?;
393                let iter = stmt.query_map([], |row| {
394                    Ok((
395                        row.get::<_, String>(0)?,
396                        row.get::<_, String>(1)?,
397                        row.get::<_, Option<String>>(2)?,
398                        row.get::<_, i64>(3)?,
399                        row.get::<_, Option<String>>(4)?,
400                        row.get::<_, Option<String>>(5)?,
401                        row.get::<_, i64>(6)?,
402                    ))
403                })?;
404                let mut out = Vec::new();
405                for r in iter {
406                    out.push(r?);
407                }
408                Ok(out)
409            })
410            .await
411            .map_err(map_isle_err)?;
412        // Per row, not all-or-nothing — see `OperatorSessionStore::list`'s
413        // contract. The returned `Err` above is a backend failure; a row
414        // that will not decode is reported and dropped here instead of
415        // being promoted into one.
416        Ok(rows
417            .into_iter()
418            .filter_map(|row| match row_to_record(row) {
419                Ok(record) => Some(record),
420                Err(RowDecodeError {
421                    raw_sid,
422                    column,
423                    detail,
424                }) => {
425                    tracing::warn!(
426                        row_sid = %raw_sid,
427                        column,
428                        detail = %detail,
429                        "operator session store: skipping a row that will not decode; \
430                         this session is gone and its owner must re-login, but the \
431                         remaining sessions are restored"
432                    );
433                    None
434                }
435            })
436            .collect())
437    }
438}
439
440// ──────────────────────────────────────────────────────────────────────────
441// tests
442// ──────────────────────────────────────────────────────────────────────────
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn mk(sid: &str, joined_at_secs: u64) -> OperatorSessionRecord {
449        OperatorSessionRecord {
450            sid: SessionId::parse(sid).unwrap(),
451            token_digest: OperatorSessionRecord::digest_of(&format!("bearer-{sid}")),
452            capability_manifest: None,
453            joined_at_secs,
454            desc: None,
455            observed: Vec::new(),
456            observed_total: 0,
457        }
458    }
459
460    #[tokio::test]
461    async fn put_then_list_orders_by_joined_at() {
462        let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
463        s.put(mk("S-late", 200)).await.unwrap();
464        s.put(mk("S-early", 100)).await.unwrap();
465        let list = s.list().await.unwrap();
466        let sids: Vec<_> = list.iter().map(|r| r.sid.to_string()).collect();
467        assert_eq!(sids, vec!["S-early", "S-late"]);
468        drop(s);
469        driver.shutdown().await.unwrap();
470    }
471
472    #[tokio::test]
473    async fn put_is_upsert() {
474        let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
475        s.put(mk("S-1", 100)).await.unwrap();
476        let mut updated = mk("S-1", 100);
477        updated.token_digest = OperatorSessionRecord::digest_of("rotated");
478        s.put(updated).await.unwrap();
479        let list = s.list().await.unwrap();
480        assert_eq!(list.len(), 1);
481        assert!(list[0].verify_bearer("rotated"));
482        drop(s);
483        driver.shutdown().await.unwrap();
484    }
485
486    #[tokio::test]
487    async fn delete_removes_and_missing_is_not_found() {
488        let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
489        s.put(mk("S-1", 100)).await.unwrap();
490        s.delete(&SessionId::parse("S-1").unwrap()).await.unwrap();
491        assert!(s.list().await.unwrap().is_empty());
492        let err = s
493            .delete(&SessionId::parse("S-1").unwrap())
494            .await
495            .unwrap_err();
496        assert!(matches!(err, OperatorSessionStoreError::NotFound(_)));
497        drop(s);
498        driver.shutdown().await.unwrap();
499    }
500
501    #[tokio::test]
502    async fn manifest_round_trips() {
503        let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
504        let mut rec = mk("S-1", 100);
505        rec.capability_manifest = Some(
506            serde_json::from_value(serde_json::json!({
507                "provider_id": "main-ai-self-report",
508                "capabilities": [{
509                    "launch_variant": "mse-coder",
510                    "resolved_model": "claude-sonnet-4",
511                    "effective_tools": ["Read", "Edit"]
512                }]
513            }))
514            .unwrap(),
515        );
516        s.put(rec.clone()).await.unwrap();
517        let list = s.list().await.unwrap();
518        assert_eq!(list, vec![rec]);
519        drop(s);
520        driver.shutdown().await.unwrap();
521    }
522
523    #[tokio::test]
524    async fn persists_across_reopen() {
525        let dir = tempfile::tempdir().unwrap();
526        let path = dir.path().join("operator_session.db");
527
528        {
529            let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
530            s.put(mk("S-keep", 42)).await.unwrap();
531            drop(s);
532            driver.shutdown().await.unwrap();
533        }
534
535        let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
536        let list = s.list().await.unwrap();
537        assert_eq!(list.len(), 1);
538        assert_eq!(list[0].sid, SessionId::parse("S-keep").unwrap());
539        assert!(
540            list[0].verify_bearer("bearer-S-keep"),
541            "the restored digest must still verify the original bearer"
542        );
543        drop(s);
544        driver.shutdown().await.unwrap();
545    }
546
547    /// The bearer must not be recoverable from the file: what lands in the
548    /// `token_digest` column is the digest, and the plaintext appears
549    /// nowhere in the database bytes.
550    #[tokio::test]
551    async fn file_holds_the_digest_and_never_the_bearer() {
552        let dir = tempfile::tempdir().unwrap();
553        let path = dir.path().join("operator_session.db");
554        let bearer = "bearer-S-keep";
555
556        {
557            let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
558            s.put(mk("S-keep", 42)).await.unwrap();
559            drop(s);
560            driver.shutdown().await.unwrap();
561        }
562
563        let bytes = std::fs::read(&path).expect("read db file");
564        let haystack = String::from_utf8_lossy(&bytes);
565        assert!(
566            !haystack.contains(bearer),
567            "the plaintext bearer must not appear anywhere in the database file"
568        );
569        assert!(
570            haystack.contains(&OperatorSessionRecord::digest_of(bearer)),
571            "the digest is what should be stored"
572        );
573    }
574
575    /// The 記名 survives the encode/decode round trip: the confirmed part
576    /// (**D1**), the observed log (**D2**) and the monotone counter.
577    #[tokio::test]
578    async fn the_kimei_round_trips() {
579        let (s, driver) = SqliteOperatorSessionStore::open_in_memory().await.unwrap();
580        let mut rec = mk("S-1", 100);
581        rec.desc = Some("rewriting the seat resolver in mlua-swarm-server".to_string());
582        rec.record_observed(ObservedAssignment::new(
583            "R-1".to_string(),
584            "phase-a-op".to_string(),
585            Some("resolve issue #10".to_string()),
586            Some("/repo".to_string()),
587            Some("/repo/.worktrees/topic".to_string()),
588            Some(serde_json::json!({"issue": 10})),
589            140,
590        ));
591        s.put(rec.clone()).await.unwrap();
592
593        let list = s.list().await.unwrap();
594        assert_eq!(list, vec![rec]);
595        assert_eq!(list[0].last_activity_secs(), 140);
596        drop(s);
597        driver.shutdown().await.unwrap();
598    }
599
600    /// A file created before the 記名 columns existed gains them on open
601    /// and keeps its rows — the sessions in it stay logged in, with an
602    /// empty 記名 rather than none at all.
603    ///
604    /// It also carries `roles_json`, so the same open exercises the
605    /// removal in the other direction: the pre-記名 shape is the
606    /// pre-role-removal shape too, and one open has to land both.
607    #[tokio::test]
608    async fn a_pre_kimei_file_is_migrated_not_dropped() {
609        let dir = tempfile::tempdir().unwrap();
610        let path = dir.path().join("operator_session.db");
611
612        {
613            let conn = rusqlite::Connection::open(&path).expect("open pre-記名 db");
614            conn.execute_batch(
615                "CREATE TABLE operator_sessions (\
616                   sid                       TEXT PRIMARY KEY, \
617                   token_digest              TEXT NOT NULL, \
618                   roles_json                TEXT NOT NULL, \
619                   capability_manifest_json  TEXT, \
620                   joined_at_secs            INTEGER NOT NULL\
621                 );",
622            )
623            .expect("create the pre-記名 table");
624            conn.execute(
625                "INSERT INTO operator_sessions VALUES (?1, ?2, ?3, NULL, ?4)",
626                params![
627                    "S-old",
628                    OperatorSessionRecord::digest_of("bearer-S-old"),
629                    r#"["main-ai"]"#,
630                    7i64
631                ],
632            )
633            .expect("seed the pre-記名 row");
634        }
635
636        let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
637        let list = s.list().await.unwrap();
638        assert_eq!(list.len(), 1, "the row survives the column addition");
639        assert_eq!(list[0].desc, None);
640        assert!(list[0].observed.is_empty());
641        assert_eq!(list[0].observed_total, 0);
642        assert!(list[0].verify_bearer("bearer-S-old"));
643
644        // And the migrated file is writable in the new shape. This is the
645        // assertion `migrate_drop_column_if_present` exists for: the seeded
646        // table declares `roles_json TEXT NOT NULL` with no default, so a
647        // build that stopped writing the column but left it in place would
648        // fail this `put` on the constraint — persistence silently breaking
649        // on upgraded installs only.
650        let mut updated = list[0].clone();
651        updated.desc = Some("picked this session back up after a restart".to_string());
652        s.put(updated).await.unwrap();
653        let list = s.list().await.unwrap();
654        assert_eq!(
655            list[0].desc.as_deref(),
656            Some("picked this session back up after a restart")
657        );
658        assert!(
659            !column_names(&path).contains(&"roles_json".to_string()),
660            "the role column is dropped, not carried along unwritten"
661        );
662        drop(s);
663        driver.shutdown().await.unwrap();
664    }
665
666    /// The column names `operator_sessions` currently has, straight off the
667    /// file.
668    fn column_names(path: &Path) -> Vec<String> {
669        let conn = rusqlite::Connection::open(path).expect("open db for the column check");
670        let mut stmt = conn
671            .prepare("PRAGMA table_info(operator_sessions)")
672            .expect("prepare table_info");
673        let names = stmt
674            .query_map([], |row| row.get::<_, String>(1))
675            .expect("query table_info")
676            .collect::<Result<Vec<String>, _>>()
677            .expect("collect column names");
678        names
679    }
680
681    /// An observed log that will not decode drops the session rather than
682    /// restoring it as "has handled nothing" — same regime as `roles`.
683    #[tokio::test]
684    async fn undecodable_observed_row_is_skipped_not_fatal() {
685        let dir = tempfile::tempdir().unwrap();
686        let path = dir.path().join("operator_session.db");
687        seed_healthy(&path).await;
688        {
689            let conn = rusqlite::Connection::open(&path).expect("open db for the raw seed");
690            conn.execute(
691                "INSERT OR REPLACE INTO operator_sessions \
692                 (sid, token_digest, capability_manifest_json, joined_at_secs, \
693                  join_desc, observed_json, observed_total) \
694                 VALUES (?1, ?2, NULL, ?3, NULL, ?4, 1)",
695                params![
696                    "S-bad-observed",
697                    OperatorSessionRecord::digest_of("bearer-S-bad-observed"),
698                    2i64,
699                    r#"[{"run_id": 42}]"#
700                ],
701            )
702            .expect("seed the raw row");
703        }
704
705        let (list, logged) = list_capturing_warnings(&path).await;
706        assert_only_healthy_survived(&list);
707        assert!(
708            logged.contains("S-bad-observed") && logged.contains(r#"column="observed""#),
709            "the warn must name the row and the column that failed: {logged}"
710        );
711    }
712
713    /// A pre-release file carrying the plaintext `token` column is dropped
714    /// on open rather than migrated, so no bearer survives the upgrade.
715    #[tokio::test]
716    async fn legacy_plaintext_table_is_dropped_on_open() {
717        let dir = tempfile::tempdir().unwrap();
718        let path = dir.path().join("operator_session.db");
719
720        // Hand-build the pre-release shape and seed one plaintext row.
721        {
722            let conn = rusqlite::Connection::open(&path).expect("open legacy db");
723            conn.execute_batch(
724                "CREATE TABLE operator_sessions (\
725                   sid                       TEXT PRIMARY KEY, \
726                   token                     TEXT NOT NULL, \
727                   roles_json                TEXT NOT NULL, \
728                   capability_manifest_json  TEXT, \
729                   joined_at_secs            INTEGER NOT NULL\
730                 );",
731            )
732            .expect("create legacy table");
733            conn.execute(
734                "INSERT INTO operator_sessions VALUES (?1, ?2, ?3, NULL, ?4)",
735                params!["S-legacy", "plaintext-bearer", r#"["main-ai"]"#, 1i64],
736            )
737            .expect("seed legacy row");
738        }
739
740        let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
741        assert!(
742            s.list().await.unwrap().is_empty(),
743            "the legacy table is dropped, not migrated — its sessions are cleared"
744        );
745        // The store is usable in the new shape straight afterwards.
746        s.put(mk("S-fresh", 10)).await.unwrap();
747        assert_eq!(s.list().await.unwrap().len(), 1);
748        drop(s);
749        driver.shutdown().await.unwrap();
750    }
751
752    // ──────────────────────────────────────────────────────────────────
753    // Per-row fault tolerance
754    //
755    // Every row below is written straight through `rusqlite`, bypassing
756    // `put`'s typed encode. That is not a shortcut: `put` takes an
757    // `OperatorSessionRecord`, whose fields are already `SessionId` /
758    // `AgentProviderManifest`, so it *cannot* produce any of these shapes.
759    // An older build could, and did — `op-<uuid>` sids were persistable
760    // before the `S-<hex>` shape landed.
761    // ──────────────────────────────────────────────────────────────────
762
763    /// A shared buffer a `tracing` subscriber can write into, so a test can
764    /// assert on the warn a skipped row emits.
765    #[derive(Clone, Default)]
766    struct CaptureBuf(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
767
768    impl CaptureBuf {
769        fn contents(&self) -> String {
770            String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
771        }
772    }
773
774    impl std::io::Write for CaptureBuf {
775        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
776            self.0.lock().unwrap().extend_from_slice(buf);
777            Ok(buf.len())
778        }
779        fn flush(&mut self) -> std::io::Result<()> {
780            Ok(())
781        }
782    }
783
784    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureBuf {
785        type Writer = Self;
786        fn make_writer(&'a self) -> Self::Writer {
787            self.clone()
788        }
789    }
790
791    /// Seed one row directly into the table, bypassing the typed encode.
792    fn insert_raw_row(
793        path: &Path,
794        sid: &str,
795        capability_manifest_json: Option<&str>,
796        joined_at_secs: i64,
797    ) {
798        let conn = rusqlite::Connection::open(path).expect("open db for the raw seed");
799        conn.execute(
800            "INSERT OR REPLACE INTO operator_sessions \
801             (sid, token_digest, capability_manifest_json, joined_at_secs) \
802             VALUES (?1, ?2, ?3, ?4)",
803            params![
804                sid,
805                OperatorSessionRecord::digest_of(&format!("bearer-{sid}")),
806                capability_manifest_json,
807                joined_at_secs
808            ],
809        )
810        .expect("seed the raw row");
811    }
812
813    /// Create the store file with one healthy row, so the poisoned row a
814    /// caller adds afterwards has an intact sibling to be measured against.
815    async fn seed_healthy(path: &Path) {
816        let (s, driver) = SqliteOperatorSessionStore::open(path).await.unwrap();
817        s.put(mk("S-healthy", 1)).await.unwrap();
818        drop(s);
819        driver.shutdown().await.unwrap();
820    }
821
822    /// Reopen the store and `list()` it with a warn-capturing subscriber
823    /// installed. Returns the decoded rows and everything logged.
824    ///
825    /// `#[tokio::test]` runs on a current-thread runtime, so the future is
826    /// polled on this thread throughout and the thread-local subscriber
827    /// covers the whole call.
828    async fn list_capturing_warnings(path: &Path) -> (Vec<OperatorSessionRecord>, String) {
829        let buf = CaptureBuf::default();
830        let subscriber = tracing_subscriber::fmt()
831            .with_writer(buf.clone())
832            .with_max_level(tracing::Level::WARN)
833            .with_ansi(false)
834            .finish();
835        let guard = tracing::subscriber::set_default(subscriber);
836
837        let (s, driver) = SqliteOperatorSessionStore::open(path).await.unwrap();
838        let list = s
839            .list()
840            .await
841            .expect("one undecodable row must not fail the whole list");
842        drop(s);
843        driver.shutdown().await.unwrap();
844
845        drop(guard);
846        (list, buf.contents())
847    }
848
849    fn assert_only_healthy_survived(list: &[OperatorSessionRecord]) {
850        let sids: Vec<_> = list.iter().map(|r| r.sid.to_string()).collect();
851        assert_eq!(
852            sids,
853            vec!["S-healthy"],
854            "the intact row must survive and the poisoned one must not be returned"
855        );
856    }
857
858    /// (a) A sid that is not `S-`-shaped. Decoded with the same regime as
859    /// the other one — the sid is not special-cased just because it is the
860    /// key.
861    #[tokio::test]
862    async fn undecodable_sid_row_is_skipped_not_fatal() {
863        let dir = tempfile::tempdir().unwrap();
864        let path = dir.path().join("operator_session.db");
865        seed_healthy(&path).await;
866        insert_raw_row(&path, "op-legacy-uuid", None, 2);
867
868        let (list, logged) = list_capturing_warnings(&path).await;
869        assert_only_healthy_survived(&list);
870        assert!(
871            logged.contains("op-legacy-uuid") && logged.contains(r#"column="sid""#),
872            "the warn must name the row and the column that failed: {logged}"
873        );
874    }
875
876    /// (b) A capability manifest that is not valid JSON for the manifest
877    /// type. Same regime again.
878    #[tokio::test]
879    async fn undecodable_capability_manifest_row_is_skipped_not_fatal() {
880        let dir = tempfile::tempdir().unwrap();
881        let path = dir.path().join("operator_session.db");
882        seed_healthy(&path).await;
883        insert_raw_row(&path, "S-bad-manifest", Some(r#"{"provider_id": 42}"#), 2);
884
885        let (list, logged) = list_capturing_warnings(&path).await;
886        assert_only_healthy_survived(&list);
887        assert!(
888            logged.contains("S-bad-manifest") && logged.contains(r#"column="capability_manifest""#),
889            "the warn must name the row and the column that failed: {logged}"
890        );
891    }
892
893    /// Every undecodable row is dropped, not just the first one, and a file
894    /// where *all* rows are poisoned lists empty rather than erroring.
895    #[tokio::test]
896    async fn several_poisoned_rows_are_all_skipped() {
897        let dir = tempfile::tempdir().unwrap();
898        let path = dir.path().join("operator_session.db");
899        seed_healthy(&path).await;
900        insert_raw_row(&path, "op-legacy-uuid", None, 3);
901        insert_raw_row(&path, "S-bad-manifest", Some(r#"{"provider_id": 42}"#), 4);
902
903        let (list, _logged) = list_capturing_warnings(&path).await;
904        assert_only_healthy_survived(&list);
905    }
906
907    /// On unix the file is owner-only (`0600`) — the umask default would
908    /// otherwise commonly leave it world-readable.
909    #[cfg(unix)]
910    #[tokio::test]
911    async fn file_is_owner_only_on_unix() {
912        use std::os::unix::fs::PermissionsExt;
913
914        let dir = tempfile::tempdir().unwrap();
915        let path = dir.path().join("operator_session.db");
916        let (s, driver) = SqliteOperatorSessionStore::open(&path).await.unwrap();
917        s.put(mk("S-1", 1)).await.unwrap();
918
919        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
920        assert_eq!(mode, 0o600, "expected owner-only, got {mode:o}");
921        drop(s);
922        driver.shutdown().await.unwrap();
923    }
924}