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