1use 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
68fn 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
96fn 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
129fn 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 migrate_drop_column_if_present(conn, "roles_json")
142}
143
144fn 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
170fn 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
197pub struct SqliteOperatorSessionStore {
204 isle: AsyncIsle,
205}
206
207impl SqliteOperatorSessionStore {
208 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 harden_file_permissions(&path);
223 Ok((Self { isle }, driver))
224 }
225
226 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
239type 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
255struct RowDecodeError {
263 raw_sid: String,
266 column: &'static str,
269 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 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 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 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 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#[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 #[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 #[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 #[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 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 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 #[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 #[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 {
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 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 #[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 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 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 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 #[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 #[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 #[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 #[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}