1use anyhow::{bail, Context, Result};
20use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25pub const SIG_NOTE: &str = "ballot-sig: ";
27pub const WITHDRAWN_NOTE: &str = "ballot-withdrawn: ";
29pub const PIN_FILE: &str = ".ljos-seat-keys";
31
32#[must_use]
36pub fn message(issue: &str, voter: &str, choice: &str) -> Vec<u8> {
37 format!(
38 "ljos-ballot/v1\n{}\n{}\n{}\n",
39 issue.trim(),
40 voter.trim(),
41 choice.trim()
42 )
43 .into_bytes()
44}
45
46#[must_use]
48pub fn message_v2(issue: &str, voter: &str, choice: &str, harness: &str, nonce: &str) -> Vec<u8> {
49 format!(
50 "ljos-ballot/v2\n{}\n{}\n{}\n{}\n{}\n",
51 issue.trim(),
52 voter.trim(),
53 choice.trim(),
54 harness.trim(),
55 nonce.trim()
56 )
57 .into_bytes()
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SigNote {
64 pub voter: String,
65 pub choice: String,
66 pub key: String,
67 pub sig: String,
68 pub harness: Option<String>,
69 pub nonce: Option<String>,
70}
71
72impl SigNote {
73 #[must_use]
75 pub fn seat_only(&self) -> bool {
76 self.nonce.as_deref() == Some(SEAT_NONCE)
77 }
78}
79
80#[must_use]
87pub fn parse_note(note: &str) -> Option<SigNote> {
88 let rest = note.trim().strip_prefix(SIG_NOTE)?;
89 let at = spans(rest);
90 let n = at.len();
91 if n < 4 {
92 return None;
93 }
94 let tok = |i: usize| &rest[at[i].0..at[i].1];
95 let key_at = |i: usize| is_hex_len(tok(i), 64) && is_hex_len(tok(i + 1), 128);
96 let (k, bound) = if n >= 6 && key_at(n - 4) {
98 (n - 4, true)
99 } else if key_at(n - 2) {
100 (n - 2, false)
101 } else if n == 6 {
102 (2, true)
103 } else if n == 4 {
104 (2, false)
105 } else {
106 return None;
107 };
108 let choice = rest[at[1].0..at[k - 1].1].trim();
109 Some(SigNote {
110 voter: tok(0).to_string(),
111 choice: choice.to_string(),
112 key: tok(k).to_string(),
113 sig: tok(k + 1).to_string(),
114 harness: bound.then(|| tok(k + 2).to_string()),
115 nonce: bound.then(|| tok(k + 3).to_string()),
116 })
117}
118
119#[must_use]
125pub fn note_session(note: &str) -> Option<(String, String)> {
126 if let Some(sig) = parse_note(note) {
127 return Some((
128 sig.harness.unwrap_or_default(),
129 sig.nonce.unwrap_or_default(),
130 ));
131 }
132 let rest = note.trim().strip_prefix(SIG_NOTE)?;
133 let at = spans(rest);
134 let n = at.len();
135 if n < 6 {
136 return None;
137 }
138 let tok = |i: usize| rest[at[i].0..at[i].1].to_string();
139 Some((tok(n - 2), tok(n - 1)))
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct WithdrawnNote {
145 pub voter: String,
146 pub harness: String,
147 pub nonce: String,
148 pub issue: Option<String>,
149}
150
151#[must_use]
153pub fn withdrawn_line(voter: &str, harness: &str, nonce: &str) -> String {
154 withdrawn_line_on(voter, harness, nonce, None)
155}
156
157#[must_use]
160pub fn withdrawn_line_on(voter: &str, harness: &str, nonce: &str, issue: Option<&str>) -> String {
161 let issue = issue.map(field).filter(|id| id != "-");
162 if let Some(issue) = issue {
163 return format!(
164 "{WITHDRAWN_NOTE}{} {} {} {}",
165 field(voter),
166 field(harness),
167 field(nonce),
168 issue
169 );
170 }
171 format!(
172 "{WITHDRAWN_NOTE}{} {} {}",
173 field(voter),
174 field(harness),
175 field(nonce)
176 )
177}
178
179#[must_use]
182pub fn parse_withdrawn(note: &str) -> Option<WithdrawnNote> {
183 let rest = note.trim().strip_prefix(WITHDRAWN_NOTE)?;
184 let parts: Vec<&str> = rest.split_whitespace().collect();
185 let (voter, harness, nonce, issue) = match parts.as_slice() {
186 [voter, harness, nonce] => (*voter, *harness, *nonce, None),
187 [voter, harness, nonce, issue] => (*voter, *harness, *nonce, Some((*issue).to_string())),
188 _ => return None,
189 };
190 Some(WithdrawnNote {
191 voter: voter.to_string(),
192 harness: harness.to_string(),
193 nonce: nonce.to_string(),
194 issue,
195 })
196}
197
198fn spans(text: &str) -> Vec<(usize, usize)> {
200 let mut out = Vec::new();
201 let mut start = None;
202 for (i, c) in text.char_indices() {
203 if c.is_whitespace() {
204 if let Some(b) = start.take() {
205 out.push((b, i));
206 }
207 } else if start.is_none() {
208 start = Some(i);
209 }
210 }
211 if let Some(b) = start {
212 out.push((b, text.len()));
213 }
214 out
215}
216
217pub(crate) fn is_hex_len(text: &str, len: usize) -> bool {
218 text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
219}
220
221#[must_use]
227pub fn signature_ok(key_hex: &str, sig_hex: &str, message: &[u8]) -> bool {
228 let (Ok(key), Ok(sig)) = (hex::decode(key_hex), hex::decode(sig_hex)) else {
229 return false;
230 };
231 let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key), <[u8; 64]>::try_from(sig)) else {
232 return false;
233 };
234 let Ok(verifying) = VerifyingKey::from_bytes(&key) else {
235 return false;
236 };
237 verifying
238 .verify_strict(message, &Signature::from_bytes(&sig))
239 .is_ok()
240}
241
242#[must_use]
244pub fn weak_key(key_hex: &str) -> bool {
245 let Ok(Ok(key)) = hex::decode(key_hex).map(<[u8; 32]>::try_from) else {
246 return false;
247 };
248 VerifyingKey::from_bytes(&key).is_ok_and(|key| key.is_weak())
249}
250
251#[must_use]
258pub fn key_fingerprint(key_hex: &str) -> String {
259 let key = hex::decode(key_hex)
260 .ok()
261 .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
262 .and_then(|bytes| VerifyingKey::from_bytes(&bytes).ok());
263 match key {
264 Some(key) => hex::encode(key.to_edwards().mul_by_cofactor().compress().to_bytes()),
265 None => key_hex.to_ascii_lowercase(),
266 }
267}
268
269#[must_use]
271pub fn signed_hex(key: &SigningKey, message: &[u8]) -> (String, String) {
272 (
273 hex::encode(key.verifying_key().to_bytes()),
274 hex::encode(key.sign(message).to_bytes()),
275 )
276}
277
278pub fn observe_pin(pins: &mut BTreeMap<String, String>, voter: &str, key: &str) -> Result<()> {
284 let fingerprint = key_fingerprint(key);
285 match pins.get(voter) {
286 Some(pinned) if key_fingerprint(pinned) != fingerprint => bail!(
287 "{voter} is pinned to key {}…, got {}…",
288 key_prefix(pinned),
289 key_prefix(key)
290 ),
291 Some(_) => Ok(()),
292 None => {
293 pins.insert(voter.to_string(), key.to_string());
294 Ok(())
295 }
296 }
297}
298
299fn key_prefix(key: &str) -> &str {
300 &key[..12.min(key.len())]
301}
302
303pub(crate) fn key_from_file(path: &std::path::Path) -> Result<SigningKey> {
304 let raw = std::fs::read(path).with_context(|| format!("seat key {}", path.display()))?;
305 let seed: [u8; 32] = raw
306 .get(..32)
307 .and_then(|s| s.try_into().ok())
308 .ok_or_else(|| anyhow::anyhow!("seat key {} holds fewer than 32 bytes", path.display()))?;
309 Ok(SigningKey::from_bytes(&seed))
310}
311
312#[must_use]
318pub fn seat_key_path(seat: &str) -> Option<PathBuf> {
319 if let Some(p) = std::env::var_os("LJOS_SEAT_KEY").filter(|p| !p.is_empty()) {
320 let path = PathBuf::from(p);
321 if !is_box_host_key(&path) && seat_may_use_override(seat) {
322 return Some(path);
323 }
324 }
325 seat_key_file(seat)
326}
327
328#[must_use]
336pub fn seat_key_file(seat: &str) -> Option<PathBuf> {
337 let base = std::env::var_os("XDG_DATA_HOME")
338 .filter(|p| !p.is_empty())
339 .map(PathBuf::from)
340 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
341 let seats = base.join("ljos").join("seats");
342 let seat = seat.trim();
343 let name = seat_dir_name(seat);
344 let path = seats.join(&name).join("ballot.key");
345 let folded = folded_seat_dir_name(seat);
346 if folded != name && !folded.starts_with('.') && !path.exists() {
347 let old = seats.join(folded).join("ballot.key");
348 if old.is_file() {
349 if let Some(dir) = path.parent() {
350 let _ = std::fs::create_dir_all(dir);
351 }
352 let _ = std::fs::rename(&old, &path);
353 }
354 }
355 Some(path)
356}
357
358fn seat_dir_name(seat: &str) -> String {
359 let mut out = String::with_capacity(seat.len());
360 for (at, b) in seat.bytes().enumerate() {
361 if b.is_ascii_alphanumeric() || b == b'-' || (b == b'.' && at > 0) {
362 out.push(char::from(b));
363 } else {
364 out.push_str(&format!("%{b:02x}"));
365 }
366 }
367 out
368}
369
370fn folded_seat_dir_name(seat: &str) -> String {
371 seat.chars()
372 .map(|c| {
373 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
374 c
375 } else {
376 '_'
377 }
378 })
379 .collect()
380}
381
382#[must_use]
384pub fn is_box_host_key(path: &std::path::Path) -> bool {
385 path.ends_with("deedar/host.key")
386}
387
388fn seat_may_use_override(seat: &str) -> bool {
389 let seat = seat.trim();
390 let own = |key: &str| std::env::var(key).ok().is_some_and(|v| v.trim() == seat);
391 match (
392 std::env::var("LJOS_SEAT")
393 .ok()
394 .filter(|s| !s.trim().is_empty()),
395 std::env::var("VISSUE_AGENT")
396 .ok()
397 .filter(|s| !s.trim().is_empty()),
398 ) {
399 (None, None) => true,
400 _ => own("LJOS_SEAT") || own("VISSUE_AGENT"),
401 }
402}
403
404pub fn load_or_create(path: &std::path::Path) -> Result<SigningKey> {
410 if !path.exists() {
411 use std::io::Read;
412 use std::os::unix::fs::OpenOptionsExt;
413 let mut seed = [0u8; 32];
414 std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
415 if let Some(dir) = path.parent() {
416 std::fs::create_dir_all(dir)?;
417 }
418 let mut f = std::fs::OpenOptions::new()
419 .write(true)
420 .create_new(true)
421 .mode(0o600)
422 .open(path)
423 .with_context(|| format!("seat key {}", path.display()))?;
424 std::io::Write::write_all(&mut f, &seed)?;
425 }
426 key_from_file(path)
427}
428
429pub fn recorded_before_signing(issue: &str, voter: &str, choice: &str) -> Result<bool, String> {
442 let Some(root) = env_root() else {
443 return Ok(false);
444 };
445 recorded_before_signing_in(&root, issue, voter, choice)
446}
447
448pub fn recorded_before_signing_in(
455 root: &Path,
456 issue: &str,
457 voter: &str,
458 choice: &str,
459) -> Result<bool, String> {
460 crate::slot::require_tracker_history(root)?;
461 let log = git_in(
462 root,
463 &[
464 "--no-pager",
465 "log",
466 "--full-history",
467 "-m",
468 "--diff-filter=A",
469 "--format=%H %P",
470 "--",
471 PIN_FILE,
472 ],
473 )
474 .unwrap_or_default();
475 let held = |commit: &str| crate::slot::file_at_in(root, commit, PIN_FILE).is_some();
476 let starts: Vec<Vec<&str>> = log
477 .lines()
478 .filter_map(|line| {
479 let mut parts = line.split_whitespace();
480 let commit = parts.next()?;
481 let parents: Vec<&str> = parts.collect();
482 (held(commit) && !parents.iter().any(|p| held(p))).then_some(parents)
483 })
484 .collect();
485 let needle = format!("] {}: {}", voter.trim(), choice.trim());
486 Ok(!starts.is_empty()
487 && starts.iter().all(|parents| {
488 !parents.is_empty()
489 && parents
490 .iter()
491 .all(|p| crate::slot::note_on_issue_at_in(root, p, issue, &needle))
492 }))
493}
494
495pub(crate) fn env_root() -> Option<PathBuf> {
496 std::env::var_os("VISSUE_ROOT")
497 .filter(|r| !r.is_empty())
498 .map(PathBuf::from)
499}
500
501pub(crate) fn tracker_git(args: &[&str]) -> Option<String> {
504 git_in(&env_root()?, args)
505}
506
507#[must_use]
510pub fn tracker_is_git_checkout() -> Option<bool> {
511 let root = env_root()?;
512 let out = std::process::Command::new("git")
513 .arg("-C")
514 .arg(root)
515 .args(["rev-parse", "--is-inside-work-tree"])
516 .output()
517 .ok()?;
518 if !out.status.success() {
519 return Some(false);
520 }
521 Some(String::from_utf8_lossy(&out.stdout).trim() == "true")
522}
523
524pub(crate) fn git_history(root: &Path) -> std::process::Command {
530 let mut cmd = std::process::Command::new("git");
531 crate::clear_git_repo_env(&mut cmd);
532 cmd.arg("--no-replace-objects")
533 .arg("-C")
534 .arg(root)
535 .env("GIT_NO_REPLACE_OBJECTS", "1")
536 .env("GIT_GRAFT_FILE", "/dev/null");
537 cmd
538}
539
540pub(crate) fn git_in(root: &Path, args: &[&str]) -> Option<String> {
543 let out = git_history(root).args(args).output().ok()?;
544 out.status
545 .success()
546 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
547}
548
549#[must_use]
551pub fn sign_with(key: &SigningKey, issue: &str, voter: &str, choice: &str) -> String {
552 let (public, sig) = signed_hex(key, &message(issue, voter, choice));
553 format!(
554 "{SIG_NOTE}{} {} {} {}",
555 voter.trim(),
556 choice.trim(),
557 public,
558 sig
559 )
560}
561
562#[must_use]
564pub fn sign_bound(
565 key: &SigningKey,
566 issue: &str,
567 voter: &str,
568 choice: &str,
569 harness: &str,
570 nonce: &str,
571) -> String {
572 let harness = field(harness);
573 let nonce = field(nonce);
574 let (public, sig) = signed_hex(key, &message_v2(issue, voter, choice, &harness, &nonce));
575 format!(
576 "{SIG_NOTE}{} {} {} {} {} {}",
577 voter.trim(),
578 choice.trim(),
579 public,
580 sig,
581 harness,
582 nonce
583 )
584}
585
586fn covered(issue: &str, note: &SigNote) -> Vec<u8> {
587 match (¬e.harness, ¬e.nonce) {
588 (Some(h), Some(n)) => message_v2(issue, ¬e.voter, ¬e.choice, h, n),
589 _ => message(issue, ¬e.voter, ¬e.choice),
590 }
591}
592
593#[must_use]
595pub fn verifies(issue: &str, note: &SigNote) -> bool {
596 signature_ok(¬e.key, ¬e.sig, &covered(issue, note))
597}
598
599fn pin_path() -> Option<PathBuf> {
601 Some(env_root()?.join(PIN_FILE))
602}
603
604#[must_use]
606pub fn parse_pins(text: &str) -> BTreeMap<String, String> {
607 text.lines()
608 .filter_map(|l| {
609 let mut it = l.split_whitespace();
610 Some((it.next()?.to_string(), it.next()?.to_string()))
611 })
612 .collect()
613}
614
615fn parse_pins_strict(text: &str) -> Result<BTreeMap<String, String>> {
616 let mut out = BTreeMap::new();
617 for (idx, raw) in text.lines().enumerate() {
618 let line = raw.trim();
619 if line.is_empty() {
620 continue;
621 }
622 let mut it = line.split_whitespace();
623 let Some(voter) = it.next() else {
624 continue;
625 };
626 let key = it
627 .next()
628 .ok_or_else(|| anyhow::anyhow!("pin line {} must be `voter key`", idx + 1))?;
629 if it.next().is_some() {
630 bail!("pin line {} must be `voter key`", idx + 1);
631 }
632 if out.insert(voter.to_string(), key.to_string()).is_some() {
633 bail!("duplicate pin voter `{voter}`");
634 }
635 }
636 Ok(out)
637}
638
639pub fn pins_for_check() -> Result<(BTreeMap<String, String>, String)> {
645 match env_root() {
646 Some(root) => pins_for_check_in(&root),
647 None => Ok((BTreeMap::new(), NO_PIN_FILE.into())),
648 }
649}
650
651const NO_PIN_FILE: &str = "none: first-seen in this check";
652
653pub fn pins_for_check_in(root: &Path) -> Result<(BTreeMap<String, String>, String)> {
655 let path = root.join(PIN_FILE);
656 let text = match std::fs::read_to_string(&path) {
657 Ok(text) => text,
658 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
659 if std::fs::symlink_metadata(&path).is_ok() {
660 bail!("pin file {} is a link to a missing file", path.display());
661 }
662 return Ok((BTreeMap::new(), NO_PIN_FILE.into()));
663 }
664 Err(err) => return Err(err).with_context(|| format!("pin file {}", path.display())),
665 };
666 let pins = parse_pins_strict(&text).with_context(|| format!("pin file {}", path.display()))?;
667 Ok((pins, path.display().to_string()))
668}
669
670#[must_use]
672pub fn pins() -> BTreeMap<String, String> {
673 pin_path()
674 .and_then(|p| std::fs::read_to_string(p).ok())
675 .map(|t| parse_pins(&t))
676 .unwrap_or_default()
677}
678
679#[must_use]
681pub fn field(raw: &str) -> String {
682 let t: String = raw
683 .trim()
684 .chars()
685 .map(|c| {
686 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@') {
687 c
688 } else {
689 '_'
690 }
691 })
692 .collect();
693 if t.is_empty() {
694 "-".into()
695 } else {
696 t
697 }
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct SessionIdentity {
707 pub harness: String,
708 pub nonce: String,
709}
710
711pub const SEAT_NONCE: &str = "seat";
715
716impl SessionIdentity {
717 #[must_use]
719 pub fn seat_only(&self) -> bool {
720 self.nonce == SEAT_NONCE
721 }
722}
723
724#[must_use]
727pub fn unverified_sentence(seat: &str) -> String {
728 format!(
729 "unverified session: nothing named the run but seat {seat}, so every run of that seat is one session"
730 )
731}
732
733tokio::task_local! {
734 static CALL_SESSION: String;
735 static CALL_AGENT: String;
736}
737
738std::thread_local! {
739 static HOOK_SESSION: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
740}
741
742#[must_use]
744pub fn call_session() -> Option<String> {
745 CALL_SESSION
746 .try_with(|s| s.clone())
747 .ok()
748 .filter(|s| !s.is_empty())
749}
750
751pub async fn scope_session<F: std::future::Future>(
755 session: Option<String>,
756 agent: Option<String>,
757 f: F,
758) -> F::Output {
759 let agent = agent
760 .map(|a| field(&a))
761 .filter(|a| a != "-")
762 .unwrap_or_default();
763 let f = CALL_AGENT.scope(agent, f);
764 match session.map(|s| field(&s)).filter(|s| s != "-") {
765 Some(s) => CALL_SESSION.scope(s, f).await,
766 None => f.await,
767 }
768}
769
770static PROCESS_AGENT: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
771
772pub fn hook_agent(agent: Option<String>) {
774 let _ = PROCESS_AGENT.set(agent);
775}
776
777fn subagent(parent: &str) -> Option<String> {
782 if let Ok(agent) = CALL_AGENT.try_with(Clone::clone) {
783 return (!agent.is_empty()).then_some(agent);
784 }
785 PROCESS_AGENT
786 .get_or_init(|| {
787 crate::subagent::claim_command(parent)
788 .filter(|a| !a.is_empty())
789 .map(|a| field(&a))
790 .or_else(|| codex_thread(parent))
791 })
792 .clone()
793}
794
795static HOOKED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
796
797fn hooked_session() -> Option<String> {
802 if CALL_AGENT.try_with(|_| ()).is_ok() {
803 return None;
804 }
805 HOOKED
806 .get_or_init(|| {
807 let (session, agent) = crate::subagent::claim_unsessioned_command()?;
808 let (session, agent) = (field(&session), field(&agent));
809 Some(if agent == "-" {
810 session
811 } else {
812 format!("{session}:{agent}")
813 })
814 })
815 .clone()
816}
817
818fn codex_thread(parent: &str) -> Option<String> {
821 let root = env_token("CODEX_SESSION_ID")?;
822 let thread = env_token("CODEX_THREAD_ID")?;
823 (root == parent && thread != root).then_some(thread)
824}
825
826pub fn export_hook_session(id: Option<&str>) {
830 crate::with_env_mutation(|| {
831 let id = id.and_then(crate::usable_session_id).map(field);
832 HOOK_SESSION.with(|c| *c.borrow_mut() = id.clone());
833 if let Some(id) = id {
834 if std::env::var("LJOS_HARNESS_SESSION")
835 .ok()
836 .filter(|s| !s.trim().is_empty())
837 .is_none()
838 {
839 std::env::set_var("LJOS_HARNESS_SESSION", id);
840 }
841 }
842 });
843}
844
845fn env_token(key: &str) -> Option<String> {
846 let v = std::env::var(key).ok()?;
847 let v = v.trim();
848 if v.is_empty() {
849 return None;
850 }
851 Some(field(v))
852}
853
854static CONNECTION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
855
856pub fn pin_connection_session(hint: Option<&str>) {
861 let Some(chosen) = hint
862 .map(str::trim)
863 .filter(|s| !s.is_empty())
864 .map(field)
865 .filter(|s| s != "-")
866 .or_else(|| env_token("LJOS_HARNESS_SESSION"))
867 else {
868 return;
869 };
870 let _ = CONNECTION.set(chosen);
871}
872
873#[must_use]
876pub fn connection_session() -> Option<String> {
877 CONNECTION.get().cloned()
878}
879
880#[must_use]
885pub fn harness_session() -> String {
886 if let Some(s) = connection_session() {
887 return s;
888 }
889 if let Some(s) = call_session() {
890 return s;
891 }
892 if let Some(s) = HOOK_SESSION.with(|c| c.borrow().clone()) {
893 return s;
894 }
895 let stamped = crate::stamped_sessions();
896 const PREFER: &[&str] = &[
897 "CURSOR_SESSION_ID",
898 "CLAUDE_SESSION_ID",
899 "CODEX_SESSION_ID",
902 "CODEX_THREAD_ID",
903 "GROK_SESSION_ID",
904 "SEAT_SESSION_ID",
905 "LJOS_SESSION_ID",
906 "CLAUDE_CODE_SESSION_ID",
910 "OPENCODE_SESSION_ID",
911 ];
912 for key in PREFER {
913 if let Some(s) = stamped
914 .iter()
915 .find(|(k, _)| k == *key)
916 .map(|(_, v)| field(v))
917 .or_else(|| env_token(key))
918 {
919 if s.len() >= 8 && !(*key == "LJOS_SESSION_ID" && crate::is_shared_holder_id(&s)) {
922 return s;
923 }
924 }
925 }
926 if let Some(s) = crate::runner_session_id().map(|s| field(&s)) {
927 return s;
928 }
929 if let Some(s) = env_token("LJOS_HARNESS_SESSION") {
930 return s;
931 }
932 run_session().unwrap_or_default()
933}
934
935#[cfg(test)]
936static RUNNER_SESSIONS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
937
938#[cfg(test)]
941#[must_use]
942pub(crate) fn fake_runner_sessions(ids: &[&str]) -> impl Drop {
943 struct Reset;
944 impl Drop for Reset {
945 fn drop(&mut self) {
946 *RUNNER_SESSIONS
947 .lock()
948 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
949 }
950 }
951 *RUNNER_SESSIONS
952 .lock()
953 .unwrap_or_else(std::sync::PoisonError::into_inner) =
954 Some(ids.iter().map(|s| (*s).to_string()).collect());
955 Reset
956}
957
958#[must_use]
966pub fn runner_sessions() -> Vec<String> {
967 #[cfg(test)]
968 if let Some(ids) = RUNNER_SESSIONS
969 .lock()
970 .unwrap_or_else(std::sync::PoisonError::into_inner)
971 .clone()
972 {
973 return ids;
974 }
975 let Some(started) = crate::proc_tree::started_by_runner() else {
976 return Vec::new();
977 };
978 crate::proc_tree::environ(started)
979 .unwrap_or_default()
980 .into_iter()
981 .filter(|(k, v)| k == "LJOS_HARNESS_SESSION" || crate::runner_session_var(k, v))
982 .map(|(_, v)| field(v.trim()))
983 .filter(|v| !v.is_empty() && v != "-")
984 .collect()
985}
986
987fn run_session() -> Option<String> {
991 let actions = std::env::var("GITHUB_ACTIONS").is_ok_and(|v| {
992 let v = v.trim();
993 !v.is_empty() && !matches!(v, "0" | "false")
994 });
995 let ci: &[&str] = if actions { &["GITHUB_RUN_ID"] } else { &[] };
996 ci.iter()
997 .chain(&["CURSOR_AGENT_ID", "CLOUD_AGENT_ID"])
998 .filter_map(|key| env_token(key))
999 .find(|s| s.len() >= 8)
1000}
1001
1002fn random_hex(n: usize) -> String {
1003 use std::io::Read;
1004 let mut buf = vec![0u8; n];
1005 if std::fs::File::open("/dev/urandom")
1006 .and_then(|mut f| f.read_exact(&mut buf))
1007 .is_ok()
1008 {
1009 return hex::encode(buf);
1010 }
1011 format!("{:x}{:x}", std::process::id(), std::process::id())
1012}
1013
1014static PROCESS_NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
1015
1016#[must_use]
1019pub fn process_nonce() -> String {
1020 if let Some(s) = env_token("LJOS_PROCESS_NONCE") {
1021 if s != "-" {
1022 return s;
1023 }
1024 }
1025 PROCESS_NONCE.get_or_init(|| random_hex(16)).clone()
1026}
1027
1028fn named_session_actor(key: &str) -> Option<String> {
1030 let name = env_token(key)?;
1031 let lower = name.to_ascii_lowercase();
1032 if matches!(lower.as_str(), "seat" | "you" | "agent" | "runner" | "-") {
1033 return None;
1034 }
1035 Some(name)
1036}
1037
1038#[must_use]
1043pub fn identity() -> SessionIdentity {
1044 let harness = harness_session();
1045 if !harness.is_empty() {
1046 let harness = match subagent(&harness) {
1047 Some(agent) => format!("{harness}:{agent}"),
1048 None => harness,
1049 };
1050 return SessionIdentity {
1051 harness,
1052 nonce: "-".into(),
1053 };
1054 }
1055 if let Some(harness) = hooked_session() {
1056 return SessionIdentity {
1057 harness,
1058 nonce: "-".into(),
1059 };
1060 }
1061 match named_session_actor("VISSUE_AGENT").or_else(|| named_session_actor("LJOS_SEAT")) {
1062 Some(seat) => SessionIdentity {
1063 harness: seat,
1064 nonce: SEAT_NONCE.into(),
1065 },
1066 None => SessionIdentity {
1067 harness: "-".into(),
1068 nonce: "-".into(),
1069 },
1070 }
1071}
1072
1073fn signing_key(voter: &str) -> Result<(SigningKey, String)> {
1075 let path = seat_key_path(voter)
1076 .ok_or_else(|| anyhow::anyhow!("no place for a seat key: HOME and XDG_DATA_HOME are unset; the ballot is unsigned and no gate counts it"))?;
1077 let key = load_or_create(&path)?;
1078 let mine = hex::encode(key.verifying_key().to_bytes());
1079 let pinned = match pin_path() {
1080 Some(p) if !pins().contains_key(voter.trim()) => pin_first(&p, voter.trim(), &mine)?,
1081 _ => pins(),
1082 };
1083 let mine_fp = key_fingerprint(&mine);
1084 if let Some(k) = pinned
1085 .get(voter.trim())
1086 .filter(|k| key_fingerprint(k) != mine_fp)
1087 {
1088 bail!(
1089 "seat {voter} is pinned to key {}…, and this key is {}…; the ballot is unsigned and no gate counts it",
1090 &k[..12.min(k.len())],
1091 &mine[..12]
1092 );
1093 }
1094 Ok((key, mine))
1095}
1096
1097fn pin_first(path: &Path, voter: &str, mine: &str) -> Result<BTreeMap<String, String>> {
1103 let _held = crate::tracker_commit_lock(path.parent().unwrap_or_else(|| Path::new(".")));
1104 let mut pinned = std::fs::read_to_string(path)
1105 .map(|t| parse_pins(&t))
1106 .unwrap_or_default();
1107 if !pinned.contains_key(voter) {
1108 pinned.insert(voter.to_string(), mine.to_string());
1109 let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
1110 vissue_core::store::replace_file_atomically(path, &text)?;
1111 }
1112 Ok(pinned)
1113}
1114
1115pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
1125 if let Some((existing, withdrawn)) = newest_sig(issue, voter) {
1126 if verifies(issue, &existing) {
1127 match resign_of(&existing, choice, voter, withdrawn) {
1128 Resign::Keep => {
1129 return Ok(format!("ballot already signed by {voter}\n"));
1130 }
1131 Resign::Refuse => {
1132 bail!("will not re-sign {voter}'s ballot");
1133 }
1134 Resign::Sign => {}
1135 }
1136 }
1137 }
1138 let (key, mine) = signing_key(voter)?;
1139 let id = identity();
1140 let note = sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce);
1141 if write_sig_note(issue, ¬e).is_err() {
1142 crate::run_captured("vissue", &["note", issue, ¬e])?;
1143 }
1144 let session = if id.harness == "-" {
1145 format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
1146 } else {
1147 id.harness.clone()
1148 };
1149 let mut said = format!(
1150 "ballot signed by {voter} with key {}… session {session}\n",
1151 &mine[..12.min(mine.len())]
1152 );
1153 if id.seat_only() {
1154 said.push_str(&format!("{}\n", unverified_sentence(&id.harness)));
1155 }
1156 Ok(said)
1157}
1158
1159enum Resign {
1160 Keep,
1161 Refuse,
1162 Sign,
1163}
1164
1165fn resign_of(existing: &SigNote, choice: &str, voter: &str, withdrawn: bool) -> Resign {
1170 let mine = current_public_hex(voter);
1171 if mine.as_deref() == Some(existing.key.as_str()) {
1172 if existing.choice == choice && !withdrawn {
1173 Resign::Keep
1174 } else {
1175 Resign::Sign
1176 }
1177 } else {
1178 Resign::Refuse
1179 }
1180}
1181
1182fn current_public_hex(voter: &str) -> Option<String> {
1183 let path = seat_key_path(voter)?;
1184 let key = key_from_file(&path).ok()?;
1185 Some(hex::encode(key.verifying_key().to_bytes()))
1186}
1187
1188fn same_voter(signed: &str, voter: &str) -> bool {
1189 signed == voter.trim() || signed == field(voter)
1190}
1191
1192fn newest_sig(issue: &str, voter: &str) -> Option<(SigNote, bool)> {
1193 let layout = vissue_core::Layout::resolve(None, None).ok()?;
1194 let (heading, _, _) = vissue_core::store::find_by_id(&layout, issue).ok()??;
1195 let mut cut = None;
1196 let mut newest = None;
1197 for (at, entry) in heading.logbook.iter().enumerate() {
1198 let Some(text) = entry.note.as_deref() else {
1199 continue;
1200 };
1201 if cut.is_none() {
1202 if let Some(withdrawn) = parse_withdrawn(text) {
1203 if same_voter(&withdrawn.voter, voter) {
1204 cut = Some(at);
1205 }
1206 }
1207 }
1208 if newest.is_none() {
1209 if let Some(sig) = parse_note(text) {
1210 if same_voter(&sig.voter, voter) {
1211 newest = Some((at, sig));
1212 }
1213 }
1214 }
1215 if cut.is_some() && newest.is_some() {
1216 break;
1217 }
1218 }
1219 let (at, sig) = newest?;
1220 let withdrawn = cut.is_some_and(|cut_at| cut_at < at);
1221 Some((sig, withdrawn))
1222}
1223
1224pub(crate) fn write_sig_note(issue: &str, note: &str) -> Result<()> {
1225 let layout = vissue_core::Layout::resolve(None, None)?;
1226 vissue_core::ops::note(&layout, issue, note)?;
1227 Ok(())
1228}
1229
1230pub fn sign_own_ballot(issue: &str) -> Result<String> {
1236 let Some(voter) = own_voter() else {
1237 return Ok(String::new());
1238 };
1239 let layout = vissue_core::Layout::resolve(None, None)?;
1240 let ballots = vissue_core::ops::ballots(&layout, issue)?;
1241 let Some(choice) = ballots
1242 .iter()
1243 .find(|b| b.agent == voter)
1244 .map(|b| b.choice.clone())
1245 else {
1246 return Ok(String::new());
1247 };
1248 sign_ballot(issue, &voter, &choice)
1249}
1250
1251#[must_use]
1254pub fn own_voter() -> Option<String> {
1255 crate::checked_seat().or_else(|| {
1256 ["VISSUE_AGENT", "LJOS_SEAT"].iter().find_map(|key| {
1257 std::env::var(key)
1258 .ok()
1259 .map(|s| s.trim().to_string())
1260 .filter(|s| !s.is_empty())
1261 })
1262 })
1263}
1264
1265pub fn note_withdrawn(issue: &str, voter: &str) -> Result<String> {
1271 note_withdrawn_on(issue, voter, issue)
1272}
1273
1274pub fn note_withdrawn_on(
1281 logbook_issue: &str,
1282 voter: &str,
1283 withdrawn_issue: &str,
1284) -> Result<String> {
1285 let id = identity();
1286 let note = withdrawn_line_on(voter, &id.harness, &id.nonce, Some(withdrawn_issue));
1287 crate::run_captured("vissue", &["note", logbook_issue, ¬e]).with_context(|| {
1288 format!("ballot withdrawn; the note on {logbook_issue} was not written")
1289 })?;
1290 Ok(note)
1291}
1292
1293pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
1295
1296#[must_use]
1299pub fn candidate_message(
1300 issue: &str,
1301 seat: &str,
1302 harness: &str,
1303 nonce: &str,
1304 saw: &str,
1305) -> Vec<u8> {
1306 format!(
1307 "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
1308 issue.trim(),
1309 seat.trim(),
1310 harness.trim(),
1311 nonce.trim(),
1312 saw.trim()
1313 )
1314 .into_bytes()
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Eq)]
1319pub struct CandidateNote {
1320 pub issue: String,
1321 pub seat: String,
1322 pub harness: String,
1323 pub nonce: String,
1324 pub saw: Vec<String>,
1325 pub key: String,
1326 pub sig: String,
1327}
1328
1329fn saw_token(saw: &[String]) -> String {
1330 if saw.is_empty() {
1331 "-".into()
1332 } else {
1333 saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
1334 }
1335}
1336
1337fn saw_list(token: &str) -> Vec<String> {
1338 if token.is_empty() || token == "-" {
1339 Vec::new()
1340 } else {
1341 token
1342 .split(',')
1343 .map(str::trim)
1344 .filter(|s| !s.is_empty() && *s != "-")
1345 .map(str::to_string)
1346 .collect()
1347 }
1348}
1349
1350#[must_use]
1352pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
1353 let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
1354 let parts: Vec<&str> = rest.split_whitespace().collect();
1355 let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
1356 return None;
1357 };
1358 Some(CandidateNote {
1359 issue: (*issue).to_string(),
1360 seat: (*seat).to_string(),
1361 harness: (*harness).to_string(),
1362 nonce: (*nonce).to_string(),
1363 saw: saw_list(saw),
1364 key: (*key).to_string(),
1365 sig: (*sig).to_string(),
1366 })
1367}
1368
1369#[must_use]
1371pub fn candidate_verifies(note: &CandidateNote) -> bool {
1372 signature_ok(
1373 ¬e.key,
1374 ¬e.sig,
1375 &candidate_message(
1376 ¬e.issue,
1377 ¬e.seat,
1378 ¬e.harness,
1379 ¬e.nonce,
1380 &saw_token(¬e.saw),
1381 ),
1382 )
1383}
1384
1385pub fn sign_candidate(
1391 issue: &str,
1392 seat: &str,
1393 id: &SessionIdentity,
1394 saw: &[String],
1395) -> Result<String> {
1396 let (key, _) = signing_key(seat)?;
1397 let harness = field(&id.harness);
1398 let nonce = field(&id.nonce);
1399 let saw_tok = saw_token(saw);
1400 let (public, sig) = signed_hex(
1401 &key,
1402 &candidate_message(issue, seat, &harness, &nonce, &saw_tok),
1403 );
1404 Ok(format!(
1405 "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
1406 field(issue),
1407 field(seat),
1408 harness,
1409 nonce,
1410 saw_tok,
1411 public,
1412 sig
1413 ))
1414}
1415
1416#[must_use]
1420pub fn seat_problems(
1421 issue: &str,
1422 seats: &[(&str, &str)],
1423 notes: &[String],
1424 pins: &BTreeMap<String, String>,
1425) -> Vec<String> {
1426 seat_check(issue, seats, notes, pins, &|_, _| false).0
1427}
1428
1429#[must_use]
1433pub fn seat_check(
1434 issue: &str,
1435 seats: &[(&str, &str)],
1436 notes: &[String],
1437 pins: &BTreeMap<String, String>,
1438 legacy: &dyn Fn(&str, &str) -> bool,
1439) -> (Vec<String>, Vec<String>) {
1440 let mut out = Vec::new();
1441 let mut old = Vec::new();
1442 for (voter, choice) in seats {
1443 let newest = notes
1444 .iter()
1445 .filter_map(|n| parse_note(n))
1446 .find(|n| n.voter == *voter);
1447 match newest {
1448 None if legacy(voter, choice) => old.push((*voter).to_string()),
1449 None => out.push(format!("{voter}'s ballot is unsigned")),
1450 Some(n) if n.choice != *choice => out.push(format!(
1451 "{voter}'s signature is for {} and the ballot says {choice}",
1452 n.choice
1453 )),
1454 Some(n) if !verifies(issue, &n) => {
1455 out.push(format!("{voter}'s signature does not verify"));
1456 }
1457 Some(n) => match pins.get(*voter) {
1458 Some(k) if key_fingerprint(k) == key_fingerprint(&n.key) => {}
1459 Some(_) => out.push(format!(
1460 "{voter}'s ballot is signed by a key other than its pinned one"
1461 )),
1462 None => out.push(format!("{voter} has no pinned key on this tracker")),
1463 },
1464 }
1465 }
1466 (out, old)
1467}
1468
1469#[must_use]
1471pub fn notes_of(v: &Value) -> Vec<String> {
1472 v["logbook"]
1473 .as_array()
1474 .into_iter()
1475 .flatten()
1476 .filter_map(|e| e["note"].as_str().map(str::to_string))
1477 .collect()
1478}
1479
1480#[must_use]
1483pub fn unsigned_allowed() -> bool {
1484 std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489 use super::*;
1490 use std::path::Path;
1491 use std::process::Command;
1492
1493 fn key(b: u8) -> SigningKey {
1494 SigningKey::from_bytes(&[b; 32])
1495 }
1496
1497 fn git(dir: &Path, args: &[&str], env: &[(&str, String)]) {
1498 let mut cmd = Command::new("git");
1499 cmd.arg("-C").arg(dir).args(args);
1500 for (k, v) in env {
1501 cmd.env(k, v);
1502 }
1503 let out = cmd.output().unwrap();
1504 assert!(
1505 out.status.success(),
1506 "git {:?} failed: {}",
1507 args,
1508 String::from_utf8_lossy(&out.stderr)
1509 );
1510 }
1511
1512 #[test]
1513 fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
1514 let alice = key(1);
1515 let mallory = key(2);
1516 let pins: BTreeMap<String, String> = [(
1517 "alice".to_string(),
1518 hex::encode(alice.verifying_key().to_bytes()),
1519 )]
1520 .into();
1521 let good = sign_with(&alice, "d-1", "alice", "ship");
1522 assert!(seat_problems(
1523 "d-1",
1524 &[("alice", "ship")],
1525 std::slice::from_ref(&good),
1526 &pins
1527 )
1528 .is_empty());
1529 let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
1531 assert!(p[0].contains("unsigned"), "{p:?}");
1532 let forged = sign_with(&mallory, "d-1", "alice", "ship");
1534 let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
1535 assert!(p[0].contains("other than its pinned"), "{p:?}");
1536 let p = seat_problems(
1538 "d-1",
1539 &[("alice", "hold")],
1540 std::slice::from_ref(&good),
1541 &pins,
1542 );
1543 assert!(p[0].contains("is for ship"), "{p:?}");
1544 let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
1546 assert!(p[0].contains("does not verify"), "{p:?}");
1547 let lone = sign_with(&mallory, "d-1", "bob", "ship");
1549 let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
1550 assert!(p[0].contains("no pinned key"), "{p:?}");
1551 }
1552
1553 #[test]
1554 fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
1555 let pins = BTreeMap::new();
1556 let legacy = |v: &str, _: &str| v == "inky";
1557 let (bad, old) = seat_check(
1558 "d-1",
1559 &[("inky", "ship"), ("bob", "ship")],
1560 &[],
1561 &pins,
1562 &legacy,
1563 );
1564 assert_eq!(old, vec!["inky".to_string()]);
1565 assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
1566 }
1567
1568 #[test]
1569 fn seats_sharing_a_home_get_their_own_keys() {
1570 let dir = tempfile::tempdir().unwrap();
1571 let a = dir.path().join("ljos/seats/inky/ballot.key");
1572 let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
1573 let ka = load_or_create(&a).unwrap();
1574 let kb = load_or_create(&b).unwrap();
1575 assert_ne!(ka.verifying_key(), kb.verifying_key());
1576 assert_eq!(
1577 load_or_create(&a).unwrap().verifying_key(),
1578 ka.verifying_key()
1579 );
1580 use std::os::unix::fs::PermissionsExt;
1581 assert_eq!(
1582 std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
1583 0o600
1584 );
1585 }
1586
1587 #[test]
1588 fn a_key_at_its_folded_name_moves_to_the_seat_file() {
1589 let _lock = crate::test_env_lock();
1590 let data = tempfile::tempdir().unwrap();
1591 crate::with_env_mutation(|| {
1592 unsafe { std::env::set_var("XDG_DATA_HOME", data.path()) };
1594 });
1595 let folded = data.path().join("ljos/seats/judge_model-1.5/ballot.key");
1596 std::fs::create_dir_all(folded.parent().unwrap()).unwrap();
1597 std::fs::write(&folded, [7u8; 32]).unwrap();
1598 let path = seat_key_file("judge:model-1.5").unwrap();
1599 assert_ne!(path, folded);
1600 assert_eq!(std::fs::read(&path).unwrap(), vec![7u8; 32]);
1601 assert!(!folded.exists());
1602 assert_eq!(seat_key_file("judge:model-1.5").unwrap(), path);
1603 let slug = seat_key_file("disk-saver").unwrap();
1604 assert_eq!(slug, data.path().join("ljos/seats/disk-saver/ballot.key"));
1605 }
1606
1607 #[test]
1608 fn the_newest_signature_wins() {
1609 let a = key(3);
1610 let pins: BTreeMap<String, String> =
1611 [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
1612 let new = sign_with(&a, "d-1", "a", "hold");
1613 let old = sign_with(&a, "d-1", "a", "ship");
1614 assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
1615 }
1616
1617 #[test]
1618 fn a_choice_with_a_space_parses_whole_and_verifies() {
1619 let k = key(5);
1622 let bound = sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
1623 let parsed = parse_note(&bound).expect("a bound note with a spaced choice");
1624 assert_eq!(parsed.voter, "seat-a");
1625 assert_eq!(parsed.choice, "beside binary");
1626 assert_eq!(parsed.harness.as_deref(), Some("spaced-1"));
1627 assert_eq!(parsed.nonce.as_deref(), Some("-"));
1628 assert!(verifies("panel-spcd", &parsed));
1629 assert_eq!(
1630 note_session(&bound),
1631 Some(("spaced-1".to_string(), "-".to_string()))
1632 );
1633 let plain = sign_with(&k, "panel-spcd", "seat-a", "xdg home dir");
1635 let parsed = parse_note(&plain).expect("a v1 note with a spaced choice");
1636 assert_eq!(parsed.choice, "xdg home dir");
1637 assert_eq!(parsed.harness, None);
1638 assert!(verifies("panel-spcd", &parsed));
1639 let wide = sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
1641 let parsed = parse_note(&wide).unwrap();
1642 assert_eq!(parsed.choice, "xdg home");
1643 assert!(verifies("panel-spcd", &parsed));
1644 let pins: BTreeMap<String, String> = [(
1645 "seat-a".to_string(),
1646 hex::encode(k.verifying_key().to_bytes()),
1647 )]
1648 .into();
1649 assert!(seat_problems(
1650 "panel-spcd",
1651 &[("seat-a", "beside binary")],
1652 &[bound],
1653 &pins
1654 )
1655 .is_empty());
1656 }
1657
1658 #[test]
1659 fn a_note_that_does_not_parse_still_names_its_session() {
1660 let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -";
1661 assert_eq!(parse_note(odd), None);
1662 assert_eq!(
1663 note_session(odd),
1664 Some(("spaced-1".to_string(), "-".to_string()))
1665 );
1666 assert_eq!(note_session("ballot-sig: seat-a ship ab"), None);
1667 assert_eq!(note_session("finding: seat-a: nit: name the unit"), None);
1668 }
1669
1670 #[test]
1671 fn a_withdrawn_note_is_three_fields() {
1672 let line = withdrawn_line("seat a", "sess-1", "-");
1673 assert_eq!(line, "ballot-withdrawn: seat_a sess-1 -");
1674 let parsed = parse_withdrawn(&line).unwrap();
1675 assert_eq!(parsed.voter, "seat_a");
1676 assert_eq!(parsed.harness, "sess-1");
1677 assert_eq!(parsed.nonce, "-");
1678 assert_eq!(parsed.issue, None);
1679 let on = withdrawn_line_on("seat a", "sess-1", "-", Some("child-a"));
1680 assert_eq!(on, "ballot-withdrawn: seat_a sess-1 - child-a");
1681 let parsed = parse_withdrawn(&on).unwrap();
1682 assert_eq!(parsed.issue.as_deref(), Some("child-a"));
1683 assert!(parse_withdrawn("ballot-withdrawn: only-one").is_none());
1684 assert!(parse_withdrawn("ballot-sig: seat-a ship").is_none());
1685 }
1686
1687 #[test]
1688 fn a_session_binding_is_signed_and_a_changed_session_is_not() {
1689 let alice = key(4);
1690 let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
1691 let parsed = parse_note(¬e).unwrap();
1692 assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
1693 assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
1694 assert!(verifies("d-1", &parsed));
1695 let mut moved = parsed.clone();
1696 moved.harness = Some("sess-other-0001".into());
1697 assert!(
1698 !verifies("d-1", &moved),
1699 "a rewritten session still verified"
1700 );
1701 let pins: BTreeMap<String, String> = [(
1702 "alice".to_string(),
1703 hex::encode(alice.verifying_key().to_bytes()),
1704 )]
1705 .into();
1706 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
1707 }
1708
1709 #[test]
1710 fn a_legacy_ballot_is_read_on_its_own_issue() {
1711 let _lock = crate::test_env_lock();
1712 let dir = tempfile::tempdir().unwrap();
1713 let file = dir.path().join("issues.org");
1714 git(dir.path(), &["init"], &[]);
1715 git(dir.path(), &["config", "user.name", "Rohit Goswami"], &[]);
1716 git(
1717 dir.path(),
1718 &["config", "user.email", "rgoswami@ieee.org"],
1719 &[],
1720 );
1721 std::fs::write(
1722 &file,
1723 "* TODO [#C] old\n:PROPERTIES:\n:ID: probe-old\n:END:\n- [X] elder: rail\n",
1724 )
1725 .unwrap();
1726 git(dir.path(), &["add", "issues.org"], &[]);
1727 git(dir.path(), &["commit", "-m", "old ballot"], &[]);
1728 std::fs::write(dir.path().join(PIN_FILE), "inky 00\n").unwrap();
1729 git(dir.path(), &["add", PIN_FILE], &[]);
1730 git(dir.path(), &["commit", "-m", "first pin"], &[]);
1731 std::fs::write(
1732 &file,
1733 "* TODO [#C] old\n:PROPERTIES:\n:ID: probe-old\n:END:\n- [X] elder: rail\n\
1734 * TODO [#C] new\n:PROPERTIES:\n:ID: probe-new\n:END:\n- [X] elder: rail\n",
1735 )
1736 .unwrap();
1737 git(dir.path(), &["add", "issues.org"], &[]);
1738 git(dir.path(), &["commit", "-m", "new ballot"], &[]);
1739 assert!(recorded_before_signing_in(dir.path(), "probe-old", "elder", "rail").unwrap());
1740 assert!(!recorded_before_signing_in(dir.path(), "probe-new", "elder", "rail").unwrap());
1741 }
1742
1743 #[test]
1744 fn seat_check_accepts_the_same_key_in_uppercase_hex() {
1745 let alice = key(8);
1746 let note = sign_with(&alice, "d-1", "alice", "ship");
1747 let pins: BTreeMap<String, String> = [(
1748 "alice".to_string(),
1749 hex::encode(alice.verifying_key().to_bytes()).to_ascii_uppercase(),
1750 )]
1751 .into();
1752 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
1753 }
1754
1755 #[tokio::test]
1759 async fn an_mcp_call_from_a_subagent_is_a_session_of_its_own() {
1760 let call = |agent: Option<&str>| {
1761 scope_session(
1762 Some("sess-mcp-0001".into()),
1763 agent.map(str::to_string),
1764 async { identity().harness },
1765 )
1766 };
1767 assert_eq!(call(Some("thread-a")).await, "sess-mcp-0001:thread-a");
1768 assert_eq!(call(Some("thread-b")).await, "sess-mcp-0001:thread-b");
1769 assert_eq!(call(None).await, "sess-mcp-0001");
1770 }
1771}