1use std::{
2 collections::{BTreeMap, BTreeSet},
3 env,
4 fs::{self, File},
5 io::Write,
6 path::{Path, PathBuf},
7 sync::atomic::{AtomicU64, Ordering},
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11use anyhow::{anyhow, bail, Context, Result};
12use chrono::{DateTime, Duration, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16use crate::{
17 activity::{ActivityState, ActivityStateValue},
18 cli::{AnnotateArgs, CurrentArgs, DiscoverArgs, LookupArgs, PruneArgs, RegisterArgs},
19 names,
20};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct ActivitySummary {
24 pub text: String,
25 pub updated_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ExtensionMetadata {
30 pub data: serde_json::Value,
31 pub updated_at: DateTime<Utc>,
32}
33
34#[derive(Debug, Clone)]
35pub struct ActivityUpdate {
36 pub summary: Option<String>,
37 pub clear_summary: bool,
38 pub state: Option<ActivityStateValue>,
39 pub clear_state: bool,
40 pub cwd: Option<String>,
41 pub clear_cwd: bool,
42 pub extensions: BTreeMap<String, serde_json::Value>,
43 pub clear_extensions: BTreeSet<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct Assignment {
48 pub version: u8,
49 pub session_id: String,
50 pub name: String,
51 pub slug: String,
52 pub first_name: String,
53 pub family_name: String,
54 pub realm: String,
55 #[serde(default)]
56 pub summary: Option<ActivitySummary>,
57 #[serde(default)]
58 pub state: Option<ActivityState>,
59 #[serde(default)]
60 pub cwd: Option<String>,
61 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
62 pub extensions: BTreeMap<String, ExtensionMetadata>,
63 pub created_at: DateTime<Utc>,
64 pub updated_at: DateTime<Utc>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct PrunedIdentity {
69 pub session_id: String,
70 pub name: String,
71 pub slug: String,
72 pub updated_at: DateTime<Utc>,
73 pub claim_removed: bool,
74}
75
76#[derive(Debug, Clone, Serialize)]
77pub struct PruneReport {
78 pub cutoff: DateTime<Utc>,
79 pub dry_run: bool,
80 pub candidates: Vec<Assignment>,
81 pub removed: Vec<PrunedIdentity>,
82 pub errors: Vec<String>,
83}
84
85#[derive(Debug, Clone)]
86pub struct Registry {
87 root: PathBuf,
88}
89
90impl Registry {
91 pub fn from_env() -> Result<Self> {
92 let root = if let Some(path) = env::var_os("AGENT_ID_HOME") {
93 PathBuf::from(path)
94 } else if let Some(path) = env::var_os("XDG_DATA_HOME") {
95 PathBuf::from(path).join("agent-id")
96 } else {
97 home_dir()?.join(".local/share/agent-id")
98 };
99
100 Ok(Self::new(root))
101 }
102
103 pub fn new(root: PathBuf) -> Self {
104 Self { root }
105 }
106
107 pub fn register(
108 &self,
109 session_id: &str,
110 family_name: Option<&str>,
111 realm: &str,
112 ) -> Result<Assignment> {
113 let session_id = validate_session_id(session_id)?;
114 let session_path = self.session_path(&session_id);
115 if session_path.exists() {
116 let mut existing = read_assignment(&session_path)
117 .with_context(|| format!("read existing assignment {}", session_path.display()))?;
118 existing.updated_at = Utc::now();
119 replace_assignment(&session_path, &existing)?;
120 return Ok(existing);
121 }
122
123 let realm = normalize_component(realm, "realm")?;
124 let requested_family = family_name
125 .map(|value| normalize_component(value, "family name"))
126 .transpose()?;
127 let first_names = names::first_names();
128 let family_names = names::family_names();
129 if first_names.is_empty() || family_names.is_empty() {
130 bail!("name lists are empty");
131 }
132
133 fs::create_dir_all(self.root.join("by-session"))
134 .with_context(|| format!("create {}", self.root.display()))?;
135 fs::create_dir_all(self.root.join("by-name"))
136 .with_context(|| format!("create {}", self.root.display()))?;
137
138 for attempt in 0..100_000_u64 {
139 let (first, family) = candidate(
140 &session_id,
141 attempt,
142 &first_names,
143 &family_names,
144 requested_family.as_deref(),
145 );
146 if first == family {
147 continue;
148 }
149
150 let first_name = title_word(first);
151 let family_name = title_word(&family);
152
153 let name = format!("{first_name} {family_name} of {realm}");
154 let slug = slug(&first_name, &family_name, &realm);
155 let claim_path = self.root.join("by-name").join(&slug);
156
157 match claim_name(&claim_path, &session_id)? {
158 Claim::Owned => {}
159 Claim::Claimed => {}
160 Claim::Other => continue,
161 }
162
163 let now = Utc::now();
164 let assignment = Assignment {
165 version: 1,
166 session_id: session_id.clone(),
167 name,
168 slug,
169 first_name,
170 family_name,
171 realm: realm.clone(),
172 state: None,
173 cwd: None,
174 summary: None,
175 extensions: BTreeMap::new(),
176 created_at: now,
177 updated_at: now,
178 };
179 write_assignment(&session_path, &assignment)?;
180 return Ok(assignment);
181 }
182
183 bail!("exhausted available names for realm {realm}")
184 }
185
186 pub fn lookup(&self, input: &str) -> Result<Assignment> {
187 let input = require_nonempty(input, "lookup identifier")?;
188 if let Ok(session_id) = validate_session_id(&input) {
189 let session_path = self.session_path(&session_id);
190 if session_path.exists() {
191 return self.lookup_session(&session_id);
192 }
193 }
194
195 for slug in lookup_slugs(&input) {
196 let claim_path = self.root.join("by-name").join(&slug);
197 if !claim_path.is_file() {
198 continue;
199 }
200
201 let session_id = require_nonempty(
202 &fs::read_to_string(&claim_path)
203 .with_context(|| format!("read name claim {}", claim_path.display()))?,
204 "claimed session ID",
205 )?;
206 return self
207 .lookup_session(&session_id)
208 .with_context(|| format!("resolve name claim {slug}"));
209 }
210
211 bail!(
212 "no identity found for '{input}'; lookup accepts a session ID, canonical name, or slug"
213 )
214 }
215
216 fn lookup_session(&self, session_id: &str) -> Result<Assignment> {
217 let session_id = validate_session_id(session_id)?;
218 let path = self.session_path(&session_id);
219 if !path.exists() {
220 bail!(
221 "no identity registered for session {session_id}; run `agent-id register {session_id}`"
222 );
223 }
224
225 let assignment = read_assignment(&path)
226 .with_context(|| format!("read assignment for session {session_id}"))?;
227 if assignment.session_id != session_id {
228 bail!("identity registry entry does not belong to session {session_id}");
229 }
230 Ok(assignment)
231 }
232
233 pub fn annotate(&self, session_id: &str, update: ActivityUpdate) -> Result<Assignment> {
234 let session_id = validate_session_id(session_id)?;
235 if update.summary.is_none()
236 && !update.clear_summary
237 && update.state.is_none()
238 && !update.clear_state
239 && update.cwd.is_none()
240 && !update.clear_cwd
241 && update.extensions.is_empty()
242 && update.clear_extensions.is_empty()
243 {
244 bail!("pass at least one activity update");
245 }
246 if update.summary.is_some() && update.clear_summary {
247 bail!("summary and clear_summary are mutually exclusive");
248 }
249 if update.state.is_some() && update.clear_state {
250 bail!("state and clear_state are mutually exclusive");
251 }
252 if update.cwd.is_some() && update.clear_cwd {
253 bail!("cwd and clear_cwd are mutually exclusive");
254 }
255 if let Some(owner) = update
256 .extensions
257 .keys()
258 .find(|owner| update.clear_extensions.contains(*owner))
259 {
260 bail!("extension {owner} cannot be set and cleared together");
261 }
262
263 let mut assignment = self.lookup_session(&session_id)?;
264 let summary = update
265 .summary
266 .as_deref()
267 .map(normalize_summary)
268 .transpose()?;
269 let cwd = update.cwd.as_deref().map(normalize_cwd).transpose()?;
270 let now = Utc::now();
271 if update.summary.is_some() {
272 assignment.summary = summary.map(|text| ActivitySummary {
273 text,
274 updated_at: now,
275 });
276 } else if update.clear_summary {
277 assignment.summary = None;
278 }
279 if let Some(value) = update.state {
280 assignment.state = Some(ActivityState {
281 value,
282 updated_at: now,
283 });
284 } else if update.clear_state {
285 assignment.state = None;
286 }
287 if update.cwd.is_some() {
288 assignment.cwd = cwd;
289 } else if update.clear_cwd {
290 assignment.cwd = None;
291 }
292 for (owner, data) in update.extensions {
293 assignment.extensions.insert(
294 owner,
295 ExtensionMetadata {
296 data,
297 updated_at: now,
298 },
299 );
300 }
301 for owner in update.clear_extensions {
302 assignment.extensions.remove(&owner);
303 }
304 assignment.updated_at = now;
305 replace_assignment(&self.session_path(&session_id), &assignment)?;
306 Ok(assignment)
307 }
308
309 pub fn discover(
310 &self,
311 limit: usize,
312 recent_hours: Option<i64>,
313 realm: Option<&str>,
314 include_stopped: bool,
315 ) -> Result<Vec<Assignment>> {
316 if recent_hours.is_some_and(|hours| hours < 0) {
317 bail!("--recent must be non-negative");
318 }
319 let realm = realm
320 .map(|value| normalize_component(value, "realm"))
321 .transpose()?;
322 let cutoff = recent_hours.map(|hours| Utc::now() - Duration::hours(hours));
323 let path = self.root.join("by-session");
324 let entries = match fs::read_dir(&path) {
325 Ok(entries) => entries,
326 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
327 Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
328 };
329 let mut assignments = Vec::new();
330 for entry in entries {
331 let path = entry?.path();
332 if path.extension().and_then(|value| value.to_str()) != Some("json") {
333 continue;
334 }
335 let assignment = read_assignment(&path)
336 .with_context(|| format!("read assignment {}", path.display()))?;
337 if realm
338 .as_deref()
339 .is_some_and(|value| value != assignment.realm)
340 {
341 continue;
342 }
343 if !include_stopped
344 && assignment
345 .state
346 .as_ref()
347 .is_some_and(|state| state.value == ActivityStateValue::Stopped)
348 {
349 continue;
350 }
351 if cutoff.is_some_and(|value| assignment.updated_at < value) {
352 continue;
353 }
354 assignments.push(assignment);
355 }
356 assignments.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
357 if limit > 0 {
358 assignments.truncate(limit);
359 }
360 Ok(assignments)
361 }
362
363 pub fn prune(&self, cutoff: DateTime<Utc>, dry_run: bool) -> Result<PruneReport> {
364 let path = self.root.join("by-session");
365 let entries = match fs::read_dir(&path) {
366 Ok(entries) => entries,
367 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
368 return Ok(PruneReport {
369 cutoff,
370 dry_run,
371 candidates: Vec::new(),
372 removed: Vec::new(),
373 errors: Vec::new(),
374 })
375 }
376 Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
377 };
378 let mut candidates = Vec::new();
379 for entry in entries {
380 let path = entry?.path();
381 if path.extension().and_then(|value| value.to_str()) != Some("json") {
382 continue;
383 }
384 let assignment = read_assignment(&path)
385 .with_context(|| format!("read assignment {}", path.display()))?;
386 if assignment.updated_at < cutoff {
387 candidates.push(assignment);
388 }
389 }
390 candidates.sort_by(|left, right| left.updated_at.cmp(&right.updated_at));
391 let mut report = PruneReport {
392 cutoff,
393 dry_run,
394 candidates,
395 removed: Vec::new(),
396 errors: Vec::new(),
397 };
398 if dry_run {
399 return Ok(report);
400 }
401
402 for assignment in &report.candidates {
403 let session_path = self.session_path(&assignment.session_id);
404 if let Err(error) = fs::remove_file(&session_path) {
405 report
406 .errors
407 .push(format!("remove {}: {error}", session_path.display()));
408 continue;
409 }
410
411 let claim_path = self.root.join("by-name").join(&assignment.slug);
412 let claim_removed = match fs::read_to_string(&claim_path) {
413 Ok(owner) if owner.trim() == assignment.session_id => {
414 if let Err(error) = fs::remove_file(&claim_path) {
415 report.errors.push(format!(
416 "remove name claim {}: {error}",
417 claim_path.display()
418 ));
419 false
420 } else {
421 true
422 }
423 }
424 Ok(owner) => {
425 report.errors.push(format!(
426 "name claim {} belongs to {}, not {}",
427 claim_path.display(),
428 owner.trim(),
429 assignment.session_id
430 ));
431 false
432 }
433 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
434 Err(error) => {
435 report
436 .errors
437 .push(format!("read name claim {}: {error}", claim_path.display()));
438 false
439 }
440 };
441 report.removed.push(PrunedIdentity {
442 session_id: assignment.session_id.clone(),
443 name: assignment.name.clone(),
444 slug: assignment.slug.clone(),
445 updated_at: assignment.updated_at,
446 claim_removed,
447 });
448 }
449 Ok(report)
450 }
451
452 fn session_path(&self, session_id: &str) -> PathBuf {
453 self.root
454 .join("by-session")
455 .join(format!("{session_id}.json"))
456 }
457}
458
459pub fn execute_register(args: &RegisterArgs) -> Result<()> {
460 let session_id = resolve_session(args.explicit_session())?;
461 let realm = resolve_realm(args.realm.as_deref())?;
462 let assignment = Registry::from_env()?.register(&session_id, args.family.as_deref(), &realm)?;
463 print_assignment(&assignment, args.json)
464}
465
466pub fn execute_lookup(args: &LookupArgs) -> Result<()> {
467 let input = resolve_session(args.explicit_input())?;
468 let assignment = Registry::from_env()?.lookup(&input)?;
469 print_assignment(&assignment, args.json)
470}
471
472pub fn execute_current(args: &CurrentArgs) -> Result<()> {
473 let session_id = resolve_session(None)?;
474 let assignment = Registry::from_env()?.lookup(&session_id)?;
475 print_assignment(&assignment, args.json)
476}
477
478pub fn execute_annotate(args: &AnnotateArgs) -> Result<()> {
479 let session_id = resolve_session(args.explicit_session())?;
480 let update = ActivityUpdate {
481 summary: args.summary.clone(),
482 clear_summary: args.clear_summary,
483 state: args.state,
484 clear_state: args.clear_state,
485 cwd: args.cwd.clone(),
486 clear_cwd: args.clear_cwd,
487 extensions: parse_extension_updates(&args.extensions)?,
488 clear_extensions: parse_extension_owners(&args.clear_extensions)?,
489 };
490 let assignment = Registry::from_env()?.annotate(&session_id, update)?;
491 print_assignment(&assignment, args.json)
492}
493
494pub fn execute_discover(args: &DiscoverArgs) -> Result<()> {
495 let assignments =
496 Registry::from_env()?.discover(args.limit, args.recent, args.realm.as_deref(), args.all)?;
497 let records = crate::herdr::augment_discovery(assignments);
498 if args.json {
499 println!("{}", serde_json::to_string_pretty(&records)?);
500 } else if records.is_empty() {
501 println!("(no identities)");
502 } else {
503 for record in records {
504 let assignment = &record.assignment;
505 let mut annotations = Vec::new();
506 if let Some(state) = assignment.state.as_ref() {
507 annotations.push(format!("state:{}", state.value));
508 }
509 if let Some(summary) = assignment.summary.as_ref() {
510 annotations.push(format!("summary:{}", summary.text));
511 }
512 if let Some(cwd) = assignment.cwd.as_ref() {
513 annotations.push(format!("cwd:{cwd}"));
514 }
515 if let Some(runtime) = record.runtime {
516 for location in runtime.locations {
517 let workspace = location
518 .workspace_label
519 .as_deref()
520 .unwrap_or(&location.workspace_id);
521 annotations.push(format!(
522 "herdr:{} pane:{} workspace:{}",
523 location.agent_status, location.pane_id, workspace
524 ));
525 }
526 }
527 if annotations.is_empty() {
528 println!("{}\t{}", assignment.name, assignment.session_id);
529 } else {
530 println!(
531 "{}\t{}\t{}",
532 assignment.name,
533 assignment.session_id,
534 annotations.join("\t")
535 );
536 }
537 }
538 }
539 Ok(())
540}
541
542pub fn execute_prune(args: &PruneArgs) -> Result<()> {
543 let cutoff = DateTime::parse_from_rfc3339(&args.before)
544 .with_context(|| format!("parse --before timestamp {}", args.before))?
545 .with_timezone(&Utc);
546 let report = Registry::from_env()?.prune(cutoff, args.dry_run)?;
547 if args.json {
548 println!("{}", serde_json::to_string_pretty(&report)?);
549 } else {
550 let action = if args.dry_run {
551 "would prune"
552 } else {
553 "pruned"
554 };
555 println!(
556 "{action} {} identities before {}",
557 report.candidates.len(),
558 cutoff
559 );
560 for assignment in &report.candidates {
561 println!(
562 "{}\t{}\tupdated:{}",
563 assignment.name, assignment.session_id, assignment.updated_at
564 );
565 }
566 for error in &report.errors {
567 eprintln!("agent-id: {error}");
568 }
569 }
570 if report.errors.is_empty() {
571 Ok(())
572 } else {
573 bail!("prune completed with {} errors", report.errors.len())
574 }
575}
576
577pub fn resolve_session(explicit: Option<&str>) -> Result<String> {
578 if let Some(session_id) = explicit.filter(|value| !value.trim().is_empty()) {
579 return Ok(session_id.trim().to_string());
580 }
581
582 if let Some(value) = env::var_os("AGENT_ID_SESSION_ID") {
583 let value = value.to_string_lossy();
584 if !value.trim().is_empty() {
585 return Ok(value.trim().to_string());
586 }
587 }
588
589 bail!("no session ID found; pass SESSION_ID or --session-id, or set AGENT_ID_SESSION_ID")
590}
591
592fn resolve_realm(explicit: Option<&str>) -> Result<String> {
593 if let Some(realm) = explicit.filter(|value| !value.trim().is_empty()) {
594 return normalize_component(realm, "realm");
595 }
596 if let Some(realm) = env::var_os("AGENT_REALM") {
597 let realm = realm.to_string_lossy();
598 if !realm.trim().is_empty() {
599 return normalize_component(&realm, "realm");
600 }
601 }
602
603 let home = home_dir()?;
604 let config_home = env::var_os("XDG_CONFIG_HOME")
605 .map(PathBuf::from)
606 .unwrap_or_else(|| home.join(".config"));
607 let realm_path = config_home.join("agent-id/realm");
608
609 if realm_path.is_file() {
610 let value = fs::read_to_string(&realm_path)
611 .with_context(|| format!("read realm from {}", realm_path.display()))?;
612 return normalize_component(&value, "realm");
613 }
614
615 auto_create_realm(&realm_path)
616}
617
618fn auto_create_realm(path: &Path) -> Result<String> {
619 let candidates = names::candidate_realms();
620 if candidates.is_empty() {
621 bail!("bundled realm candidate list is empty");
622 }
623
624 let nanos = SystemTime::now()
625 .duration_since(UNIX_EPOCH)
626 .map(|duration| duration.as_nanos())
627 .unwrap_or_default();
628 let pid = std::process::id();
629 let hostname = env::var("HOSTNAME").unwrap_or_default();
630 let digest = Sha256::digest(format!("{nanos}:{pid}:{hostname}").as_bytes());
631 let index = usize::try_from(u64::from_be_bytes(digest[0..8].try_into().unwrap())).unwrap_or(0)
632 % candidates.len();
633 let realm = normalize_component(candidates[index], "realm")?;
634
635 let parent = path
636 .parent()
637 .ok_or_else(|| anyhow!("realm path has no parent directory"))?;
638 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
639
640 let temp = temporary_path(path);
641 let mut file = File::create(&temp)
642 .with_context(|| format!("create temporary realm {}", temp.display()))?;
643 file.write_all(format!("{realm}\n").as_bytes())?;
644 file.sync_all()?;
645
646 match fs::hard_link(&temp, path) {
647 Ok(()) => {
648 let _ = fs::remove_file(temp);
649 Ok(realm)
650 }
651 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
652 let _ = fs::remove_file(temp);
653 let value = fs::read_to_string(path)
654 .with_context(|| format!("read existing realm {}", path.display()))?;
655 normalize_component(&value, "realm")
656 }
657 Err(error) => {
658 let _ = fs::remove_file(temp);
659 Err(error).with_context(|| format!("create realm file {}", path.display()))
660 }
661 }
662}
663
664fn print_assignment(assignment: &Assignment, json: bool) -> Result<()> {
665 if json {
666 println!("{}", serde_json::to_string_pretty(assignment)?);
667 } else {
668 println!("{}", assignment.name);
669 }
670 Ok(())
671}
672
673fn lookup_slugs(input: &str) -> Vec<String> {
674 let mut slugs = Vec::new();
675 let input = input.trim();
676 if is_slug(input) {
677 slugs.push(input.to_ascii_lowercase());
678 }
679 if let Some(slug) = canonical_name_slug(input) {
680 if !slugs.contains(&slug) {
681 slugs.push(slug);
682 }
683 }
684 slugs
685}
686
687fn canonical_name_slug(input: &str) -> Option<String> {
688 let mut parts = input.split_whitespace();
689 let first = normalize_component(parts.next()?, "first name").ok()?;
690 let family = normalize_component(parts.next()?, "family name").ok()?;
691 if !parts.next()?.eq_ignore_ascii_case("of") {
692 return None;
693 }
694 let realm = normalize_component(parts.next()?, "realm").ok()?;
695 if parts.next().is_some() {
696 return None;
697 }
698 Some(slug(&first, &family, &realm))
699}
700
701fn is_slug(input: &str) -> bool {
702 !input.is_empty()
703 && !input.starts_with('-')
704 && !input.ends_with('-')
705 && input
706 .chars()
707 .all(|character| character.is_ascii_alphanumeric() || character == '-')
708}
709
710fn candidate<'a>(
711 session_id: &str,
712 attempt: u64,
713 first_names: &'a [&'a str],
714 family_names: &'a [&'a str],
715 requested_family: Option<&str>,
716) -> (&'a str, String) {
717 let digest = Sha256::digest(format!("{session_id}:{attempt}").as_bytes());
718 let first_index = usize::try_from(u64::from_be_bytes(digest[0..8].try_into().unwrap()))
719 .unwrap_or(0)
720 % first_names.len();
721 let family_index = usize::try_from(u64::from_be_bytes(digest[8..16].try_into().unwrap()))
722 .unwrap_or(0)
723 % family_names.len();
724 let family = requested_family
725 .map(ToOwned::to_owned)
726 .unwrap_or_else(|| family_names[family_index].to_string());
727 (first_names[first_index], family)
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731enum Claim {
732 Claimed,
733 Owned,
734 Other,
735}
736
737fn claim_name(path: &Path, session_id: &str) -> Result<Claim> {
738 let parent = path
739 .parent()
740 .ok_or_else(|| anyhow!("name claim has no parent directory"))?;
741 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
742
743 let temp = temporary_path(path);
744 let mut file = File::create(&temp)
745 .with_context(|| format!("create temporary claim {}", temp.display()))?;
746 file.write_all(session_id.as_bytes())?;
747 file.write_all(b"\n")?;
748 file.sync_all()?;
749
750 let claim = match fs::hard_link(&temp, path) {
751 Ok(()) => Claim::Claimed,
752 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
753 let owner = fs::read_to_string(path).unwrap_or_default();
754 if owner.trim() == session_id {
755 Claim::Owned
756 } else {
757 Claim::Other
758 }
759 }
760 Err(error) => return Err(error).with_context(|| format!("claim name {}", path.display())),
761 };
762 let _ = fs::remove_file(temp);
763 Ok(claim)
764}
765
766fn write_assignment(path: &Path, assignment: &Assignment) -> Result<()> {
767 let parent = path
768 .parent()
769 .ok_or_else(|| anyhow!("assignment has no parent directory"))?;
770 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
771 let contents = format!("{}\n", serde_json::to_string_pretty(assignment)?);
772 let temp = temporary_path(path);
773 let mut file = File::create(&temp)
774 .with_context(|| format!("create temporary assignment {}", temp.display()))?;
775 file.write_all(contents.as_bytes())?;
776 file.sync_all()?;
777
778 let result = match fs::hard_link(&temp, path) {
779 Ok(()) => Ok(()),
780 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
781 bail!(
782 "session {} already has a registered identity",
783 assignment.session_id
784 )
785 }
786 Err(error) => Err(error).with_context(|| format!("write assignment {}", path.display())),
787 };
788 let _ = fs::remove_file(temp);
789 result
790}
791
792fn replace_assignment(path: &Path, assignment: &Assignment) -> Result<()> {
793 let parent = path
794 .parent()
795 .ok_or_else(|| anyhow!("assignment has no parent directory"))?;
796 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
797 let contents = format!("{}\n", serde_json::to_string_pretty(assignment)?);
798 let temp = temporary_path(path);
799 let mut file = File::create(&temp)
800 .with_context(|| format!("create temporary assignment {}", temp.display()))?;
801 file.write_all(contents.as_bytes())?;
802 file.sync_all()?;
803
804 let result =
805 fs::rename(&temp, path).with_context(|| format!("replace assignment {}", path.display()));
806 if result.is_err() {
807 let _ = fs::remove_file(temp);
808 }
809 result
810}
811
812fn read_assignment(path: &Path) -> Result<Assignment> {
813 let contents = fs::read_to_string(path)?;
814 Ok(serde_json::from_str(&contents)?)
815}
816
817static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
818
819fn temporary_path(path: &Path) -> PathBuf {
820 let nanos = SystemTime::now()
821 .duration_since(UNIX_EPOCH)
822 .map(|duration| duration.as_nanos())
823 .unwrap_or_default();
824 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
825 path.with_extension(format!("tmp-{}-{nanos}-{counter}", std::process::id()))
826}
827
828fn slug(first_name: &str, family_name: &str, realm: &str) -> String {
829 [first_name, family_name, realm]
830 .iter()
831 .map(|part| part.to_ascii_lowercase().replace([' ', '\'', '_'], "-"))
832 .collect::<Vec<_>>()
833 .join("-")
834}
835
836fn normalize_component(value: &str, kind: &str) -> Result<String> {
837 let value = value.trim();
838 let valid = !value.is_empty()
839 && value.chars().all(|character| {
840 character.is_ascii_alphabetic() || character == '-' || character == '\''
841 })
842 && value
843 .chars()
844 .next()
845 .is_some_and(|character| character.is_ascii_alphabetic())
846 && value
847 .chars()
848 .last()
849 .is_some_and(|character| character.is_ascii_alphabetic());
850 if !valid {
851 bail!("{kind} must contain only letters, apostrophes, or hyphens")
852 }
853 Ok(title_word(value))
854}
855
856fn title_word(value: &str) -> String {
857 let mut characters = value.chars();
858 let Some(first) = characters.next() else {
859 return String::new();
860 };
861 first.to_uppercase().collect::<String>() + &characters.as_str().to_ascii_lowercase()
862}
863
864fn validate_session_id(value: &str) -> Result<String> {
865 let value = require_nonempty(value, "session ID")?;
866 let safe = value
867 .chars()
868 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'));
869 if !safe || value == "." || value == ".." {
870 bail!("session ID must be a filename-safe value using letters, digits, '.', '_' or '-'");
871 }
872 Ok(value)
873}
874
875const MAX_EXTENSION_OWNER_CHARS: usize = 64;
876const MAX_EXTENSION_JSON_BYTES: usize = 16 * 1024;
877
878fn parse_extension_updates(values: &[String]) -> Result<BTreeMap<String, serde_json::Value>> {
879 let mut extensions = BTreeMap::new();
880 for value in values {
881 let (owner, json) = value
882 .split_once('=')
883 .ok_or_else(|| anyhow!("--extension must use OWNER=JSON"))?;
884 let owner = normalize_extension_owner(owner)?;
885 if json.len() > MAX_EXTENSION_JSON_BYTES {
886 bail!("extension {owner} JSON must be at most {MAX_EXTENSION_JSON_BYTES} bytes");
887 }
888 let data = serde_json::from_str(json)
889 .with_context(|| format!("parse JSON for extension {owner}"))?;
890 if extensions.insert(owner.clone(), data).is_some() {
891 bail!("extension {owner} was provided more than once");
892 }
893 }
894 Ok(extensions)
895}
896
897fn parse_extension_owners(values: &[String]) -> Result<BTreeSet<String>> {
898 values
899 .iter()
900 .map(|owner| normalize_extension_owner(owner))
901 .collect()
902}
903
904fn normalize_extension_owner(value: &str) -> Result<String> {
905 let owner = require_nonempty(value, "extension owner")?;
906 let valid = owner.chars().count() <= MAX_EXTENSION_OWNER_CHARS
907 && owner
908 .chars()
909 .next()
910 .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
911 && owner.chars().all(|character| {
912 character.is_ascii_lowercase()
913 || character.is_ascii_digit()
914 || matches!(character, '.' | '_' | '-')
915 });
916 if !valid {
917 bail!(
918 "extension owner must be at most {MAX_EXTENSION_OWNER_CHARS} characters using lowercase letters, digits, '.', '_' or '-'"
919 );
920 }
921 Ok(owner)
922}
923
924const MAX_SUMMARY_CHARS: usize = 240;
925
926fn normalize_summary(value: &str) -> Result<String> {
927 let mut normalized = String::new();
928 for word in value.split_whitespace() {
929 if !normalized.is_empty() {
930 normalized.push(' ');
931 }
932 normalized.push_str(word);
933 }
934 if normalized.is_empty() {
935 bail!("summary cannot be empty");
936 }
937 if normalized.chars().count() > MAX_SUMMARY_CHARS {
938 bail!("summary must be at most {MAX_SUMMARY_CHARS} characters");
939 }
940 Ok(normalized)
941}
942
943const MAX_CWD_CHARS: usize = 4096;
944
945fn normalize_cwd(value: &str) -> Result<String> {
946 let value = require_nonempty(value, "working directory")?;
947 if value.chars().any(char::is_control) {
948 bail!("working directory cannot contain control characters");
949 }
950 if value.chars().count() > MAX_CWD_CHARS {
951 bail!("working directory must be at most {MAX_CWD_CHARS} characters");
952 }
953 Ok(value)
954}
955
956fn require_nonempty(value: &str, kind: &str) -> Result<String> {
957 let value = value.trim();
958 if value.is_empty() {
959 bail!("{kind} cannot be empty")
960 }
961 Ok(value.to_string())
962}
963
964fn home_dir() -> Result<PathBuf> {
965 env::var_os("HOME")
966 .map(PathBuf::from)
967 .ok_or_else(|| anyhow!("HOME is not set; set AGENT_ID_HOME explicitly"))
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973
974 #[test]
975 fn generated_identity_has_canonical_parts() {
976 let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
977 let assignment = registry
978 .register("session-1", Some("Oak"), "Darkwood")
979 .unwrap();
980
981 assert_eq!(assignment.family_name, "Oak");
982 assert_eq!(assignment.realm, "Darkwood");
983 assert_eq!(
984 assignment.slug,
985 format!(
986 "{}-oak-darkwood",
987 assignment.first_name.to_ascii_lowercase()
988 )
989 );
990 assert_eq!(
991 assignment.name,
992 format!("{} Oak of Darkwood", assignment.first_name)
993 );
994 }
995
996 #[test]
997 fn session_id_is_the_registry_filename() {
998 let root = tempfile::tempdir().unwrap();
999 let registry = Registry::new(root.path().to_path_buf());
1000 registry
1001 .register("session-visible", None, "Darkwood")
1002 .unwrap();
1003
1004 assert!(root
1005 .path()
1006 .join("by-session/session-visible.json")
1007 .is_file());
1008 }
1009
1010 #[test]
1011 fn unsafe_session_ids_are_rejected() {
1012 let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1013 let error = registry
1014 .register("../escape", None, "Darkwood")
1015 .unwrap_err();
1016 assert!(error.to_string().contains("filename-safe"));
1017 }
1018
1019 #[test]
1020 fn lookup_accepts_session_name_and_slug() {
1021 let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1022 let assignment = registry
1023 .register("session-lookup", Some("Oak"), "Darkwood")
1024 .unwrap();
1025
1026 assert_eq!(registry.lookup(&assignment.session_id).unwrap(), assignment);
1027 assert_eq!(registry.lookup(&assignment.name).unwrap(), assignment);
1028 assert_eq!(registry.lookup(&assignment.slug).unwrap(), assignment);
1029 }
1030
1031 #[test]
1032 fn missing_realm_is_auto_created_and_reused() {
1033 let config_dir = tempfile::tempdir().unwrap();
1034 let realm_file = config_dir.path().join("agent-id/realm");
1035 assert!(!realm_file.exists());
1036
1037 let first = auto_create_realm(&realm_file).unwrap();
1038 assert!(realm_file.is_file());
1039 let contents = fs::read_to_string(&realm_file).unwrap();
1040 assert_eq!(contents.trim(), first);
1041
1042 let second = auto_create_realm(&realm_file).unwrap();
1043 assert_eq!(second, first);
1044 }
1045
1046 #[test]
1047 fn concurrent_realm_creation_keeps_one_value() {
1048 let config_dir = tempfile::tempdir().unwrap();
1049 let realm_file = std::sync::Arc::new(config_dir.path().join("agent-id/realm"));
1050 let handles = (0..8)
1051 .map(|_| {
1052 let realm_file = std::sync::Arc::clone(&realm_file);
1053 std::thread::spawn(move || auto_create_realm(&realm_file).unwrap())
1054 })
1055 .collect::<Vec<_>>();
1056 let mut handles = handles.into_iter();
1057 let first = handles.next().unwrap().join().unwrap();
1058 for handle in handles {
1059 assert_eq!(handle.join().unwrap(), first);
1060 }
1061 }
1062
1063 #[test]
1064 fn cwd_metadata_is_bounded_and_single_line() {
1065 assert_eq!(normalize_cwd(" /tmp/agent-id ").unwrap(), "/tmp/agent-id");
1066 assert!(normalize_cwd("/tmp/agent\nid").is_err());
1067 assert!(normalize_cwd(&"x".repeat(MAX_CWD_CHARS + 1)).is_err());
1068 }
1069
1070 #[test]
1071 fn summaries_are_single_line_and_bounded() {
1072 assert_eq!(
1073 normalize_summary(" Implementing\n activity summaries ").unwrap(),
1074 "Implementing activity summaries"
1075 );
1076 assert!(normalize_summary(" \n\t ").is_err());
1077 assert!(normalize_summary(&"x".repeat(MAX_SUMMARY_CHARS + 1)).is_err());
1078 }
1079
1080 #[test]
1081 fn registering_a_session_updates_existing_identity() {
1082 let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1083 let first = registry.register("session-1", None, "Darkwood").unwrap();
1084 let second = registry.register("session-1", None, "Darkwood").unwrap();
1085
1086 assert_eq!(second.name, first.name);
1087 assert_eq!(second.session_id, first.session_id);
1088 assert_eq!(second.created_at, first.created_at);
1089 assert!(second.updated_at >= first.updated_at);
1090 }
1091
1092 #[test]
1093 fn lookup_requires_a_registered_session() {
1094 let registry = Registry::new(tempfile::tempdir().unwrap().path().to_path_buf());
1095 let error = registry.lookup("missing").unwrap_err();
1096 assert!(error.to_string().contains("no identity found"));
1097 }
1098}