1use std::path::Path;
71
72use car_sync::{
73 check_dispatch, frontier_of, system_clock, FsRelay, InMemoryLeaseCoordinator, Intent,
74 IntentStatus, LeaseCoordinator, NetworkLeaseCoordinator, NetworkRelay, Relay, RelayConfig,
75 Scope, Surface, SyncKeyProvider, SyncSession, SyncTransport, Turn, WallClock,
76};
77use serde_json::{json, Value};
78use std::sync::Arc;
79
80use crate::assistant::governance::{
81 ActionState, AssistantCheckpoint, SupervisedActionRecord, ACTION_REGISTRY_KIND,
82 CHECKPOINT_REGISTRY_KIND,
83};
84
85const CONFIG_KIND: &str = "config";
89
90const HOST_ENDPOINT_KIND: &str = "host_endpoint";
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct HostEndpoint {
107 pub device_id: String,
108 pub name: String,
109 pub url: String,
110 pub pubkey: String,
114}
115const KNOWLEDGE_SURFACE: &str = "knowledge";
118
119pub struct SyncSubsystem {
122 device_id: String,
123 session: SyncSession,
124 relay: Box<dyn Relay + Send>,
125 coordinator: Box<dyn LeaseCoordinator + Send>,
126}
127
128impl std::fmt::Debug for SyncSubsystem {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("SyncSubsystem")
131 .field("device_id", &self.device_id)
132 .finish_non_exhaustive()
133 }
134}
135
136impl SyncSubsystem {
137 pub fn open(root: &Path) -> Result<Self, String> {
143 std::fs::create_dir_all(root)
144 .map_err(|e| format!("sync: create {}: {e}", root.display()))?;
145 let device_id = load_or_mint_device_id(root)?;
146 let device_dir = root.join(&device_id);
147 let relay_dir = root.join("relay");
148 let coordinator = Box::new(InMemoryLeaseCoordinator::new(system_clock()));
149 Self::open_with(
150 device_id,
151 &device_dir,
152 &relay_dir,
153 coordinator,
154 system_clock(),
155 )
156 }
157
158 pub fn open_with(
162 device_id: String,
163 device_dir: &Path,
164 relay_dir: &Path,
165 coordinator: Box<dyn LeaseCoordinator + Send>,
166 wall: WallClock,
167 ) -> Result<Self, String> {
168 std::fs::create_dir_all(device_dir)
169 .map_err(|e| format!("sync: create {}: {e}", device_dir.display()))?;
170 let journal_path = device_dir.join("oplog.jsonl");
171 let checkpoint_dir = device_dir.join("checkpoints");
172 let session = SyncSession::open(
173 device_id.clone(),
174 &journal_path,
175 &checkpoint_dir,
176 wall.clone(),
177 )
178 .map_err(|e| format!("sync: open session: {e}"))?;
179 let relay = FsRelay::open(relay_dir, RelayConfig::default(), wall)
180 .map_err(|e| format!("sync: open relay {}: {e}", relay_dir.display()))?;
181 Ok(Self {
182 device_id,
183 session,
184 relay: Box::new(relay),
185 coordinator,
186 })
187 }
188
189 pub fn open_remote(
199 root: &Path,
200 transport: Arc<dyn SyncTransport>,
201 scope: impl Into<String>,
202 key_provider: Arc<dyn SyncKeyProvider>,
203 ) -> Result<Self, String> {
204 std::fs::create_dir_all(root)
205 .map_err(|e| format!("sync: create {}: {e}", root.display()))?;
206 let device_id = load_or_mint_device_id(root)?;
207 let device_dir = root.join(&device_id);
208 std::fs::create_dir_all(&device_dir)
209 .map_err(|e| format!("sync: create {}: {e}", device_dir.display()))?;
210 let scope = scope.into();
211 let journal_path = device_dir.join("oplog.jsonl");
212 let checkpoint_dir = device_dir.join("checkpoints");
213 let session = SyncSession::open(
214 device_id.clone(),
215 &journal_path,
216 &checkpoint_dir,
217 system_clock(),
218 )
219 .map_err(|e| format!("sync: open session: {e}"))?
220 .with_key_provider(key_provider);
221 let relay: Box<dyn Relay + Send> =
222 Box::new(NetworkRelay::new(transport.clone(), scope.clone()));
223 let coordinator: Box<dyn LeaseCoordinator + Send> =
224 Box::new(NetworkLeaseCoordinator::new(transport, scope));
225 Ok(Self {
226 device_id,
227 session,
228 relay,
229 coordinator,
230 })
231 }
232
233 pub fn device_id(&self) -> &str {
234 &self.device_id
235 }
236
237 pub fn status(&mut self) -> Result<Value, String> {
243 let stable = self
244 .relay
245 .stable_frontier()
246 .map_err(|e| format!("sync: stable_frontier: {e}"))?;
247 let roster = self
248 .relay
249 .roster()
250 .map_err(|e| format!("sync: roster: {e}"))?;
251 Ok(json!({
252 "device_id": self.device_id,
253 "state_hash": self.session.state_hash(),
254 "journal_frontier": frontier_of(self.session.ops()),
255 "stable_frontier": stable,
256 "base_checkpoint": self.session.base().map(|c| c.checkpoint_hash.clone()),
257 "roster": roster,
258 }))
259 }
260
261 pub fn append(
263 &mut self,
264 scope: Scope,
265 surface: Surface,
266 payload: Value,
267 ) -> Result<Value, String> {
268 let op = self
269 .session
270 .append(scope, surface, payload)
271 .map_err(|e| format!("sync: append: {e}"))?;
272 Ok(json!({ "op_id": op.op_id, "seq": op.seq, "hlc": op.hlc }))
273 }
274
275 pub fn tee_config(&mut self, domain: &str, value: Value) -> Result<Value, String> {
285 if !car_sync::is_portable(domain) {
286 return Err(format!(
287 "sync: refusing to tee device-local domain '{domain}' — only portable \
288 policy syncs; secrets and OS grants stay on the device"
289 ));
290 }
291 self.append(
292 Scope::Personal,
293 Surface::Registry {
294 kind: CONFIG_KIND.to_string(),
295 },
296 json!({ "id": domain, "value": value }),
297 )
298 }
299
300 pub fn config_get(&self, domain: &str) -> Value {
304 self.session
305 .state()
306 .registries
307 .get(&format!("registry:{CONFIG_KIND}"))
308 .and_then(|reg| reg.get(&format!("id:{domain}")))
309 .and_then(|rec| rec.payload.get("value").cloned())
310 .unwrap_or(Value::Null)
311 }
312
313 pub fn publish_host_endpoint(
322 &mut self,
323 name: &str,
324 url: &str,
325 pubkey: &str,
326 ) -> Result<Value, String> {
327 let id = self.device_id().to_string();
328 self.append(
329 Scope::Personal,
330 Surface::Registry {
331 kind: HOST_ENDPOINT_KIND.to_string(),
332 },
333 json!({ "id": id, "name": name, "url": url, "pubkey": pubkey }),
334 )
335 }
336
337 pub fn host_endpoints(&self) -> Vec<HostEndpoint> {
348 let me = self.device_id().to_string();
349 let state = self.session.state();
352 let Some(reg) = state
353 .registries
354 .get(&format!("registry:{HOST_ENDPOINT_KIND}"))
355 else {
356 return Vec::new();
357 };
358 let mut out: Vec<HostEndpoint> = reg
359 .iter()
360 .filter_map(|(key, rec)| {
361 let device_id = key.strip_prefix("id:")?.to_string();
362 if device_id == me {
363 return None;
364 }
365 let url = rec.payload.get("url")?.as_str()?.trim().to_string();
366 if !(url.starts_with("http://") || url.starts_with("https://")) {
370 return None;
371 }
372 let name = rec
373 .payload
374 .get("name")
375 .and_then(|v| v.as_str())
376 .filter(|n| !n.trim().is_empty())
377 .unwrap_or(&device_id)
378 .to_string();
379 let pubkey = rec
380 .payload
381 .get("pubkey")
382 .and_then(|v| v.as_str())
383 .unwrap_or_default()
384 .to_string();
385 Some(HostEndpoint {
386 device_id,
387 name,
388 url,
389 pubkey,
390 })
391 })
392 .collect();
393 out.sort_by(|a, b| a.name.cmp(&b.name));
394 out
395 }
396
397 pub fn knowledge(&self) -> Vec<Value> {
405 self.session
406 .state()
407 .log_entries(KNOWLEDGE_SURFACE)
408 .into_iter()
409 .map(|rec| rec.payload.clone())
410 .collect()
411 }
412
413 pub fn assistant_checkpoint_put(
417 &mut self,
418 checkpoint: AssistantCheckpoint,
419 ) -> Result<Value, String> {
420 if checkpoint.session_id.trim().is_empty() || checkpoint.id != checkpoint.session_id {
421 return Err("assistant checkpoint id must equal its non-empty session_id".into());
422 }
423 if let Some(current) = self.assistant_checkpoint_get(&checkpoint.session_id)? {
424 if checkpoint.revision <= current.revision {
425 return Err(format!(
426 "assistant checkpoint revision {} is not newer than {}",
427 checkpoint.revision, current.revision
428 ));
429 }
430 }
431 let session_id = checkpoint.session_id.clone();
432 let value = serde_json::to_value(checkpoint)
433 .map_err(|e| format!("sync: serialize assistant checkpoint: {e}"))?;
434 self.append(
435 Scope::Personal,
436 Surface::Registry {
437 kind: CHECKPOINT_REGISTRY_KIND.to_string(),
438 },
439 json!({ "id": session_id, "checkpoint": value }),
440 )
441 }
442
443 pub fn assistant_checkpoint_get(
445 &self,
446 session_id: &str,
447 ) -> Result<Option<AssistantCheckpoint>, String> {
448 let state = self.session.state();
449 let value = state
450 .registries
451 .get(&format!("registry:{CHECKPOINT_REGISTRY_KIND}"))
452 .and_then(|reg| reg.get(&format!("id:{session_id}")))
453 .and_then(|rec| rec.payload.get("checkpoint"));
454 value
455 .map(|v| {
456 serde_json::from_value(v.clone())
457 .map_err(|e| format!("sync: decode assistant checkpoint: {e}"))
458 })
459 .transpose()
460 }
461
462 pub fn assistant_action_put(
466 &mut self,
467 record: SupervisedActionRecord,
468 ) -> Result<Value, String> {
469 if record.id.trim().is_empty()
470 || record.id != record.scope.action_id(&record.session_id, &record.call_id)
471 {
472 return Err("assistant action id does not match its canonical scope digest".into());
473 }
474 match self.assistant_action_get(&record.id)? {
475 None if record.state != ActionState::Proposed => {
476 return Err("a supervised action must begin in proposed state".into());
477 }
478 Some(current) => {
479 let mut expected = current.clone();
480 expected.transition(record.state, record.receipt.clone())?;
481 if expected != record {
482 return Err("supervised action mutation changed immutable scope fields".into());
483 }
484 }
485 None => {}
486 }
487 let id = record.id.clone();
488 let value = serde_json::to_value(record)
489 .map_err(|e| format!("sync: serialize supervised action: {e}"))?;
490 self.append(
491 Scope::Personal,
492 Surface::Registry {
493 kind: ACTION_REGISTRY_KIND.to_string(),
494 },
495 json!({ "id": id, "record": value }),
496 )
497 }
498
499 pub fn assistant_action_get(
500 &self,
501 action_id: &str,
502 ) -> Result<Option<SupervisedActionRecord>, String> {
503 let state = self.session.state();
504 let value = state
505 .registries
506 .get(&format!("registry:{ACTION_REGISTRY_KIND}"))
507 .and_then(|reg| reg.get(&format!("id:{action_id}")))
508 .and_then(|rec| rec.payload.get("record"));
509 value
510 .map(|v| {
511 serde_json::from_value(v.clone())
512 .map_err(|e| format!("sync: decode supervised action: {e}"))
513 })
514 .transpose()
515 }
516
517 pub fn record_turn(
520 &mut self,
521 scope: Scope,
522 conversation_id: &str,
523 role: &str,
524 content: &str,
525 tool_calls: Vec<Value>,
526 tool_use_id: Option<&str>,
527 timestamp: u64,
528 ) -> Result<Value, String> {
529 if conversation_id.trim().is_empty() {
539 return Err("sync.record_turn requires a non-empty `conversation_id`".into());
540 }
541 if content.is_empty() && tool_calls.is_empty() && tool_use_id.is_none() {
542 return Err(
543 "sync.record_turn requires `content` (or tool_calls / tool_use_id for a tool turn)"
544 .into(),
545 );
546 }
547 let payload = match role {
548 "assistant" => Turn::assistant_payload(conversation_id, content, tool_calls, timestamp),
549 "tool" | "tool_result" => Turn::tool_payload(
550 conversation_id,
551 tool_use_id.unwrap_or_default(),
552 content,
553 timestamp,
554 ),
555 _ => Turn::user_payload(conversation_id, content, timestamp),
556 };
557 self.append(scope, Surface::Conversation, payload)
558 }
559
560 pub fn record_intent(&mut self, scope: Scope, intent: &Intent) -> Result<Value, String> {
565 let recorded = self
566 .session
567 .record_intent(scope, intent)
568 .map_err(|e| format!("sync: record_intent: {e}"))?;
569 Ok(match recorded {
570 Some(op) => json!({ "recorded": true, "op_id": op.op_id }),
571 None => {
572 json!({ "recorded": false, "reason": "run already committed (terminal guard)" })
573 }
574 })
575 }
576
577 pub fn pump(&mut self) -> Result<Value, String> {
580 let report = self
581 .session
582 .pump(self.relay.as_mut())
583 .map_err(|e| format!("sync: pump: {e}"))?;
584 Ok(json!({
585 "pushed": report.pushed,
586 "push_deduped": report.push_deduped,
587 "folded": report.folded,
588 "acked": report.acked,
589 "state_hash": self.session.state_hash(),
590 }))
591 }
592
593 pub fn checkpoint(&mut self) -> Result<Value, String> {
596 let published = self
597 .session
598 .publish_checkpoint(self.relay.as_mut())
599 .map_err(|e| format!("sync: publish_checkpoint: {e}"))?;
600 Ok(match published {
601 Some(c) => json!({ "published": true, "checkpoint_hash": c.checkpoint_hash }),
602 None => json!({ "published": false }),
603 })
604 }
605
606 pub fn rebase(&mut self) -> Result<Value, String> {
609 let rebased = self
610 .session
611 .rebase(self.relay.as_mut())
612 .map_err(|e| format!("sync: rebase: {e}"))?;
613 Ok(json!({
614 "rebased": rebased,
615 "base_checkpoint": self.session.base().map(|c| c.checkpoint_hash.clone()),
616 }))
617 }
618
619 pub fn transcript(&self, conversation_id: &str) -> Value {
621 json!(self.session.state().transcript(conversation_id))
622 }
623
624 pub fn resume(&self, conversation_id: &str) -> Result<Value, String> {
627 serde_json::to_value(self.session.state().resume_messages(conversation_id))
628 .map_err(|e| format!("sync: serialize resume messages: {e}"))
629 }
630
631 pub fn fence_check(
635 &mut self,
636 agent_id: &str,
637 run_id: &str,
638 epoch: u64,
639 ) -> Result<Value, String> {
640 let state = self.session.state();
641 let decision = check_dispatch(
642 self.coordinator.as_mut(),
643 &state,
644 agent_id,
645 run_id,
646 &self.device_id,
647 epoch,
648 )
649 .map_err(|e| format!("sync: fence_check: {e}"))?;
650 let may = decision.may_dispatch();
651 Ok(json!({ "decision": decision, "may_dispatch": may }))
652 }
653
654 pub fn lease_acquire(&mut self, agent_id: &str, ttl_ms: u64) -> Result<Value, String> {
659 let lease = self
660 .coordinator
661 .acquire(agent_id, &self.device_id, ttl_ms)
662 .map_err(|e| format!("lease: acquire: {e}"))?;
663 serde_json::to_value(lease).map_err(|e| e.to_string())
664 }
665
666 pub fn lease_renew(
668 &mut self,
669 agent_id: &str,
670 epoch: u64,
671 ttl_ms: u64,
672 ) -> Result<Value, String> {
673 let lease = self
674 .coordinator
675 .renew(agent_id, &self.device_id, epoch, ttl_ms)
676 .map_err(|e| format!("lease: renew: {e}"))?;
677 serde_json::to_value(lease).map_err(|e| e.to_string())
678 }
679
680 pub fn lease_release(&mut self, agent_id: &str, epoch: u64) -> Result<Value, String> {
682 self.coordinator
683 .release(agent_id, &self.device_id, epoch)
684 .map_err(|e| format!("lease: release: {e}"))?;
685 Ok(json!({ "released": true }))
686 }
687
688 pub fn lease_status(&mut self, agent_id: &str) -> Result<Value, String> {
690 let current = self
691 .coordinator
692 .current(agent_id)
693 .map_err(|e| format!("lease: status: {e}"))?;
694 Ok(json!({ "lease": current }))
695 }
696}
697
698fn load_or_mint_device_id(root: &Path) -> Result<String, String> {
700 let path = root.join("device-id");
701 if path.exists() {
702 let id = std::fs::read_to_string(&path)
703 .map_err(|e| format!("sync: read device-id: {e}"))?
704 .trim()
705 .to_string();
706 if !id.is_empty() {
707 return Ok(id);
708 }
709 }
710 let id = format!("device-{}", uuid::Uuid::new_v4());
711 std::fs::write(&path, &id).map_err(|e| format!("sync: write device-id: {e}"))?;
712 Ok(id)
713}
714
715pub fn parse_scope(params: &Value) -> Scope {
718 if let Some(org) = params
719 .get("scope")
720 .and_then(|s| s.get("org"))
721 .or_else(|| params.get("org"))
722 .and_then(Value::as_str)
723 {
724 return Scope::Shared {
725 org: org.to_string(),
726 };
727 }
728 Scope::Personal
729}
730
731pub fn parse_surface(s: &str) -> Result<Surface, String> {
734 Ok(match s {
735 "routing" => Surface::Routing,
736 "declagent" => Surface::Declagent,
737 "conversation" => Surface::Conversation,
738 "knowledge" => Surface::Knowledge,
739 "skill" => Surface::Skill,
740 "trajectory" => Surface::Trajectory,
741 "run" => Surface::Run,
742 "intent" => Surface::Intent,
743 other => {
744 if let Some(kind) = other.strip_prefix("registry:") {
745 Surface::Registry {
746 kind: kind.to_string(),
747 }
748 } else {
749 return Err(format!("unknown sync surface '{other}'"));
750 }
751 }
752 })
753}
754
755pub fn parse_intent_status(s: &str) -> Result<IntentStatus, String> {
757 Ok(match s {
758 "pending" => IntentStatus::Pending,
759 "committed" => IntentStatus::Committed,
760 "failed" => IntentStatus::Failed,
761 other => return Err(format!("unknown intent status '{other}'")),
762 })
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use std::sync::atomic::{AtomicU64, Ordering};
769 use std::sync::Arc;
770
771 fn manual_clock() -> (Arc<AtomicU64>, WallClock) {
772 let t = Arc::new(AtomicU64::new(0));
773 let reader = t.clone();
774 (t, Arc::new(move || reader.load(Ordering::SeqCst)))
775 }
776
777 fn two_devices(relay_dir: &Path, a_dir: &Path, b_dir: &Path) -> (SyncSubsystem, SyncSubsystem) {
781 let coord = InMemoryLeaseCoordinator::new({
782 let (_t, w) = manual_clock();
783 w
784 });
785 let (_ta, wa) = manual_clock();
786 let (_tb, wb) = manual_clock();
787 let a = SyncSubsystem::open_with(
788 "mac-a".into(),
789 a_dir,
790 relay_dir,
791 Box::new(coord.clone()),
792 wa,
793 )
794 .unwrap();
795 let b = SyncSubsystem::open_with("mac-b".into(), b_dir, relay_dir, Box::new(coord), wb)
796 .unwrap();
797 (a, b)
798 }
799
800 #[test]
801 fn record_turn_rejects_empty_conversation_id_and_empty_turns() {
802 let tmp = tempfile::tempdir().unwrap();
803 let relay = tmp.path().join("relay");
804 let (mut a, _b) = two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
805
806 assert!(a
808 .record_turn(Scope::Personal, "", "user", "hi", vec![], None, 1)
809 .unwrap_err()
810 .contains("conversation_id"));
811 assert!(a
812 .record_turn(Scope::Personal, " ", "user", "hi", vec![], None, 1)
813 .is_err());
814
815 assert!(a
817 .record_turn(Scope::Personal, "c1", "user", "", vec![], None, 1)
818 .unwrap_err()
819 .contains("content"));
820
821 a.record_turn(Scope::Personal, "c1", "user", "hello", vec![], None, 1)
823 .unwrap();
824 a.record_turn(Scope::Personal, "c1", "tool", "", vec![], Some("call_1"), 2)
825 .unwrap();
826 }
827
828 #[test]
829 fn two_devices_converge_a_conversation_through_the_shared_relay() {
830 let tmp = tempfile::tempdir().unwrap();
831 let relay = tmp.path().join("relay");
832 let (mut a, mut b) = two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
833
834 a.record_turn(Scope::Personal, "c1", "user", "hello", vec![], None, 1)
836 .unwrap();
837 a.append(
838 Scope::Personal,
839 Surface::Knowledge,
840 json!({"id": "f1", "body": "sky is blue"}),
841 )
842 .unwrap();
843 a.pump().unwrap();
844 b.pump().unwrap();
845 b.record_turn(
846 Scope::Personal,
847 "c1",
848 "assistant",
849 "hi there",
850 vec![],
851 None,
852 2,
853 )
854 .unwrap();
855 b.pump().unwrap();
856 a.pump().unwrap();
857
858 let sa = a.status().unwrap();
860 let sb = b.status().unwrap();
861 assert_eq!(sa["state_hash"], sb["state_hash"], "two devices converge");
862
863 let resume_a = a.resume("c1").unwrap();
866 let resume_b = b.resume("c1").unwrap();
867 assert_eq!(resume_a, resume_b);
868 let msgs = resume_a.as_array().unwrap();
869 assert_eq!(msgs.len(), 2, "user + assistant");
870 assert_eq!(msgs[0]["role"], json!("user"));
871 assert_eq!(msgs[1]["role"], json!("assistant"));
872
873 let transcript = a.transcript("c1");
875 assert_eq!(transcript.as_array().unwrap().len(), 2);
876 }
877
878 #[test]
879 fn two_devices_discover_each_others_a2a_endpoints_through_the_relay() {
880 use car_sync::{DerivedKeyProvider, LoopbackTransport};
886 let tmp = tempfile::tempdir().unwrap();
887 let transport: Arc<dyn SyncTransport> = Arc::new(LoopbackTransport::new().unwrap());
888 let provider: Arc<dyn SyncKeyProvider> =
889 Arc::new(DerivedKeyProvider::new(b"parslee-login-master".to_vec()));
890
891 let mut mac = SyncSubsystem::open_remote(
892 &tmp.path().join("mac"),
893 transport.clone(),
894 "user:matt",
895 provider.clone(),
896 )
897 .unwrap();
898 let mut desktop = SyncSubsystem::open_remote(
899 &tmp.path().join("desktop"),
900 transport.clone(),
901 "user:matt",
902 provider.clone(),
903 )
904 .unwrap();
905
906 assert!(mac.host_endpoints().is_empty());
908 assert!(desktop.host_endpoints().is_empty());
909
910 mac.publish_host_endpoint("mac-studio", "http://192.168.1.10:8731", "MAC-PUBKEY")
911 .unwrap();
912 desktop
913 .publish_host_endpoint("desktop", "http://192.168.1.20:8731", "DESKTOP-PUBKEY")
914 .unwrap();
915 mac.pump().unwrap();
916 desktop.pump().unwrap();
917 mac.pump().unwrap();
918
919 let seen_by_mac = mac.host_endpoints();
920 assert_eq!(seen_by_mac.len(), 1, "mac should see exactly the desktop");
921 assert_eq!(seen_by_mac[0].name, "desktop");
922 assert_eq!(seen_by_mac[0].url, "http://192.168.1.20:8731");
923 assert_eq!(seen_by_mac[0].pubkey, "DESKTOP-PUBKEY");
926
927 let seen_by_desktop = desktop.host_endpoints();
928 assert_eq!(seen_by_desktop.len(), 1);
929 assert_eq!(seen_by_desktop[0].name, "mac-studio");
930 assert_eq!(seen_by_desktop[0].url, "http://192.168.1.10:8731");
931 assert_eq!(seen_by_desktop[0].pubkey, "MAC-PUBKEY");
932
933 desktop
937 .publish_host_endpoint("desktop", "http://10.0.0.5:8731", "DESKTOP-PUBKEY")
938 .unwrap();
939 desktop.pump().unwrap();
940 mac.pump().unwrap();
941 let after_move = mac.host_endpoints();
942 assert_eq!(after_move.len(), 1, "re-announcing must not accumulate");
943 assert_eq!(after_move[0].url, "http://10.0.0.5:8731");
944 }
945
946 #[test]
947 fn a_malformed_or_scheme_less_endpoint_is_not_offered_as_a_peer() {
948 use car_sync::{DerivedKeyProvider, LoopbackTransport};
951 let tmp = tempfile::tempdir().unwrap();
952 let transport: Arc<dyn SyncTransport> = Arc::new(LoopbackTransport::new().unwrap());
953 let provider: Arc<dyn SyncKeyProvider> = Arc::new(DerivedKeyProvider::new(b"k".to_vec()));
954 let mut a = SyncSubsystem::open_remote(
955 &tmp.path().join("a"),
956 transport.clone(),
957 "user:matt",
958 provider.clone(),
959 )
960 .unwrap();
961 let mut b = SyncSubsystem::open_remote(
962 &tmp.path().join("b"),
963 transport.clone(),
964 "user:matt",
965 provider.clone(),
966 )
967 .unwrap();
968 b.publish_host_endpoint("hostile", "file:///etc/passwd", "K")
969 .unwrap();
970 b.pump().unwrap();
971 a.pump().unwrap();
972 assert!(
973 a.host_endpoints().is_empty(),
974 "a non-http(s) endpoint must never be offered as a dialable peer"
975 );
976 }
977
978 #[test]
979 fn two_remote_devices_converge_e2e_through_the_network_relay() {
980 use car_sync::{DerivedKeyProvider, LoopbackTransport};
986 let tmp = tempfile::tempdir().unwrap();
987 let transport: Arc<dyn SyncTransport> = Arc::new(LoopbackTransport::new().unwrap());
988 let provider: Arc<dyn SyncKeyProvider> =
989 Arc::new(DerivedKeyProvider::new(b"parslee-login-master".to_vec()));
990
991 let mut mac = SyncSubsystem::open_remote(
992 &tmp.path().join("mac"),
993 transport.clone(),
994 "user:matt",
995 provider.clone(),
996 )
997 .unwrap();
998 let mut phone = SyncSubsystem::open_remote(
999 &tmp.path().join("phone"),
1000 transport.clone(),
1001 "user:matt",
1002 provider.clone(),
1003 )
1004 .unwrap();
1005
1006 mac.record_turn(
1007 Scope::Personal,
1008 "c1",
1009 "user",
1010 "hello from mac",
1011 vec![],
1012 None,
1013 1,
1014 )
1015 .unwrap();
1016 mac.append(
1017 Scope::Personal,
1018 Surface::Knowledge,
1019 json!({"id": "f1", "body": "sensitive note"}),
1020 )
1021 .unwrap();
1022 mac.pump().unwrap();
1023 phone.pump().unwrap();
1024 phone
1025 .record_turn(
1026 Scope::Personal,
1027 "c1",
1028 "assistant",
1029 "hi from phone",
1030 vec![],
1031 None,
1032 2,
1033 )
1034 .unwrap();
1035 phone.pump().unwrap();
1036 mac.pump().unwrap();
1037
1038 assert_eq!(
1040 mac.status().unwrap()["state_hash"],
1041 phone.status().unwrap()["state_hash"],
1042 "remote-backed devices converge through the network relay"
1043 );
1044
1045 let resume = phone.resume("c1").unwrap();
1048 let msgs = resume.as_array().unwrap();
1049 assert_eq!(msgs.len(), 2, "user + assistant, decrypted on the phone");
1050 assert_eq!(msgs[0]["role"], json!("user"));
1051 assert_eq!(msgs[1]["role"], json!("assistant"));
1052
1053 let intruder: Arc<dyn SyncKeyProvider> =
1056 Arc::new(DerivedKeyProvider::new(b"someone-elses-login".to_vec()));
1057 let mut evil = SyncSubsystem::open_remote(
1058 &tmp.path().join("evil"),
1059 transport.clone(),
1060 "user:matt",
1061 intruder,
1062 )
1063 .unwrap();
1064 evil.pump().unwrap();
1065 assert_ne!(
1066 evil.status().unwrap()["state_hash"],
1067 mac.status().unwrap()["state_hash"],
1068 "a wrong-key reader cannot reconstruct the plaintext state"
1069 );
1070 }
1071
1072 #[test]
1073 fn config_tees_across_devices_and_refuses_device_local_domains() {
1074 let tmp = tempfile::tempdir().unwrap();
1078 let relay = tmp.path().join("relay");
1079 let (mut mac, mut phone) =
1080 two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
1081
1082 mac.tee_config(
1083 "agent_permissions",
1084 json!({"milo": {"full_access": "require_approval"}}),
1085 )
1086 .unwrap();
1087 mac.pump().unwrap();
1088 phone.pump().unwrap();
1089
1090 assert_eq!(
1091 phone.config_get("agent_permissions"),
1092 json!({"milo": {"full_access": "require_approval"}}),
1093 "the phone converges the Mac's config change"
1094 );
1095
1096 phone
1098 .tee_config(
1099 "agent_permissions",
1100 json!({"milo": {"full_access": "deny"}}),
1101 )
1102 .unwrap();
1103 phone.pump().unwrap();
1104 mac.pump().unwrap();
1105 assert_eq!(
1106 mac.config_get("agent_permissions")["milo"]["full_access"],
1107 json!("deny")
1108 );
1109
1110 assert!(mac
1112 .tee_config("keychain_secrets", json!({"slack": "xoxb-…"}))
1113 .is_err());
1114 assert!(mac.tee_config("parslee_tokens", json!("tok")).is_err());
1115 assert_eq!(mac.config_get("keychain_secrets"), json!(null));
1116 }
1117
1118 #[test]
1119 fn dispatch_fence_refuses_stale_epoch_and_already_committed() {
1120 let tmp = tempfile::tempdir().unwrap();
1121 let relay = tmp.path().join("relay");
1122 let (_tc, wc) = manual_clock();
1124 let coord = InMemoryLeaseCoordinator::new(wc);
1125 let (_ta, wa) = manual_clock();
1126 let (_tb, wb) = manual_clock();
1127 let mut a = SyncSubsystem::open_with(
1128 "mac-a".into(),
1129 &tmp.path().join("a"),
1130 &relay,
1131 Box::new(coord.clone()),
1132 wa,
1133 )
1134 .unwrap();
1135 let mut b = SyncSubsystem::open_with(
1136 "mac-b".into(),
1137 &tmp.path().join("b"),
1138 &relay,
1139 Box::new(coord),
1140 wb,
1141 )
1142 .unwrap();
1143
1144 let lease = a.lease_acquire("milo", 100).unwrap();
1146 assert_eq!(lease["epoch"], json!(1));
1147 let f = a.fence_check("milo", "run-1", 1).unwrap();
1148 assert_eq!(f["may_dispatch"], json!(true));
1149
1150 a.record_intent(
1152 Scope::Personal,
1153 &Intent::new("milo", "run-1", 1, IntentStatus::Committed),
1154 )
1155 .unwrap();
1156 a.pump().unwrap();
1157 b.pump().unwrap();
1158
1159 let f = a.fence_check("milo", "run-1", 1).unwrap();
1162 assert_eq!(f["decision"]["decision"], json!("already_committed"));
1163 assert_eq!(f["may_dispatch"], json!(false));
1164 let fb = b.fence_check("milo", "run-1", 1).unwrap();
1165 assert_eq!(fb["decision"]["decision"], json!("already_committed"));
1166
1167 let stale = b.fence_check("milo", "run-2", 1).unwrap();
1170 assert_eq!(stale["decision"]["decision"], json!("stale_epoch"));
1171 assert_eq!(stale["may_dispatch"], json!(false));
1172 }
1173
1174 #[test]
1175 fn lease_is_visible_across_two_devices_sharing_the_register() {
1176 let tmp = tempfile::tempdir().unwrap();
1177 let relay = tmp.path().join("relay");
1178 let (mut a, mut b) = two_devices(&relay, &tmp.path().join("a"), &tmp.path().join("b"));
1179
1180 a.lease_acquire("milo", 1_000_000).unwrap();
1182 let status = b.lease_status("milo").unwrap();
1183 assert_eq!(status["lease"]["holder"], json!("mac-a"));
1184
1185 assert!(b.lease_acquire("milo", 100).is_err());
1187
1188 a.lease_release("milo", 1).unwrap();
1190 let lease = b.lease_acquire("milo", 100).unwrap();
1191 assert_eq!(
1192 (lease["epoch"].as_u64(), lease["holder"].as_str()),
1193 (Some(2), Some("mac-b"))
1194 );
1195 }
1196
1197 #[test]
1198 fn assistant_checkpoint_and_action_survive_process_reopen_exactly() {
1199 use crate::assistant::governance::{
1200 ActionScope, AssistantCheckpoint, CompletionMatrix, CredentialCapability,
1201 ResumeDirective, SupervisedActionRecord,
1202 };
1203 use car_inference::tasks::generate::{
1204 ContentBlock, Message, Provenance, ThinkingBlock, ToolCall,
1205 };
1206
1207 let tmp = tempfile::tempdir().unwrap();
1208 let device = tmp.path().join("device");
1209 let relay = tmp.path().join("relay");
1210 let repo = tmp.path().join("repo");
1211 std::fs::create_dir_all(repo.join(".git")).unwrap();
1212 let (_t, wall) = manual_clock();
1213 let coordinator = InMemoryLeaseCoordinator::new(wall.clone());
1214 let checkpoint = AssistantCheckpoint {
1215 id: "task-1".into(),
1216 session_id: "task-1".into(),
1217 revision: 1,
1218 repository_root: repo.clone(),
1219 messages: vec![
1220 Message::System {
1221 content: "system exact".into(),
1222 },
1223 Message::User {
1224 content: "diagnose it".into(),
1225 },
1226 Message::UserMultimodal {
1227 content: vec![ContentBlock::Text {
1228 text: "screenshot attached".into(),
1229 }],
1230 },
1231 Message::Assistant {
1232 content: "checking".into(),
1233 tool_calls: vec![serde_json::from_value::<ToolCall>(json!({
1234 "id": "call-1",
1235 "name": "read_file",
1236 "arguments": {"path": "src/app.rs"}
1237 }))
1238 .unwrap()],
1239 thinking: vec![ThinkingBlock {
1240 text: "opaque reasoning".into(),
1241 signature: Some("provider-signature".into()),
1242 redacted_data: None,
1243 }],
1244 model_id: None,
1245 local_last_resort: false,
1246 },
1247 Message::ToolResult {
1248 tool_use_id: "call-1".into(),
1249 content: "source".into(),
1250 provenance: Provenance::Internal,
1251 },
1252 Message::ProviderOutputItems {
1253 protocol: "openai-responses".into(),
1254 items: vec![json!({"type": "reasoning", "encrypted_content": "opaque"})],
1255 },
1256 ],
1257 goal: Some(json!({"check": "./verify"})),
1258 compaction: Some(json!({"generation": 2, "supersedes": 1})),
1259 completion: CompletionMatrix::default(),
1260 };
1261 let scope = ActionScope {
1262 tool: "shell".into(),
1263 parameters: json!({"command": "git push origin HEAD:main"}),
1264 repository_root: repo,
1265 target: "origin/main".into(),
1266 environment: "fixture".into(),
1267 credential_capabilities: vec![CredentialCapability("git:origin".into())],
1268 };
1269 let mut action = SupervisedActionRecord::propose("task-1", "call-7", scope);
1270
1271 {
1272 let mut first = SyncSubsystem::open_with(
1273 "mac-a".into(),
1274 &device,
1275 &relay,
1276 Box::new(coordinator.clone()),
1277 wall.clone(),
1278 )
1279 .unwrap();
1280 first.assistant_checkpoint_put(checkpoint.clone()).unwrap();
1281 first.assistant_action_put(action.clone()).unwrap();
1282 action.transition(ActionState::Approved, None).unwrap();
1283 first.assistant_action_put(action.clone()).unwrap();
1284 action.transition(ActionState::Dispatched, None).unwrap();
1285 first.assistant_action_put(action.clone()).unwrap();
1286 }
1287
1288 let reopened =
1289 SyncSubsystem::open_with("mac-a".into(), &device, &relay, Box::new(coordinator), wall)
1290 .unwrap();
1291 assert_eq!(
1292 reopened.assistant_checkpoint_get("task-1").unwrap(),
1293 Some(checkpoint)
1294 );
1295 let recovered = reopened
1296 .assistant_action_get(&action.id)
1297 .unwrap()
1298 .expect("durable action");
1299 assert_eq!(recovered, action);
1300 assert_eq!(
1301 recovered.resume_directive(),
1302 ResumeDirective::MarkIndeterminate,
1303 "a crash after dispatch must not replay the external effect"
1304 );
1305 }
1306}