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}
149
150#[must_use]
152pub fn withdrawn_line(voter: &str, harness: &str, nonce: &str) -> String {
153 format!(
154 "{WITHDRAWN_NOTE}{} {} {}",
155 field(voter),
156 field(harness),
157 field(nonce)
158 )
159}
160
161#[must_use]
164pub fn parse_withdrawn(note: &str) -> Option<WithdrawnNote> {
165 let rest = note.trim().strip_prefix(WITHDRAWN_NOTE)?;
166 let parts: Vec<&str> = rest.split_whitespace().collect();
167 let [voter, harness, nonce] = parts.as_slice() else {
168 return None;
169 };
170 Some(WithdrawnNote {
171 voter: (*voter).to_string(),
172 harness: (*harness).to_string(),
173 nonce: (*nonce).to_string(),
174 })
175}
176
177fn spans(text: &str) -> Vec<(usize, usize)> {
179 let mut out = Vec::new();
180 let mut start = None;
181 for (i, c) in text.char_indices() {
182 if c.is_whitespace() {
183 if let Some(b) = start.take() {
184 out.push((b, i));
185 }
186 } else if start.is_none() {
187 start = Some(i);
188 }
189 }
190 if let Some(b) = start {
191 out.push((b, text.len()));
192 }
193 out
194}
195
196pub(crate) fn is_hex_len(text: &str, len: usize) -> bool {
197 text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
198}
199
200#[must_use]
206pub fn signature_ok(key_hex: &str, sig_hex: &str, message: &[u8]) -> bool {
207 let (Ok(key), Ok(sig)) = (hex::decode(key_hex), hex::decode(sig_hex)) else {
208 return false;
209 };
210 let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key), <[u8; 64]>::try_from(sig)) else {
211 return false;
212 };
213 let Ok(verifying) = VerifyingKey::from_bytes(&key) else {
214 return false;
215 };
216 verifying
217 .verify_strict(message, &Signature::from_bytes(&sig))
218 .is_ok()
219}
220
221#[must_use]
223pub fn weak_key(key_hex: &str) -> bool {
224 let Ok(Ok(key)) = hex::decode(key_hex).map(<[u8; 32]>::try_from) else {
225 return false;
226 };
227 VerifyingKey::from_bytes(&key).is_ok_and(|key| key.is_weak())
228}
229
230#[must_use]
237pub fn key_fingerprint(key_hex: &str) -> String {
238 let key = hex::decode(key_hex)
239 .ok()
240 .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
241 .and_then(|bytes| VerifyingKey::from_bytes(&bytes).ok());
242 match key {
243 Some(key) => hex::encode(key.to_edwards().mul_by_cofactor().compress().to_bytes()),
244 None => key_hex.to_ascii_lowercase(),
245 }
246}
247
248#[must_use]
250pub fn signed_hex(key: &SigningKey, message: &[u8]) -> (String, String) {
251 (
252 hex::encode(key.verifying_key().to_bytes()),
253 hex::encode(key.sign(message).to_bytes()),
254 )
255}
256
257pub fn observe_pin(pins: &mut BTreeMap<String, String>, voter: &str, key: &str) -> Result<()> {
263 let fingerprint = key_fingerprint(key);
264 match pins.get(voter) {
265 Some(pinned) if key_fingerprint(pinned) != fingerprint => bail!(
266 "{voter} is pinned to key {}…, got {}…",
267 key_prefix(pinned),
268 key_prefix(key)
269 ),
270 Some(_) => Ok(()),
271 None => {
272 pins.insert(voter.to_string(), key.to_string());
273 Ok(())
274 }
275 }
276}
277
278fn key_prefix(key: &str) -> &str {
279 &key[..12.min(key.len())]
280}
281
282pub(crate) fn key_from_file(path: &std::path::Path) -> Result<SigningKey> {
283 let raw = std::fs::read(path).with_context(|| format!("seat key {}", path.display()))?;
284 let seed: [u8; 32] = raw
285 .get(..32)
286 .and_then(|s| s.try_into().ok())
287 .ok_or_else(|| anyhow::anyhow!("seat key {} holds fewer than 32 bytes", path.display()))?;
288 Ok(SigningKey::from_bytes(&seed))
289}
290
291#[must_use]
297pub fn seat_key_path(seat: &str) -> Option<PathBuf> {
298 if let Some(p) = std::env::var_os("LJOS_SEAT_KEY").filter(|p| !p.is_empty()) {
299 let path = PathBuf::from(p);
300 if !is_box_host_key(&path) && seat_may_use_override(seat) {
301 return Some(path);
302 }
303 }
304 seat_key_file(seat)
305}
306
307#[must_use]
315pub fn seat_key_file(seat: &str) -> Option<PathBuf> {
316 let base = std::env::var_os("XDG_DATA_HOME")
317 .filter(|p| !p.is_empty())
318 .map(PathBuf::from)
319 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
320 let seats = base.join("ljos").join("seats");
321 let seat = seat.trim();
322 let name = seat_dir_name(seat);
323 let path = seats.join(&name).join("ballot.key");
324 let folded = folded_seat_dir_name(seat);
325 if folded != name && !folded.starts_with('.') && !path.exists() {
326 let old = seats.join(folded).join("ballot.key");
327 if old.is_file() {
328 if let Some(dir) = path.parent() {
329 let _ = std::fs::create_dir_all(dir);
330 }
331 let _ = std::fs::rename(&old, &path);
332 }
333 }
334 Some(path)
335}
336
337fn seat_dir_name(seat: &str) -> String {
338 let mut out = String::with_capacity(seat.len());
339 for (at, b) in seat.bytes().enumerate() {
340 if b.is_ascii_alphanumeric() || b == b'-' || (b == b'.' && at > 0) {
341 out.push(char::from(b));
342 } else {
343 out.push_str(&format!("%{b:02x}"));
344 }
345 }
346 out
347}
348
349fn folded_seat_dir_name(seat: &str) -> String {
350 seat.chars()
351 .map(|c| {
352 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
353 c
354 } else {
355 '_'
356 }
357 })
358 .collect()
359}
360
361#[must_use]
363pub fn is_box_host_key(path: &std::path::Path) -> bool {
364 path.ends_with("deedar/host.key")
365}
366
367fn seat_may_use_override(seat: &str) -> bool {
368 let seat = seat.trim();
369 let own = |key: &str| std::env::var(key).ok().is_some_and(|v| v.trim() == seat);
370 match (
371 std::env::var("LJOS_SEAT")
372 .ok()
373 .filter(|s| !s.trim().is_empty()),
374 std::env::var("VISSUE_AGENT")
375 .ok()
376 .filter(|s| !s.trim().is_empty()),
377 ) {
378 (None, None) => true,
379 _ => own("LJOS_SEAT") || own("VISSUE_AGENT"),
380 }
381}
382
383pub fn load_or_create(path: &std::path::Path) -> Result<SigningKey> {
389 if !path.exists() {
390 use std::io::Read;
391 use std::os::unix::fs::OpenOptionsExt;
392 let mut seed = [0u8; 32];
393 std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
394 if let Some(dir) = path.parent() {
395 std::fs::create_dir_all(dir)?;
396 }
397 let mut f = std::fs::OpenOptions::new()
398 .write(true)
399 .create_new(true)
400 .mode(0o600)
401 .open(path)
402 .with_context(|| format!("seat key {}", path.display()))?;
403 std::io::Write::write_all(&mut f, &seed)?;
404 }
405 key_from_file(path)
406}
407
408pub fn recorded_before_signing(issue: &str, voter: &str, choice: &str) -> Result<bool, String> {
421 let Some(root) = env_root() else {
422 return Ok(false);
423 };
424 recorded_before_signing_in(&root, issue, voter, choice)
425}
426
427pub fn recorded_before_signing_in(
434 root: &Path,
435 issue: &str,
436 voter: &str,
437 choice: &str,
438) -> Result<bool, String> {
439 crate::slot::require_tracker_history(root)?;
440 let log = git_in(
441 root,
442 &[
443 "--no-pager",
444 "log",
445 "--full-history",
446 "-m",
447 "--diff-filter=A",
448 "--format=%H %P",
449 "--",
450 PIN_FILE,
451 ],
452 )
453 .unwrap_or_default();
454 let held = |commit: &str| crate::slot::file_at_in(root, commit, PIN_FILE).is_some();
455 let starts: Vec<Vec<&str>> = log
456 .lines()
457 .filter_map(|line| {
458 let mut parts = line.split_whitespace();
459 let commit = parts.next()?;
460 let parents: Vec<&str> = parts.collect();
461 (held(commit) && !parents.iter().any(|p| held(p))).then_some(parents)
462 })
463 .collect();
464 let needle = format!("] {}: {}", voter.trim(), choice.trim());
465 Ok(!starts.is_empty()
466 && starts.iter().all(|parents| {
467 !parents.is_empty()
468 && parents
469 .iter()
470 .all(|p| crate::slot::note_on_issue_at_in(root, p, issue, &needle))
471 }))
472}
473
474pub(crate) fn env_root() -> Option<PathBuf> {
475 std::env::var_os("VISSUE_ROOT")
476 .filter(|r| !r.is_empty())
477 .map(PathBuf::from)
478}
479
480pub(crate) fn tracker_git(args: &[&str]) -> Option<String> {
483 git_in(&env_root()?, args)
484}
485
486#[must_use]
489pub fn tracker_is_git_checkout() -> Option<bool> {
490 let root = env_root()?;
491 let out = std::process::Command::new("git")
492 .arg("-C")
493 .arg(root)
494 .args(["rev-parse", "--is-inside-work-tree"])
495 .output()
496 .ok()?;
497 if !out.status.success() {
498 return Some(false);
499 }
500 Some(String::from_utf8_lossy(&out.stdout).trim() == "true")
501}
502
503pub(crate) fn git_history(root: &Path) -> std::process::Command {
509 let mut cmd = std::process::Command::new("git");
510 crate::clear_git_repo_env(&mut cmd);
511 cmd.arg("--no-replace-objects")
512 .arg("-C")
513 .arg(root)
514 .env("GIT_NO_REPLACE_OBJECTS", "1")
515 .env("GIT_GRAFT_FILE", "/dev/null");
516 cmd
517}
518
519pub(crate) fn git_in(root: &Path, args: &[&str]) -> Option<String> {
522 let out = git_history(root).args(args).output().ok()?;
523 out.status
524 .success()
525 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
526}
527
528#[must_use]
530pub fn sign_with(key: &SigningKey, issue: &str, voter: &str, choice: &str) -> String {
531 let (public, sig) = signed_hex(key, &message(issue, voter, choice));
532 format!(
533 "{SIG_NOTE}{} {} {} {}",
534 voter.trim(),
535 choice.trim(),
536 public,
537 sig
538 )
539}
540
541#[must_use]
543pub fn sign_bound(
544 key: &SigningKey,
545 issue: &str,
546 voter: &str,
547 choice: &str,
548 harness: &str,
549 nonce: &str,
550) -> String {
551 let harness = field(harness);
552 let nonce = field(nonce);
553 let (public, sig) = signed_hex(key, &message_v2(issue, voter, choice, &harness, &nonce));
554 format!(
555 "{SIG_NOTE}{} {} {} {} {} {}",
556 voter.trim(),
557 choice.trim(),
558 public,
559 sig,
560 harness,
561 nonce
562 )
563}
564
565fn covered(issue: &str, note: &SigNote) -> Vec<u8> {
566 match (¬e.harness, ¬e.nonce) {
567 (Some(h), Some(n)) => message_v2(issue, ¬e.voter, ¬e.choice, h, n),
568 _ => message(issue, ¬e.voter, ¬e.choice),
569 }
570}
571
572#[must_use]
574pub fn verifies(issue: &str, note: &SigNote) -> bool {
575 signature_ok(¬e.key, ¬e.sig, &covered(issue, note))
576}
577
578fn pin_path() -> Option<PathBuf> {
580 Some(env_root()?.join(PIN_FILE))
581}
582
583#[must_use]
585pub fn parse_pins(text: &str) -> BTreeMap<String, String> {
586 text.lines()
587 .filter_map(|l| {
588 let mut it = l.split_whitespace();
589 Some((it.next()?.to_string(), it.next()?.to_string()))
590 })
591 .collect()
592}
593
594fn parse_pins_strict(text: &str) -> Result<BTreeMap<String, String>> {
595 let mut out = BTreeMap::new();
596 for (idx, raw) in text.lines().enumerate() {
597 let line = raw.trim();
598 if line.is_empty() {
599 continue;
600 }
601 let mut it = line.split_whitespace();
602 let Some(voter) = it.next() else {
603 continue;
604 };
605 let key = it
606 .next()
607 .ok_or_else(|| anyhow::anyhow!("pin line {} must be `voter key`", idx + 1))?;
608 if it.next().is_some() {
609 bail!("pin line {} must be `voter key`", idx + 1);
610 }
611 if out.insert(voter.to_string(), key.to_string()).is_some() {
612 bail!("duplicate pin voter `{voter}`");
613 }
614 }
615 Ok(out)
616}
617
618pub fn pins_for_check() -> Result<(BTreeMap<String, String>, String)> {
624 match env_root() {
625 Some(root) => pins_for_check_in(&root),
626 None => Ok((BTreeMap::new(), NO_PIN_FILE.into())),
627 }
628}
629
630const NO_PIN_FILE: &str = "none: first-seen in this check";
631
632pub fn pins_for_check_in(root: &Path) -> Result<(BTreeMap<String, String>, String)> {
634 let path = root.join(PIN_FILE);
635 let text = match std::fs::read_to_string(&path) {
636 Ok(text) => text,
637 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
638 if std::fs::symlink_metadata(&path).is_ok() {
639 bail!("pin file {} is a link to a missing file", path.display());
640 }
641 return Ok((BTreeMap::new(), NO_PIN_FILE.into()));
642 }
643 Err(err) => return Err(err).with_context(|| format!("pin file {}", path.display())),
644 };
645 let pins = parse_pins_strict(&text).with_context(|| format!("pin file {}", path.display()))?;
646 Ok((pins, path.display().to_string()))
647}
648
649#[must_use]
651pub fn pins() -> BTreeMap<String, String> {
652 pin_path()
653 .and_then(|p| std::fs::read_to_string(p).ok())
654 .map(|t| parse_pins(&t))
655 .unwrap_or_default()
656}
657
658#[must_use]
660pub fn field(raw: &str) -> String {
661 let t: String = raw
662 .trim()
663 .chars()
664 .map(|c| {
665 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@') {
666 c
667 } else {
668 '_'
669 }
670 })
671 .collect();
672 if t.is_empty() {
673 "-".into()
674 } else {
675 t
676 }
677}
678
679#[derive(Debug, Clone, PartialEq, Eq)]
685pub struct SessionIdentity {
686 pub harness: String,
687 pub nonce: String,
688}
689
690pub const SEAT_NONCE: &str = "seat";
694
695impl SessionIdentity {
696 #[must_use]
698 pub fn seat_only(&self) -> bool {
699 self.nonce == SEAT_NONCE
700 }
701}
702
703#[must_use]
706pub fn unverified_sentence(seat: &str) -> String {
707 format!(
708 "unverified session: nothing named the run but seat {seat}, so every run of that seat is one session"
709 )
710}
711
712tokio::task_local! {
713 static CALL_SESSION: String;
714 static CALL_AGENT: String;
715}
716
717std::thread_local! {
718 static HOOK_SESSION: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
719}
720
721#[must_use]
723pub fn call_session() -> Option<String> {
724 CALL_SESSION
725 .try_with(|s| s.clone())
726 .ok()
727 .filter(|s| !s.is_empty())
728}
729
730pub async fn scope_session<F: std::future::Future>(
734 session: Option<String>,
735 agent: Option<String>,
736 f: F,
737) -> F::Output {
738 let agent = agent
739 .map(|a| field(&a))
740 .filter(|a| a != "-")
741 .unwrap_or_default();
742 let f = CALL_AGENT.scope(agent, f);
743 match session.map(|s| field(&s)).filter(|s| s != "-") {
744 Some(s) => CALL_SESSION.scope(s, f).await,
745 None => f.await,
746 }
747}
748
749static PROCESS_AGENT: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
750
751pub fn hook_agent(agent: Option<String>) {
753 let _ = PROCESS_AGENT.set(agent);
754}
755
756fn subagent(parent: &str) -> Option<String> {
761 if let Ok(agent) = CALL_AGENT.try_with(Clone::clone) {
762 return (!agent.is_empty()).then_some(agent);
763 }
764 PROCESS_AGENT
765 .get_or_init(|| {
766 crate::subagent::claim_command(parent)
767 .filter(|a| !a.is_empty())
768 .map(|a| field(&a))
769 .or_else(|| codex_thread(parent))
770 })
771 .clone()
772}
773
774static HOOKED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
775
776fn hooked_session() -> Option<String> {
781 if CALL_AGENT.try_with(|_| ()).is_ok() {
782 return None;
783 }
784 HOOKED
785 .get_or_init(|| {
786 let (session, agent) = crate::subagent::claim_unsessioned_command()?;
787 let (session, agent) = (field(&session), field(&agent));
788 Some(if agent == "-" {
789 session
790 } else {
791 format!("{session}:{agent}")
792 })
793 })
794 .clone()
795}
796
797fn codex_thread(parent: &str) -> Option<String> {
800 let root = env_token("CODEX_SESSION_ID")?;
801 let thread = env_token("CODEX_THREAD_ID")?;
802 (root == parent && thread != root).then_some(thread)
803}
804
805pub fn export_hook_session(id: Option<&str>) {
809 crate::with_env_mutation(|| {
810 let id = id.and_then(crate::usable_session_id).map(field);
811 HOOK_SESSION.with(|c| *c.borrow_mut() = id.clone());
812 if let Some(id) = id {
813 if std::env::var("LJOS_HARNESS_SESSION")
814 .ok()
815 .filter(|s| !s.trim().is_empty())
816 .is_none()
817 {
818 std::env::set_var("LJOS_HARNESS_SESSION", id);
819 }
820 }
821 });
822}
823
824fn env_token(key: &str) -> Option<String> {
825 let v = std::env::var(key).ok()?;
826 let v = v.trim();
827 if v.is_empty() {
828 return None;
829 }
830 Some(field(v))
831}
832
833static CONNECTION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
834
835pub fn pin_connection_session(hint: Option<&str>) {
840 let Some(chosen) = hint
841 .map(str::trim)
842 .filter(|s| !s.is_empty())
843 .map(field)
844 .filter(|s| s != "-")
845 .or_else(|| env_token("LJOS_HARNESS_SESSION"))
846 else {
847 return;
848 };
849 let _ = CONNECTION.set(chosen);
850}
851
852#[must_use]
855pub fn connection_session() -> Option<String> {
856 CONNECTION.get().cloned()
857}
858
859#[must_use]
864pub fn harness_session() -> String {
865 if let Some(s) = connection_session() {
866 return s;
867 }
868 if let Some(s) = call_session() {
869 return s;
870 }
871 if let Some(s) = HOOK_SESSION.with(|c| c.borrow().clone()) {
872 return s;
873 }
874 let stamped = crate::stamped_sessions();
875 const PREFER: &[&str] = &[
876 "CURSOR_SESSION_ID",
877 "CLAUDE_SESSION_ID",
878 "CODEX_SESSION_ID",
881 "CODEX_THREAD_ID",
882 "GROK_SESSION_ID",
883 "SEAT_SESSION_ID",
884 "LJOS_SESSION_ID",
885 "CLAUDE_CODE_SESSION_ID",
889 "OPENCODE_SESSION_ID",
890 ];
891 for key in PREFER {
892 if let Some(s) = stamped
893 .iter()
894 .find(|(k, _)| k == *key)
895 .map(|(_, v)| field(v))
896 .or_else(|| env_token(key))
897 {
898 if s.len() >= 8 && !(*key == "LJOS_SESSION_ID" && crate::is_shared_holder_id(&s)) {
901 return s;
902 }
903 }
904 }
905 if let Some(s) = crate::runner_session_id().map(|s| field(&s)) {
906 return s;
907 }
908 if let Some(s) = env_token("LJOS_HARNESS_SESSION") {
909 return s;
910 }
911 run_session().unwrap_or_default()
912}
913
914#[cfg(test)]
915static RUNNER_SESSIONS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
916
917#[cfg(test)]
920#[must_use]
921pub(crate) fn fake_runner_sessions(ids: &[&str]) -> impl Drop {
922 struct Reset;
923 impl Drop for Reset {
924 fn drop(&mut self) {
925 *RUNNER_SESSIONS
926 .lock()
927 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
928 }
929 }
930 *RUNNER_SESSIONS
931 .lock()
932 .unwrap_or_else(std::sync::PoisonError::into_inner) =
933 Some(ids.iter().map(|s| (*s).to_string()).collect());
934 Reset
935}
936
937#[must_use]
945pub fn runner_sessions() -> Vec<String> {
946 #[cfg(test)]
947 if let Some(ids) = RUNNER_SESSIONS
948 .lock()
949 .unwrap_or_else(std::sync::PoisonError::into_inner)
950 .clone()
951 {
952 return ids;
953 }
954 let Some(started) = crate::proc_tree::started_by_runner() else {
955 return Vec::new();
956 };
957 crate::proc_tree::environ(started)
958 .unwrap_or_default()
959 .into_iter()
960 .filter(|(k, v)| k == "LJOS_HARNESS_SESSION" || crate::runner_session_var(k, v))
961 .map(|(_, v)| field(v.trim()))
962 .filter(|v| !v.is_empty() && v != "-")
963 .collect()
964}
965
966fn run_session() -> Option<String> {
970 let actions = std::env::var("GITHUB_ACTIONS").is_ok_and(|v| {
971 let v = v.trim();
972 !v.is_empty() && !matches!(v, "0" | "false")
973 });
974 let ci: &[&str] = if actions { &["GITHUB_RUN_ID"] } else { &[] };
975 ci.iter()
976 .chain(&["CURSOR_AGENT_ID", "CLOUD_AGENT_ID"])
977 .filter_map(|key| env_token(key))
978 .find(|s| s.len() >= 8)
979}
980
981fn random_hex(n: usize) -> String {
982 use std::io::Read;
983 let mut buf = vec![0u8; n];
984 if std::fs::File::open("/dev/urandom")
985 .and_then(|mut f| f.read_exact(&mut buf))
986 .is_ok()
987 {
988 return hex::encode(buf);
989 }
990 format!("{:x}{:x}", std::process::id(), std::process::id())
991}
992
993static PROCESS_NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
994
995#[must_use]
998pub fn process_nonce() -> String {
999 if let Some(s) = env_token("LJOS_PROCESS_NONCE") {
1000 if s != "-" {
1001 return s;
1002 }
1003 }
1004 PROCESS_NONCE.get_or_init(|| random_hex(16)).clone()
1005}
1006
1007fn named_session_actor(key: &str) -> Option<String> {
1009 let name = env_token(key)?;
1010 let lower = name.to_ascii_lowercase();
1011 if matches!(lower.as_str(), "seat" | "you" | "agent" | "runner" | "-") {
1012 return None;
1013 }
1014 Some(name)
1015}
1016
1017#[must_use]
1022pub fn identity() -> SessionIdentity {
1023 let harness = harness_session();
1024 if !harness.is_empty() {
1025 let harness = match subagent(&harness) {
1026 Some(agent) => format!("{harness}:{agent}"),
1027 None => harness,
1028 };
1029 return SessionIdentity {
1030 harness,
1031 nonce: "-".into(),
1032 };
1033 }
1034 if let Some(harness) = hooked_session() {
1035 return SessionIdentity {
1036 harness,
1037 nonce: "-".into(),
1038 };
1039 }
1040 match named_session_actor("VISSUE_AGENT").or_else(|| named_session_actor("LJOS_SEAT")) {
1041 Some(seat) => SessionIdentity {
1042 harness: seat,
1043 nonce: SEAT_NONCE.into(),
1044 },
1045 None => SessionIdentity {
1046 harness: "-".into(),
1047 nonce: "-".into(),
1048 },
1049 }
1050}
1051
1052fn signing_key(voter: &str) -> Result<(SigningKey, String)> {
1054 let path = seat_key_path(voter)
1055 .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"))?;
1056 let key = load_or_create(&path)?;
1057 let mine = hex::encode(key.verifying_key().to_bytes());
1058 let pinned = match pin_path() {
1059 Some(p) if !pins().contains_key(voter.trim()) => pin_first(&p, voter.trim(), &mine)?,
1060 _ => pins(),
1061 };
1062 let mine_fp = key_fingerprint(&mine);
1063 if let Some(k) = pinned
1064 .get(voter.trim())
1065 .filter(|k| key_fingerprint(k) != mine_fp)
1066 {
1067 bail!(
1068 "seat {voter} is pinned to key {}…, and this key is {}…; the ballot is unsigned and no gate counts it",
1069 &k[..12.min(k.len())],
1070 &mine[..12]
1071 );
1072 }
1073 Ok((key, mine))
1074}
1075
1076fn pin_first(path: &Path, voter: &str, mine: &str) -> Result<BTreeMap<String, String>> {
1082 let _held = crate::tracker_commit_lock(path.parent().unwrap_or_else(|| Path::new(".")));
1083 let mut pinned = std::fs::read_to_string(path)
1084 .map(|t| parse_pins(&t))
1085 .unwrap_or_default();
1086 if !pinned.contains_key(voter) {
1087 pinned.insert(voter.to_string(), mine.to_string());
1088 let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
1089 vissue_core::store::replace_file_atomically(path, &text)?;
1090 }
1091 Ok(pinned)
1092}
1093
1094pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
1104 if let Some((existing, withdrawn)) = newest_sig(issue, voter) {
1105 if verifies(issue, &existing) {
1106 match resign_of(&existing, choice, voter, withdrawn) {
1107 Resign::Keep => {
1108 return Ok(format!("ballot already signed by {voter}\n"));
1109 }
1110 Resign::Refuse => {
1111 bail!("will not re-sign {voter}'s ballot");
1112 }
1113 Resign::Sign => {}
1114 }
1115 }
1116 }
1117 let (key, mine) = signing_key(voter)?;
1118 let id = identity();
1119 let note = sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce);
1120 if write_sig_note(issue, ¬e).is_err() {
1121 crate::run_captured("vissue", &["note", issue, ¬e])?;
1122 }
1123 let session = if id.harness == "-" {
1124 format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
1125 } else {
1126 id.harness.clone()
1127 };
1128 let mut said = format!(
1129 "ballot signed by {voter} with key {}… session {session}\n",
1130 &mine[..12.min(mine.len())]
1131 );
1132 if id.seat_only() {
1133 said.push_str(&format!("{}\n", unverified_sentence(&id.harness)));
1134 }
1135 Ok(said)
1136}
1137
1138enum Resign {
1139 Keep,
1140 Refuse,
1141 Sign,
1142}
1143
1144fn resign_of(existing: &SigNote, choice: &str, voter: &str, withdrawn: bool) -> Resign {
1149 let mine = current_public_hex(voter);
1150 if mine.as_deref() == Some(existing.key.as_str()) {
1151 if existing.choice == choice && !withdrawn {
1152 Resign::Keep
1153 } else {
1154 Resign::Sign
1155 }
1156 } else {
1157 Resign::Refuse
1158 }
1159}
1160
1161fn current_public_hex(voter: &str) -> Option<String> {
1162 let path = seat_key_path(voter)?;
1163 let key = key_from_file(&path).ok()?;
1164 Some(hex::encode(key.verifying_key().to_bytes()))
1165}
1166
1167fn same_voter(signed: &str, voter: &str) -> bool {
1168 signed == voter.trim() || signed == field(voter)
1169}
1170
1171fn newest_sig(issue: &str, voter: &str) -> Option<(SigNote, bool)> {
1172 let layout = vissue_core::Layout::resolve(None, None).ok()?;
1173 let (heading, _, _) = vissue_core::store::find_by_id(&layout, issue).ok()??;
1174 let mut cut = None;
1175 let mut newest = None;
1176 for (at, entry) in heading.logbook.iter().enumerate() {
1177 let Some(text) = entry.note.as_deref() else {
1178 continue;
1179 };
1180 if cut.is_none() {
1181 if let Some(withdrawn) = parse_withdrawn(text) {
1182 if same_voter(&withdrawn.voter, voter) {
1183 cut = Some(at);
1184 }
1185 }
1186 }
1187 if newest.is_none() {
1188 if let Some(sig) = parse_note(text) {
1189 if same_voter(&sig.voter, voter) {
1190 newest = Some((at, sig));
1191 }
1192 }
1193 }
1194 if cut.is_some() && newest.is_some() {
1195 break;
1196 }
1197 }
1198 let (at, sig) = newest?;
1199 let withdrawn = cut.is_some_and(|cut_at| cut_at < at);
1200 Some((sig, withdrawn))
1201}
1202
1203pub(crate) fn write_sig_note(issue: &str, note: &str) -> Result<()> {
1204 let layout = vissue_core::Layout::resolve(None, None)?;
1205 vissue_core::ops::note(&layout, issue, note)?;
1206 Ok(())
1207}
1208
1209pub fn sign_own_ballot(issue: &str) -> Result<String> {
1215 let Some(voter) = own_voter() else {
1216 return Ok(String::new());
1217 };
1218 let layout = vissue_core::Layout::resolve(None, None)?;
1219 let ballots = vissue_core::ops::ballots(&layout, issue)?;
1220 let Some(choice) = ballots
1221 .iter()
1222 .find(|b| b.agent == voter)
1223 .map(|b| b.choice.clone())
1224 else {
1225 return Ok(String::new());
1226 };
1227 sign_ballot(issue, &voter, &choice)
1228}
1229
1230#[must_use]
1233pub fn own_voter() -> Option<String> {
1234 crate::checked_seat().or_else(|| {
1235 ["VISSUE_AGENT", "LJOS_SEAT"].iter().find_map(|key| {
1236 std::env::var(key)
1237 .ok()
1238 .map(|s| s.trim().to_string())
1239 .filter(|s| !s.is_empty())
1240 })
1241 })
1242}
1243
1244pub fn note_withdrawn(issue: &str, voter: &str) -> Result<String> {
1250 let id = identity();
1251 let note = withdrawn_line(voter, &id.harness, &id.nonce);
1252 crate::run_captured("vissue", &["note", issue, ¬e])
1253 .with_context(|| format!("ballot withdrawn; the note on {issue} was not written"))?;
1254 Ok(note)
1255}
1256
1257pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
1259
1260#[must_use]
1263pub fn candidate_message(
1264 issue: &str,
1265 seat: &str,
1266 harness: &str,
1267 nonce: &str,
1268 saw: &str,
1269) -> Vec<u8> {
1270 format!(
1271 "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
1272 issue.trim(),
1273 seat.trim(),
1274 harness.trim(),
1275 nonce.trim(),
1276 saw.trim()
1277 )
1278 .into_bytes()
1279}
1280
1281#[derive(Debug, Clone, PartialEq, Eq)]
1283pub struct CandidateNote {
1284 pub issue: String,
1285 pub seat: String,
1286 pub harness: String,
1287 pub nonce: String,
1288 pub saw: Vec<String>,
1289 pub key: String,
1290 pub sig: String,
1291}
1292
1293fn saw_token(saw: &[String]) -> String {
1294 if saw.is_empty() {
1295 "-".into()
1296 } else {
1297 saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
1298 }
1299}
1300
1301fn saw_list(token: &str) -> Vec<String> {
1302 if token.is_empty() || token == "-" {
1303 Vec::new()
1304 } else {
1305 token
1306 .split(',')
1307 .map(str::trim)
1308 .filter(|s| !s.is_empty() && *s != "-")
1309 .map(str::to_string)
1310 .collect()
1311 }
1312}
1313
1314#[must_use]
1316pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
1317 let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
1318 let parts: Vec<&str> = rest.split_whitespace().collect();
1319 let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
1320 return None;
1321 };
1322 Some(CandidateNote {
1323 issue: (*issue).to_string(),
1324 seat: (*seat).to_string(),
1325 harness: (*harness).to_string(),
1326 nonce: (*nonce).to_string(),
1327 saw: saw_list(saw),
1328 key: (*key).to_string(),
1329 sig: (*sig).to_string(),
1330 })
1331}
1332
1333#[must_use]
1335pub fn candidate_verifies(note: &CandidateNote) -> bool {
1336 signature_ok(
1337 ¬e.key,
1338 ¬e.sig,
1339 &candidate_message(
1340 ¬e.issue,
1341 ¬e.seat,
1342 ¬e.harness,
1343 ¬e.nonce,
1344 &saw_token(¬e.saw),
1345 ),
1346 )
1347}
1348
1349pub fn sign_candidate(
1355 issue: &str,
1356 seat: &str,
1357 id: &SessionIdentity,
1358 saw: &[String],
1359) -> Result<String> {
1360 let (key, _) = signing_key(seat)?;
1361 let harness = field(&id.harness);
1362 let nonce = field(&id.nonce);
1363 let saw_tok = saw_token(saw);
1364 let (public, sig) = signed_hex(
1365 &key,
1366 &candidate_message(issue, seat, &harness, &nonce, &saw_tok),
1367 );
1368 Ok(format!(
1369 "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
1370 field(issue),
1371 field(seat),
1372 harness,
1373 nonce,
1374 saw_tok,
1375 public,
1376 sig
1377 ))
1378}
1379
1380#[must_use]
1384pub fn seat_problems(
1385 issue: &str,
1386 seats: &[(&str, &str)],
1387 notes: &[String],
1388 pins: &BTreeMap<String, String>,
1389) -> Vec<String> {
1390 seat_check(issue, seats, notes, pins, &|_, _| false).0
1391}
1392
1393#[must_use]
1397pub fn seat_check(
1398 issue: &str,
1399 seats: &[(&str, &str)],
1400 notes: &[String],
1401 pins: &BTreeMap<String, String>,
1402 legacy: &dyn Fn(&str, &str) -> bool,
1403) -> (Vec<String>, Vec<String>) {
1404 let mut out = Vec::new();
1405 let mut old = Vec::new();
1406 for (voter, choice) in seats {
1407 let newest = notes
1408 .iter()
1409 .filter_map(|n| parse_note(n))
1410 .find(|n| n.voter == *voter);
1411 match newest {
1412 None if legacy(voter, choice) => old.push((*voter).to_string()),
1413 None => out.push(format!("{voter}'s ballot is unsigned")),
1414 Some(n) if n.choice != *choice => out.push(format!(
1415 "{voter}'s signature is for {} and the ballot says {choice}",
1416 n.choice
1417 )),
1418 Some(n) if !verifies(issue, &n) => {
1419 out.push(format!("{voter}'s signature does not verify"));
1420 }
1421 Some(n) => match pins.get(*voter) {
1422 Some(k) if key_fingerprint(k) == key_fingerprint(&n.key) => {}
1423 Some(_) => out.push(format!(
1424 "{voter}'s ballot is signed by a key other than its pinned one"
1425 )),
1426 None => out.push(format!("{voter} has no pinned key on this tracker")),
1427 },
1428 }
1429 }
1430 (out, old)
1431}
1432
1433#[must_use]
1435pub fn notes_of(v: &Value) -> Vec<String> {
1436 v["logbook"]
1437 .as_array()
1438 .into_iter()
1439 .flatten()
1440 .filter_map(|e| e["note"].as_str().map(str::to_string))
1441 .collect()
1442}
1443
1444#[must_use]
1447pub fn unsigned_allowed() -> bool {
1448 std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454 use std::path::Path;
1455 use std::process::Command;
1456
1457 fn key(b: u8) -> SigningKey {
1458 SigningKey::from_bytes(&[b; 32])
1459 }
1460
1461 fn git(dir: &Path, args: &[&str], env: &[(&str, String)]) {
1462 let mut cmd = Command::new("git");
1463 cmd.arg("-C").arg(dir).args(args);
1464 for (k, v) in env {
1465 cmd.env(k, v);
1466 }
1467 let out = cmd.output().unwrap();
1468 assert!(
1469 out.status.success(),
1470 "git {:?} failed: {}",
1471 args,
1472 String::from_utf8_lossy(&out.stderr)
1473 );
1474 }
1475
1476 #[test]
1477 fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
1478 let alice = key(1);
1479 let mallory = key(2);
1480 let pins: BTreeMap<String, String> = [(
1481 "alice".to_string(),
1482 hex::encode(alice.verifying_key().to_bytes()),
1483 )]
1484 .into();
1485 let good = sign_with(&alice, "d-1", "alice", "ship");
1486 assert!(seat_problems(
1487 "d-1",
1488 &[("alice", "ship")],
1489 std::slice::from_ref(&good),
1490 &pins
1491 )
1492 .is_empty());
1493 let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
1495 assert!(p[0].contains("unsigned"), "{p:?}");
1496 let forged = sign_with(&mallory, "d-1", "alice", "ship");
1498 let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
1499 assert!(p[0].contains("other than its pinned"), "{p:?}");
1500 let p = seat_problems(
1502 "d-1",
1503 &[("alice", "hold")],
1504 std::slice::from_ref(&good),
1505 &pins,
1506 );
1507 assert!(p[0].contains("is for ship"), "{p:?}");
1508 let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
1510 assert!(p[0].contains("does not verify"), "{p:?}");
1511 let lone = sign_with(&mallory, "d-1", "bob", "ship");
1513 let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
1514 assert!(p[0].contains("no pinned key"), "{p:?}");
1515 }
1516
1517 #[test]
1518 fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
1519 let pins = BTreeMap::new();
1520 let legacy = |v: &str, _: &str| v == "inky";
1521 let (bad, old) = seat_check(
1522 "d-1",
1523 &[("inky", "ship"), ("bob", "ship")],
1524 &[],
1525 &pins,
1526 &legacy,
1527 );
1528 assert_eq!(old, vec!["inky".to_string()]);
1529 assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
1530 }
1531
1532 #[test]
1533 fn seats_sharing_a_home_get_their_own_keys() {
1534 let dir = tempfile::tempdir().unwrap();
1535 let a = dir.path().join("ljos/seats/inky/ballot.key");
1536 let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
1537 let ka = load_or_create(&a).unwrap();
1538 let kb = load_or_create(&b).unwrap();
1539 assert_ne!(ka.verifying_key(), kb.verifying_key());
1540 assert_eq!(
1541 load_or_create(&a).unwrap().verifying_key(),
1542 ka.verifying_key()
1543 );
1544 use std::os::unix::fs::PermissionsExt;
1545 assert_eq!(
1546 std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
1547 0o600
1548 );
1549 }
1550
1551 #[test]
1552 fn a_key_at_its_folded_name_moves_to_the_seat_file() {
1553 let _lock = crate::test_env_lock();
1554 let data = tempfile::tempdir().unwrap();
1555 crate::with_env_mutation(|| {
1556 unsafe { std::env::set_var("XDG_DATA_HOME", data.path()) };
1558 });
1559 let folded = data.path().join("ljos/seats/judge_model-1.5/ballot.key");
1560 std::fs::create_dir_all(folded.parent().unwrap()).unwrap();
1561 std::fs::write(&folded, [7u8; 32]).unwrap();
1562 let path = seat_key_file("judge:model-1.5").unwrap();
1563 assert_ne!(path, folded);
1564 assert_eq!(std::fs::read(&path).unwrap(), vec![7u8; 32]);
1565 assert!(!folded.exists());
1566 assert_eq!(seat_key_file("judge:model-1.5").unwrap(), path);
1567 let slug = seat_key_file("disk-saver").unwrap();
1568 assert_eq!(slug, data.path().join("ljos/seats/disk-saver/ballot.key"));
1569 }
1570
1571 #[test]
1572 fn the_newest_signature_wins() {
1573 let a = key(3);
1574 let pins: BTreeMap<String, String> =
1575 [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
1576 let new = sign_with(&a, "d-1", "a", "hold");
1577 let old = sign_with(&a, "d-1", "a", "ship");
1578 assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
1579 }
1580
1581 #[test]
1582 fn a_choice_with_a_space_parses_whole_and_verifies() {
1583 let k = key(5);
1586 let bound = sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
1587 let parsed = parse_note(&bound).expect("a bound note with a spaced choice");
1588 assert_eq!(parsed.voter, "seat-a");
1589 assert_eq!(parsed.choice, "beside binary");
1590 assert_eq!(parsed.harness.as_deref(), Some("spaced-1"));
1591 assert_eq!(parsed.nonce.as_deref(), Some("-"));
1592 assert!(verifies("panel-spcd", &parsed));
1593 assert_eq!(
1594 note_session(&bound),
1595 Some(("spaced-1".to_string(), "-".to_string()))
1596 );
1597 let plain = sign_with(&k, "panel-spcd", "seat-a", "xdg home dir");
1599 let parsed = parse_note(&plain).expect("a v1 note with a spaced choice");
1600 assert_eq!(parsed.choice, "xdg home dir");
1601 assert_eq!(parsed.harness, None);
1602 assert!(verifies("panel-spcd", &parsed));
1603 let wide = sign_bound(&k, "panel-spcd", "seat-a", "xdg home", "spaced-1", "-");
1605 let parsed = parse_note(&wide).unwrap();
1606 assert_eq!(parsed.choice, "xdg home");
1607 assert!(verifies("panel-spcd", &parsed));
1608 let pins: BTreeMap<String, String> = [(
1609 "seat-a".to_string(),
1610 hex::encode(k.verifying_key().to_bytes()),
1611 )]
1612 .into();
1613 assert!(seat_problems(
1614 "panel-spcd",
1615 &[("seat-a", "beside binary")],
1616 &[bound],
1617 &pins
1618 )
1619 .is_empty());
1620 }
1621
1622 #[test]
1623 fn a_note_that_does_not_parse_still_names_its_session() {
1624 let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -";
1625 assert_eq!(parse_note(odd), None);
1626 assert_eq!(
1627 note_session(odd),
1628 Some(("spaced-1".to_string(), "-".to_string()))
1629 );
1630 assert_eq!(note_session("ballot-sig: seat-a ship ab"), None);
1631 assert_eq!(note_session("finding: seat-a: nit: name the unit"), None);
1632 }
1633
1634 #[test]
1635 fn a_withdrawn_note_is_three_fields() {
1636 let line = withdrawn_line("seat a", "sess-1", "-");
1637 assert_eq!(line, "ballot-withdrawn: seat_a sess-1 -");
1638 let parsed = parse_withdrawn(&line).unwrap();
1639 assert_eq!(parsed.voter, "seat_a");
1640 assert_eq!(parsed.harness, "sess-1");
1641 assert_eq!(parsed.nonce, "-");
1642 assert!(parse_withdrawn("ballot-withdrawn: only-one").is_none());
1643 assert!(parse_withdrawn("ballot-sig: seat-a ship").is_none());
1644 }
1645
1646 #[test]
1647 fn a_session_binding_is_signed_and_a_changed_session_is_not() {
1648 let alice = key(4);
1649 let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
1650 let parsed = parse_note(¬e).unwrap();
1651 assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
1652 assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
1653 assert!(verifies("d-1", &parsed));
1654 let mut moved = parsed.clone();
1655 moved.harness = Some("sess-other-0001".into());
1656 assert!(
1657 !verifies("d-1", &moved),
1658 "a rewritten session still verified"
1659 );
1660 let pins: BTreeMap<String, String> = [(
1661 "alice".to_string(),
1662 hex::encode(alice.verifying_key().to_bytes()),
1663 )]
1664 .into();
1665 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
1666 }
1667
1668 #[test]
1669 fn a_legacy_ballot_is_read_on_its_own_issue() {
1670 let _lock = crate::test_env_lock();
1671 let dir = tempfile::tempdir().unwrap();
1672 let file = dir.path().join("issues.org");
1673 git(dir.path(), &["init"], &[]);
1674 git(dir.path(), &["config", "user.name", "Rohit Goswami"], &[]);
1675 git(
1676 dir.path(),
1677 &["config", "user.email", "rgoswami@ieee.org"],
1678 &[],
1679 );
1680 std::fs::write(
1681 &file,
1682 "* TODO [#C] old\n:PROPERTIES:\n:ID: probe-old\n:END:\n- [X] elder: rail\n",
1683 )
1684 .unwrap();
1685 git(dir.path(), &["add", "issues.org"], &[]);
1686 git(dir.path(), &["commit", "-m", "old ballot"], &[]);
1687 std::fs::write(dir.path().join(PIN_FILE), "inky 00\n").unwrap();
1688 git(dir.path(), &["add", PIN_FILE], &[]);
1689 git(dir.path(), &["commit", "-m", "first pin"], &[]);
1690 std::fs::write(
1691 &file,
1692 "* TODO [#C] old\n:PROPERTIES:\n:ID: probe-old\n:END:\n- [X] elder: rail\n\
1693 * TODO [#C] new\n:PROPERTIES:\n:ID: probe-new\n:END:\n- [X] elder: rail\n",
1694 )
1695 .unwrap();
1696 git(dir.path(), &["add", "issues.org"], &[]);
1697 git(dir.path(), &["commit", "-m", "new ballot"], &[]);
1698 assert!(recorded_before_signing_in(dir.path(), "probe-old", "elder", "rail").unwrap());
1699 assert!(!recorded_before_signing_in(dir.path(), "probe-new", "elder", "rail").unwrap());
1700 }
1701
1702 #[test]
1703 fn seat_check_accepts_the_same_key_in_uppercase_hex() {
1704 let alice = key(8);
1705 let note = sign_with(&alice, "d-1", "alice", "ship");
1706 let pins: BTreeMap<String, String> = [(
1707 "alice".to_string(),
1708 hex::encode(alice.verifying_key().to_bytes()).to_ascii_uppercase(),
1709 )]
1710 .into();
1711 assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
1712 }
1713
1714 #[tokio::test]
1718 async fn an_mcp_call_from_a_subagent_is_a_session_of_its_own() {
1719 let call = |agent: Option<&str>| {
1720 scope_session(
1721 Some("sess-mcp-0001".into()),
1722 agent.map(str::to_string),
1723 async { identity().harness },
1724 )
1725 };
1726 assert_eq!(call(Some("thread-a")).await, "sess-mcp-0001:thread-a");
1727 assert_eq!(call(Some("thread-b")).await, "sess-mcp-0001:thread-b");
1728 assert_eq!(call(None).await, "sess-mcp-0001");
1729 }
1730}