1use std::io::{self, BufRead};
25use std::path::{Path, PathBuf};
26
27use crate::claude_attach::{perform_attach, AttachError, AttachRequest, ControlTransport};
28
29pub const DETACH_SENTINELS: [&str; 2] = ["\x1b_cc-daemon-detach\x1b\\", "\x1b_cc-detach-msg;"];
33
34pub const PROJECTS_DIR_ENV: &str = "FNO_CLAUDE_PROJECTS_DIR";
37
38#[derive(Debug)]
40pub enum DriveError {
41 UnsafeText,
43 Attach(AttachError),
45 Io(String),
47 NotDelivered,
49}
50
51impl std::fmt::Display for DriveError {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 DriveError::UnsafeText => write!(f, "refused: text contains a detach sentinel"),
55 DriveError::Attach(e) => write!(f, "attach failed: {e}"),
56 DriveError::Io(s) => write!(f, "io error: {s}"),
57 DriveError::NotDelivered => {
58 write!(f, "injected but no confirming assistant turn appeared")
59 }
60 }
61 }
62}
63
64impl std::error::Error for DriveError {}
65
66pub fn claude_projects_dir() -> PathBuf {
68 if let Some(v) = std::env::var_os(PROJECTS_DIR_ENV) {
69 return PathBuf::from(v);
70 }
71 let home = std::env::var_os("HOME")
72 .map(PathBuf::from)
73 .unwrap_or_else(|| PathBuf::from("."));
74 home.join(".claude").join("projects")
75}
76
77fn is_session_uuid(s: &str) -> bool {
79 let parts: Vec<&str> = s.split('-').collect();
80 parts.len() == 5
81 && [8, 4, 4, 4, 12]
82 .iter()
83 .zip(&parts)
84 .all(|(&n, p)| p.len() == n && p.bytes().all(|b| b.is_ascii_hexdigit()))
85}
86
87pub fn find_transcript(session_uuid: &str) -> Option<PathBuf> {
91 if !is_session_uuid(session_uuid) {
92 return None;
93 }
94 let base = claude_projects_dir();
95 let entries = std::fs::read_dir(&base).ok()?;
96 for entry in entries.flatten() {
97 let candidate = entry.path().join(format!("{session_uuid}.jsonl"));
98 if candidate.exists() {
99 return Some(candidate);
100 }
101 }
102 None
103}
104
105pub fn contains_detach_sentinel(text: &str) -> bool {
107 DETACH_SENTINELS.iter().any(|s| text.contains(s))
108}
109
110pub struct FnoMail<'a> {
125 pub from: &'a str,
127 pub harness: &'a str,
129 pub model: &'a str,
131 pub node: Option<&'a str>,
134 pub to: Option<&'a str>,
137 pub id: Option<&'a str>,
143 pub reply_to: Option<&'a str>,
147}
148
149pub fn fno_mail_open(m: &FnoMail) -> String {
152 let mut s = format!(
153 "<fno_mail from=\"{}\" harness=\"{}\" model=\"{}\"",
154 m.from, m.harness, m.model
155 );
156 if let Some(node) = m.node {
157 s.push_str(&format!(" node=\"{node}\""));
158 }
159 if let Some(to) = m.to {
160 s.push_str(&format!(" to=\"{to}\""));
161 }
162 if let Some(id) = m.id {
163 s.push_str(&format!(" id=\"{id}\""));
164 }
165 if let Some(reply_to) = m.reply_to {
166 s.push_str(&format!(" reply_to=\"{reply_to}\""));
167 }
168 s.push('>');
169 s
170}
171
172pub fn wrap_fno_mail(m: &FnoMail, text: &str) -> String {
175 format!("{}\n{}\n</fno_mail>", fno_mail_open(m), text)
176}
177
178pub fn build_reply_request(
188 short: &str,
189 text: &str,
190 auth: Option<&str>,
191 mail: Option<&FnoMail>,
192) -> Result<String, DriveError> {
193 if contains_detach_sentinel(text) {
194 return Err(DriveError::UnsafeText);
195 }
196 let body = match mail {
197 Some(m) => wrap_fno_mail(m, text),
198 None => text.to_string(),
199 };
200 let mut obj = serde_json::Map::new();
201 obj.insert("op".into(), "reply".into());
202 obj.insert("short".into(), short.into());
203 obj.insert("text".into(), body.into());
204 if let Some(a) = auth {
205 obj.insert("auth".into(), a.into());
206 }
207 let mut line = serde_json::Value::Object(obj).to_string();
208 line.push('\n');
209 Ok(line)
210}
211
212pub fn inject_reply<T: ControlTransport>(
216 t: &mut T,
217 short: &str,
218 text: &str,
219 auth: Option<&str>,
220 mail: Option<&FnoMail>,
221) -> Result<(), DriveError> {
222 let line = build_reply_request(short, text, auth, mail)?;
223 t.send_line(&line)
224 .map_err(|e| DriveError::Io(e.to_string()))
225}
226
227pub fn transcript_len(path: &Path) -> u64 {
230 std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
231}
232
233pub fn confirm_marker_after(path: &Path, marker: &str, since_byte: u64) -> io::Result<bool> {
239 let mut file = std::fs::File::open(path)?;
240 use std::io::Seek;
241 file.seek(io::SeekFrom::Start(since_byte))?;
242 let reader = io::BufReader::new(file);
243 for line in reader.lines() {
244 let line = line?;
245 if line.contains(marker) && line_is_assistant(&line) {
246 return Ok(true);
247 }
248 }
249 Ok(false)
250}
251
252fn line_is_assistant(line: &str) -> bool {
256 let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
257 return false;
258 };
259 if v.get("type").and_then(serde_json::Value::as_str) == Some("assistant") {
260 return true;
261 }
262 v.get("message")
263 .and_then(|m| m.get("role"))
264 .and_then(serde_json::Value::as_str)
265 == Some("assistant")
266}
267
268pub struct DriveTurn<'a> {
271 pub text: &'a str,
272 pub marker: &'a str,
273 pub mail: Option<&'a FnoMail<'a>>,
274}
275
276pub fn drive_and_confirm<T: ControlTransport>(
285 transport: &mut T,
286 attach: &AttachRequest,
287 session_uuid: &str,
288 turn: &DriveTurn,
289 attempts: u32,
290 interval: std::time::Duration,
291) -> Result<(), DriveError> {
292 perform_attach(transport, attach).map_err(DriveError::Attach)?;
293
294 let transcript = find_transcript(session_uuid)
295 .ok_or_else(|| DriveError::Io(format!("no transcript for session {session_uuid}")))?;
296 let baseline = transcript_len(&transcript);
297
298 inject_reply(
299 transport,
300 &attach.short,
301 turn.text,
302 attach.auth.as_deref(),
303 turn.mail,
304 )?;
305
306 for _ in 0..attempts.max(1) {
307 if confirm_marker_after(&transcript, turn.marker, baseline)
308 .map_err(|e| DriveError::Io(e.to_string()))?
309 {
310 return Ok(());
311 }
312 std::thread::sleep(interval);
313 }
314 Err(DriveError::NotDelivered)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::io::Write;
321
322 struct Fake {
323 sent: Vec<String>,
324 }
325 impl ControlTransport for Fake {
326 fn send_line(&mut self, line: &str) -> io::Result<()> {
327 self.sent.push(line.to_string());
328 Ok(())
329 }
330 fn recv_line(&mut self) -> io::Result<Option<String>> {
331 Ok(None)
332 }
333 }
334
335 fn tmpdir(tag: &str) -> PathBuf {
336 let p = std::env::temp_dir().join(format!(
337 "fno-drive-{}-{}-{}",
338 tag,
339 std::process::id(),
340 std::time::SystemTime::now()
341 .duration_since(std::time::UNIX_EPOCH)
342 .unwrap()
343 .as_nanos()
344 ));
345 std::fs::create_dir_all(&p).unwrap();
346 p
347 }
348
349 const UUID: &str = "a1b2c3d4-1111-2222-3333-444455556666";
350
351 #[test]
352 fn is_session_uuid_validates_shape() {
353 assert!(is_session_uuid(UUID));
354 assert!(!is_session_uuid("a1b2c3d4")); assert!(!is_session_uuid("not-a-uuid"));
356 assert!(!is_session_uuid("g1b2c3d4-1111-2222-3333-444455556666")); }
358
359 fn from_orchestrator() -> FnoMail<'static> {
360 FnoMail {
361 from: "7d1f8bdc",
362 harness: "claude-code",
363 model: "opus-4.8",
364 node: Some("x-26df"),
365 to: None,
366 id: None,
367 reply_to: None,
368 }
369 }
370
371 #[test]
372 fn fno_mail_open_is_lowercase_quoted_attrs() {
373 assert_eq!(
376 fno_mail_open(&from_orchestrator()),
377 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" node=\"x-26df\">"
378 );
379 let directed = FnoMail {
381 from: "7d1f8bdc",
382 harness: "claude-code",
383 model: "opus-4.8",
384 node: None,
385 to: Some("claude-ee99ff00"),
386 id: None,
387 reply_to: None,
388 };
389 assert_eq!(
390 fno_mail_open(&directed),
391 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" to=\"claude-ee99ff00\">"
392 );
393 }
394
395 #[test]
396 fn fno_mail_open_renders_reply_to_last_when_present() {
397 let reply = FnoMail {
401 from: "7d1f8bdc",
402 harness: "claude-code",
403 model: "opus-4.8",
404 node: None,
405 to: Some("claude-e5f6a7b8"),
406 id: None,
407 reply_to: Some("msg-0091f3"),
408 };
409 assert_eq!(
410 fno_mail_open(&reply),
411 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" to=\"claude-e5f6a7b8\" reply_to=\"msg-0091f3\">"
412 );
413 }
414
415 #[test]
416 fn fno_mail_open_renders_id_last_but_one_before_reply_to() {
417 let with_id = FnoMail {
421 from: "7d1f8bdc",
422 harness: "claude-code",
423 model: "opus-4.8",
424 node: None,
425 to: Some("claude-e5f6a7b8"),
426 id: Some("msg-abc123"),
427 reply_to: Some("msg-0091f3"),
428 };
429 assert_eq!(
430 fno_mail_open(&with_id),
431 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" to=\"claude-e5f6a7b8\" id=\"msg-abc123\" reply_to=\"msg-0091f3\">"
432 );
433 let fresh = FnoMail {
435 from: "7d1f8bdc",
436 harness: "claude-code",
437 model: "opus-4.8",
438 node: None,
439 to: None,
440 id: Some("msg-abc123"),
441 reply_to: None,
442 };
443 assert_eq!(
444 fno_mail_open(&fresh),
445 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" id=\"msg-abc123\">"
446 );
447 }
448
449 #[test]
450 fn wrap_fno_mail_is_a_paired_envelope() {
451 let wrapped = wrap_fno_mail(&from_orchestrator(), "ship it");
452 assert_eq!(
453 wrapped,
454 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" node=\"x-26df\">\nship it\n</fno_mail>"
455 );
456 }
457
458 #[test]
459 fn build_reply_request_untagged_when_no_mail() {
460 let line = build_reply_request("a1b2c3d4", "hello world", Some("deadbeef"), None).unwrap();
461 assert!(line.ends_with('\n'));
462 let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
463 assert_eq!(v["op"], "reply");
464 assert_eq!(v["short"], "a1b2c3d4");
465 assert_eq!(v["text"], "hello world");
466 assert_eq!(v["auth"], "deadbeef");
467 }
468
469 #[test]
470 fn build_reply_request_wraps_fno_mail() {
471 let from = from_orchestrator();
472 let line = build_reply_request("a1b2c3d4", "ship it MARKER42", None, Some(&from)).unwrap();
473 let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
474 let text = v["text"].as_str().unwrap();
475 assert_eq!(
477 text,
478 "<fno_mail from=\"7d1f8bdc\" harness=\"claude-code\" model=\"opus-4.8\" node=\"x-26df\">\nship it MARKER42\n</fno_mail>"
479 );
480 }
481
482 #[test]
483 fn build_reply_request_omits_auth() {
484 let line = build_reply_request("a1b2c3d4", "hi", None, None).unwrap();
485 let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
486 assert!(v.get("auth").is_none());
487 }
488
489 #[test]
490 fn build_reply_request_refuses_detach_sentinel() {
491 let evil = format!("hi {}", DETACH_SENTINELS[0]);
492 assert!(matches!(
493 build_reply_request("a1b2c3d4", &evil, None, None),
494 Err(DriveError::UnsafeText)
495 ));
496 assert!(matches!(
497 build_reply_request("a1b2c3d4", DETACH_SENTINELS[1], None, None),
498 Err(DriveError::UnsafeText)
499 ));
500 }
501
502 #[test]
503 fn inject_reply_writes_one_line_and_guards_sentinels() {
504 let mut t = Fake { sent: Vec::new() };
505 inject_reply(&mut t, "a1b2c3d4", "ping MARKER42", None, None).unwrap();
506 assert_eq!(t.sent.len(), 1);
507 assert!(t.sent[0].contains("\"op\":\"reply\""));
508 assert!(t.sent[0].contains("MARKER42"));
509
510 let mut t2 = Fake { sent: Vec::new() };
511 assert!(inject_reply(&mut t2, "a1b2c3d4", DETACH_SENTINELS[0], None, None).is_err());
512 assert!(t2.sent.is_empty(), "must not write unsafe text");
513 }
514
515 #[test]
516 fn find_transcript_by_uuid_across_project_dirs() {
517 let base = tmpdir("find");
518 std::env::set_var(PROJECTS_DIR_ENV, &base);
519 let proj = base.join("-Users-x-code-proj");
520 std::fs::create_dir_all(&proj).unwrap();
521 let t = proj.join(format!("{UUID}.jsonl"));
522 std::fs::write(&t, b"{}\n").unwrap();
523
524 assert_eq!(find_transcript(UUID), Some(t));
525 assert_eq!(
526 find_transcript("ffffffff-0000-0000-0000-000000000000"),
527 None
528 );
529 assert_eq!(find_transcript("bad-uuid"), None);
530 std::env::remove_var(PROJECTS_DIR_ENV);
531 std::fs::remove_dir_all(&base).ok();
532 }
533
534 #[test]
535 fn confirm_marker_after_counts_only_assistant_turns() {
536 let dir = tmpdir("confirm");
537 let path = dir.join("t.jsonl");
538 let mut f = std::fs::File::create(&path).unwrap();
540 writeln!(
541 f,
542 r#"{{"type":"user","message":{{"role":"user","content":"older"}}}}"#
543 )
544 .unwrap();
545 let baseline = transcript_len(&path);
546
547 let mut f = std::fs::OpenOptions::new()
550 .append(true)
551 .open(&path)
552 .unwrap();
553 writeln!(
554 f,
555 r#"{{"type":"user","message":{{"role":"user","content":"drive MARKER42"}}}}"#
556 )
557 .unwrap();
558 assert!(!confirm_marker_after(&path, "MARKER42", baseline).unwrap());
561
562 writeln!(
563 f,
564 r#"{{"type":"assistant","message":{{"role":"assistant","content":"done MARKER42"}}}}"#
565 )
566 .unwrap();
567 assert!(confirm_marker_after(&path, "MARKER42", baseline).unwrap());
568
569 let end = transcript_len(&path);
571 assert!(!confirm_marker_after(&path, "MARKER42", end).unwrap());
572 std::fs::remove_dir_all(&dir).ok();
573 }
574
575 #[test]
576 fn confirm_marker_absent_assistant_is_false() {
577 let dir = tmpdir("absent");
578 let path = dir.join("t.jsonl");
579 let mut f = std::fs::File::create(&path).unwrap();
580 writeln!(
581 f,
582 r#"{{"type":"assistant","message":{{"role":"assistant","content":"no token here"}}}}"#
583 )
584 .unwrap();
585 assert!(!confirm_marker_after(&path, "MARKER42", 0).unwrap());
586 std::fs::remove_dir_all(&dir).ok();
587 }
588}