1use std::path::PathBuf;
2
3use getset::Getters;
4use nutype::nutype;
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use crate::domain::{
9 process::{AgentIdentitySource, AgentProtocol, AgentTool},
10 value::{CommandLine, ProcessName},
11};
12
13#[nutype(
15 sanitize(trim),
16 validate(not_empty),
17 derive(
18 Debug,
19 Clone,
20 PartialEq,
21 Eq,
22 Hash,
23 AsRef,
24 Display,
25 Serialize,
26 Deserialize
27 )
28)]
29pub struct AgentSessionId(String);
30
31impl AgentSessionId {
32 pub fn generate() -> Result<Self, AgentSessionIdError> {
38 Self::try_new(uuid::Uuid::new_v4().to_string())
39 }
40}
41
42#[nutype(
44 sanitize(trim),
45 validate(not_empty),
46 derive(
47 Debug,
48 Clone,
49 PartialEq,
50 Eq,
51 Hash,
52 AsRef,
53 Display,
54 Serialize,
55 Deserialize
56 )
57)]
58pub struct NativeSessionId(String);
59
60#[nutype(
62 validate(greater = 0),
63 derive(
64 Debug,
65 Clone,
66 Copy,
67 PartialEq,
68 Eq,
69 Hash,
70 Display,
71 Serialize,
72 Deserialize
73 )
74)]
75pub struct AgentProcessId(u32);
76
77#[nutype(
79 validate(greater = 0),
80 derive(
81 Debug,
82 Clone,
83 Copy,
84 PartialEq,
85 Eq,
86 Hash,
87 Display,
88 Serialize,
89 Deserialize
90 )
91)]
92pub struct AgentProcessStartToken(u64);
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum AgentSessionState {
98 Pending,
100 Open,
102 Closed,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum ShellContext {
109 Unquoted,
111 Single,
113 Double,
115 Backtick,
117 TrailingEscape,
119}
120
121const SESSION_ID_PLACEHOLDER: &str = "{session_id}";
123
124#[derive(Debug, Clone, Serialize, Deserialize, Getters, TypedBuilder)]
126#[getset(get = "pub")]
127pub struct AgentSession {
128 id: AgentSessionId,
130 name: ProcessName,
132 tool: AgentTool,
134 project: PathBuf,
136 launch_command: CommandLine,
138 #[builder(default)]
140 working_dir: Option<PathBuf>,
141 #[builder(default)]
143 resume_command: Option<CommandLine>,
144 #[builder(default)]
146 native_id: Option<NativeSessionId>,
147 #[builder(default)]
149 owner_process_id: Option<AgentProcessId>,
150 #[builder(default)]
152 owner_process_start_token: Option<AgentProcessStartToken>,
153 #[builder(default)]
155 wrapper_process_id: Option<AgentProcessId>,
156 state: AgentSessionState,
158}
159
160impl AgentSession {
161 pub fn with_native_id(mut self, native_id: NativeSessionId) -> Self {
163 self.native_id = Some(native_id);
164 self
165 }
166
167 pub fn with_owner_process_id(mut self, process_id: AgentProcessId) -> Self {
169 self.owner_process_id = Some(process_id);
170 self
171 }
172
173 pub fn with_launch_processes(
175 mut self,
176 owner_process_id: AgentProcessId,
177 owner_process_start_token: Option<AgentProcessStartToken>,
178 wrapper_process_id: Option<AgentProcessId>,
179 ) -> Self {
180 self.owner_process_id = Some(owner_process_id);
181 self.owner_process_start_token = owner_process_start_token;
182 self.wrapper_process_id = wrapper_process_id;
183 self
184 }
185
186 pub fn with_state(mut self, state: AgentSessionState) -> Self {
188 self.state = state;
189 self
190 }
191
192 pub fn resume(&self) -> Option<CommandLine> {
194 let native_id = self.native_id.as_ref()?;
195 if let Some(template) = &self.resume_command {
196 return Self::expand_resume_template(template, native_id);
197 }
198 self.tool.resume_command(&self.launch_command, native_id)
199 }
200
201 pub fn restore_command(&self) -> Option<CommandLine> {
205 if self.native_id.is_some() {
206 return self.resume();
207 }
208 (self.state == AgentSessionState::Pending
209 || self.state == AgentSessionState::Open
210 && self.tool.identity_source() == AgentIdentitySource::Assigned)
211 .then(|| {
212 self.tool
213 .new_session_command(&self.launch_command, &self.id)
214 })
215 .flatten()
216 }
217
218 pub fn resume_template_is_valid(template: &CommandLine) -> bool {
222 let template = template.as_ref();
223 Self::shell_context(template) == ShellContext::Unquoted
224 && !Self::contains_here_document(template)
225 && (!template.contains(SESSION_ID_PLACEHOLDER)
226 && Self::command_text_accepts_provider_arguments(template)
227 || template.contains(SESSION_ID_PLACEHOLDER)
228 && template
229 .match_indices(SESSION_ID_PLACEHOLDER)
230 .all(|(index, _)| {
231 Self::placeholder_is_unquoted_shell_word(
232 template,
233 index,
234 SESSION_ID_PLACEHOLDER,
235 )
236 }))
237 }
238
239 pub fn launch_command_accepts_provider_arguments(command: &CommandLine) -> bool {
243 Self::command_text_accepts_provider_arguments(command.as_ref())
244 }
245
246 fn command_text_accepts_provider_arguments(command: &str) -> bool {
248 let mut context = ShellContext::Unquoted;
249 let mut chars = command.chars();
250 while let Some(character) = chars.next() {
251 if context == ShellContext::Unquoted
252 && matches!(
253 character,
254 '#' | '|' | ';' | '&' | '(' | ')' | '`' | '\n' | '\r'
255 )
256 {
257 return false;
258 }
259 context = match (context, character) {
260 (ShellContext::Unquoted, '\\')
261 | (ShellContext::Double, '\\')
262 | (ShellContext::Backtick, '\\') => {
263 if chars.next().is_some() {
264 context
265 } else {
266 ShellContext::TrailingEscape
267 }
268 },
269 (ShellContext::Unquoted, '\'') => ShellContext::Single,
270 (ShellContext::Single, '\'') => ShellContext::Unquoted,
271 (ShellContext::Unquoted, '"') => ShellContext::Double,
272 (ShellContext::Double, '"') => ShellContext::Unquoted,
273 (ShellContext::Unquoted, '`') => ShellContext::Backtick,
274 (ShellContext::Backtick, '`') => ShellContext::Unquoted,
275 _ => context,
276 };
277 }
278 context == ShellContext::Unquoted
279 }
280
281 fn contains_here_document(command: &str) -> bool {
284 let mut context = ShellContext::Unquoted;
285 let mut chars = command.chars().peekable();
286 while let Some(character) = chars.next() {
287 if context == ShellContext::Unquoted && character == '<' && chars.peek() == Some(&'<') {
288 return true;
289 }
290 context = match (context, character) {
291 (ShellContext::Unquoted, '\\')
292 | (ShellContext::Double, '\\')
293 | (ShellContext::Backtick, '\\') => {
294 if chars.next().is_some() {
295 context
296 } else {
297 ShellContext::TrailingEscape
298 }
299 },
300 (ShellContext::Unquoted, '\'') => ShellContext::Single,
301 (ShellContext::Single, '\'') => ShellContext::Unquoted,
302 (ShellContext::Unquoted, '"') => ShellContext::Double,
303 (ShellContext::Double, '"') => ShellContext::Unquoted,
304 (ShellContext::Unquoted, '`') => ShellContext::Backtick,
305 (ShellContext::Backtick, '`') => ShellContext::Unquoted,
306 _ => context,
307 };
308 }
309 false
310 }
311
312 fn expand_resume_template(
315 template: &CommandLine,
316 native_id: &NativeSessionId,
317 ) -> Option<CommandLine> {
318 let quoted = Self::quote_for_command_shell(native_id.as_ref())?;
319 Self::resume_template_is_valid(template).then_some(())?;
320 let template = template.as_ref();
321 let command = if template.contains(SESSION_ID_PLACEHOLDER) {
322 template.replace(SESSION_ID_PLACEHOLDER, "ed)
323 } else {
324 format!("{template} {quoted}")
325 };
326 CommandLine::try_new(command).ok()
327 }
328
329 pub(crate) fn quote_for_command_shell(value: &str) -> Option<String> {
331 #[cfg(windows)]
332 {
333 Some(format!(
337 "\"{}\"",
338 value.replace('%', "%%").replace('"', "^\"")
339 ))
340 }
341 #[cfg(not(windows))]
342 {
343 shlex::try_quote(value).ok().map(Into::into)
344 }
345 }
346
347 fn placeholder_is_unquoted_shell_word(template: &str, index: usize, placeholder: &str) -> bool {
350 let before = &template[..index];
351 let after = &template[index + placeholder.len()..];
352 Self::shell_context(before) == ShellContext::Unquoted
353 && Self::prefix_ends_at_shell_boundary(before)
354 && after.chars().next().is_none_or(char::is_whitespace)
355 }
356
357 fn prefix_ends_at_shell_boundary(prefix: &str) -> bool {
360 let Some(last) = prefix.chars().next_back() else {
361 return true;
362 };
363 if !last.is_whitespace() {
364 return false;
365 }
366 prefix
367 .chars()
368 .rev()
369 .skip(1)
370 .take_while(|character| *character == '\\')
371 .count()
372 % 2
373 == 0
374 }
375
376 fn shell_context(prefix: &str) -> ShellContext {
379 let mut context = ShellContext::Unquoted;
380 let mut chars = prefix.chars();
381 while let Some(character) = chars.next() {
382 context = match (context, character) {
383 (ShellContext::Unquoted, '\\')
384 | (ShellContext::Double, '\\')
385 | (ShellContext::Backtick, '\\') => {
386 if chars.next().is_some() {
387 context
388 } else {
389 ShellContext::TrailingEscape
390 }
391 },
392 (ShellContext::Unquoted, '\'') => ShellContext::Single,
393 (ShellContext::Single, '\'') => ShellContext::Unquoted,
394 (ShellContext::Unquoted, '"') => ShellContext::Double,
395 (ShellContext::Double, '"') => ShellContext::Unquoted,
396 (ShellContext::Unquoted, '`') => ShellContext::Backtick,
397 (ShellContext::Backtick, '`') => ShellContext::Unquoted,
398 _ => context,
399 };
400 }
401 context
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
411 fn expands_custom_resume_templates() {
412 let session = AgentSession::builder()
413 .id(AgentSessionId::generate().unwrap())
414 .name(ProcessName::try_new("Ada").unwrap())
415 .tool(AgentTool::Custom)
416 .project(PathBuf::from("/repo/muster.yml"))
417 .launch_command(CommandLine::try_new("agent").unwrap())
418 .resume_command(Some(
419 CommandLine::try_new("agent --resume {session_id}").unwrap(),
420 ))
421 .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
422 .state(AgentSessionState::Closed)
423 .build();
424
425 assert_eq!(
426 session.resume().unwrap().as_ref(),
427 "agent --resume 'thread one'"
428 );
429 }
430
431 #[test]
434 fn appends_ids_to_complete_placeholder_free_templates() {
435 let session = AgentSession::builder()
436 .id(AgentSessionId::generate().unwrap())
437 .name(ProcessName::try_new("Ada").unwrap())
438 .tool(AgentTool::Custom)
439 .project(PathBuf::from("/repo/muster.yml"))
440 .launch_command(CommandLine::try_new("agent").unwrap())
441 .resume_command(Some(CommandLine::try_new("agent --resume").unwrap()))
442 .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
443 .state(AgentSessionState::Closed)
444 .build();
445
446 assert_eq!(
447 session.resume().unwrap().as_ref(),
448 "agent --resume 'thread one'"
449 );
450 }
451
452 #[test]
455 fn rejects_incomplete_placeholder_free_templates() {
456 for template in ["agent --resume \"", "agent --resume \\"] {
457 let template = CommandLine::try_new(template).unwrap();
458 assert!(!AgentSession::resume_template_is_valid(&template));
459 }
460 let session = AgentSession::builder()
461 .id(AgentSessionId::generate().unwrap())
462 .name(ProcessName::try_new("Ada").unwrap())
463 .tool(AgentTool::Custom)
464 .project(PathBuf::from("/repo/muster.yml"))
465 .launch_command(CommandLine::try_new("agent").unwrap())
466 .resume_command(Some(CommandLine::try_new("agent --resume \"").unwrap()))
467 .native_id(Some(
468 NativeSessionId::try_new("\"; some-command; #").unwrap(),
469 ))
470 .state(AgentSessionState::Closed)
471 .build();
472
473 assert!(session.resume().is_none());
474 }
475
476 #[test]
478 fn rejects_shell_compositions_for_provider_arguments() {
479 let pipeline = CommandLine::try_new("codex | tee agent.log").unwrap();
480 let sequence = CommandLine::try_new("codex; echo done").unwrap();
481 let newline = CommandLine::try_new("codex\ntee agent.log").unwrap();
482 let simple = CommandLine::try_new("FOO=bar codex").unwrap();
483
484 assert!(!AgentSession::launch_command_accepts_provider_arguments(
485 &pipeline
486 ));
487 assert!(!AgentSession::launch_command_accepts_provider_arguments(
488 &sequence
489 ));
490 assert!(!AgentSession::launch_command_accepts_provider_arguments(
491 &newline
492 ));
493 assert!(AgentSession::launch_command_accepts_provider_arguments(
494 &simple
495 ));
496 }
497
498 #[test]
501 fn rejects_placeholder_free_composed_resume_templates() {
502 for template in [
503 "agent --resume | tee agent.log",
504 "agent --resume # local",
505 "agent --resume\ntee agent.log",
506 ] {
507 let template = CommandLine::try_new(template).unwrap();
508 assert!(!AgentSession::resume_template_is_valid(&template));
509 }
510 let explicit = CommandLine::try_new("agent --resume {session_id} | tee agent.log").unwrap();
511 assert!(AgentSession::resume_template_is_valid(&explicit));
512 }
513
514 #[test]
516 fn rejects_resume_placeholders_inside_here_documents() {
517 let template = CommandLine::try_new("cat <<EOF\n{session_id}\nEOF").unwrap();
518
519 assert!(!AgentSession::resume_template_is_valid(&template));
520 }
521
522 #[test]
525 fn rejects_a_quoted_resume_placeholder() {
526 let session = AgentSession::builder()
527 .id(AgentSessionId::generate().unwrap())
528 .name(ProcessName::try_new("Ada").unwrap())
529 .tool(AgentTool::Custom)
530 .project(PathBuf::from("/repo/muster.yml"))
531 .launch_command(CommandLine::try_new("agent").unwrap())
532 .resume_command(Some(
533 CommandLine::try_new("agent --resume \"{session_id}\"").unwrap(),
534 ))
535 .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
536 .state(AgentSessionState::Closed)
537 .build();
538
539 assert!(session.resume().is_none());
540 }
541
542 #[test]
545 fn rejects_a_spaced_placeholder_inside_shell_quotes() {
546 let session = AgentSession::builder()
547 .id(AgentSessionId::generate().unwrap())
548 .name(ProcessName::try_new("Ada").unwrap())
549 .tool(AgentTool::Custom)
550 .project(PathBuf::from("/repo/muster.yml"))
551 .launch_command(CommandLine::try_new("agent").unwrap())
552 .resume_command(Some(
553 CommandLine::try_new("agent --resume \"prefix {session_id} suffix\"").unwrap(),
554 ))
555 .native_id(Some(
556 NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
557 ))
558 .state(AgentSessionState::Closed)
559 .build();
560
561 assert!(session.resume().is_none());
562 }
563
564 #[test]
566 fn quotes_shell_metacharacters_in_resume_ids() {
567 let session = AgentSession::builder()
568 .id(AgentSessionId::generate().unwrap())
569 .name(ProcessName::try_new("Ada").unwrap())
570 .tool(AgentTool::Custom)
571 .project(PathBuf::from("/repo/muster.yml"))
572 .launch_command(CommandLine::try_new("agent").unwrap())
573 .resume_command(Some(
574 CommandLine::try_new("agent --resume {session_id}").unwrap(),
575 ))
576 .native_id(Some(
577 NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
578 ))
579 .state(AgentSessionState::Closed)
580 .build();
581
582 assert_eq!(
583 session.resume().unwrap().as_ref(),
584 "agent --resume '$(touch /tmp/muster-owned)'"
585 );
586 }
587
588 #[cfg(windows)]
590 #[test]
591 fn quotes_resume_ids_for_the_windows_command_shell() {
592 assert_eq!(
593 AgentSession::quote_for_command_shell("thread & command"),
594 Some("\"thread & command\"".to_string())
595 );
596 }
597
598 #[test]
601 fn restores_an_unconfirmed_assigned_identity_with_a_new_session_command() {
602 let session = AgentSession::builder()
603 .id(AgentSessionId::try_new("assigned-session").unwrap())
604 .name(ProcessName::try_new("Ada").unwrap())
605 .tool(AgentTool::Claude)
606 .project(PathBuf::from("/repo/muster.yml"))
607 .launch_command(CommandLine::try_new("claude").unwrap())
608 .state(AgentSessionState::Open)
609 .build();
610
611 assert!(session.resume().is_none());
612 assert_eq!(
613 session.restore_command().unwrap().as_ref(),
614 "claude --session-id assigned-session"
615 );
616 }
617
618 #[test]
620 fn does_not_reopen_a_closed_unconfirmed_assigned_identity() {
621 let session = AgentSession::builder()
622 .id(AgentSessionId::try_new("closed-session").unwrap())
623 .name(ProcessName::try_new("Ada").unwrap())
624 .tool(AgentTool::Claude)
625 .project(PathBuf::from("/repo/muster.yml"))
626 .launch_command(CommandLine::try_new("claude").unwrap())
627 .state(AgentSessionState::Closed)
628 .build();
629
630 assert!(session.restore_command().is_none());
631 }
632
633 #[test]
635 fn restores_a_pending_reported_identity_with_a_new_session_command() {
636 let session = AgentSession::builder()
637 .id(AgentSessionId::try_new("pending-session").unwrap())
638 .name(ProcessName::try_new("Ada").unwrap())
639 .tool(AgentTool::Codex)
640 .project(PathBuf::from("/repo/muster.yml"))
641 .launch_command(CommandLine::try_new("codex").unwrap())
642 .state(AgentSessionState::Pending)
643 .build();
644
645 assert_eq!(session.restore_command().unwrap().as_ref(), "codex");
646 }
647
648 #[test]
651 fn restores_a_confirmed_assigned_identity_with_resume() {
652 let session = AgentSession::builder()
653 .id(AgentSessionId::try_new("assigned-session").unwrap())
654 .name(ProcessName::try_new("Ada").unwrap())
655 .tool(AgentTool::Claude)
656 .project(PathBuf::from("/repo/muster.yml"))
657 .launch_command(CommandLine::try_new("claude").unwrap())
658 .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
659 .state(AgentSessionState::Open)
660 .build();
661
662 assert_eq!(
663 session.restore_command().unwrap().as_ref(),
664 "claude --resume confirmed-session"
665 );
666 }
667
668 #[test]
671 fn does_not_replace_a_confirmed_conversation_when_resume_is_invalid() {
672 let session = AgentSession::builder()
673 .id(AgentSessionId::try_new("assigned-session").unwrap())
674 .name(ProcessName::try_new("Ada").unwrap())
675 .tool(AgentTool::Claude)
676 .project(PathBuf::from("/repo/muster.yml"))
677 .launch_command(CommandLine::try_new("claude").unwrap())
678 .resume_command(Some(CommandLine::try_new("claude --resume \"").unwrap()))
679 .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
680 .state(AgentSessionState::Open)
681 .build();
682
683 assert!(session.restore_command().is_none());
684 }
685}