1use std::env;
41
42use crate::args_parser_queries::{parse_query_option, validate_query_options};
43use crate::docker_recovery_options::{
44 parse_docker_recovery_option, validate_docker_recovery_options,
45};
46use crate::isolation::get_default_docker_image;
47
48pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
50
51pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
53
54pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
56
57const UUID_REGEX: &str = r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
59
60pub fn is_valid_uuid(s: &str) -> bool {
62 regex::Regex::new(UUID_REGEX)
63 .map(|re| re.is_match(&s.to_lowercase()))
64 .unwrap_or(false)
65}
66
67pub fn generate_uuid() -> String {
69 uuid::Uuid::new_v4().to_string()
70}
71
72#[derive(Debug, Clone)]
74pub struct WrapperOptions {
75 pub isolated: Option<String>,
77 pub attached: bool,
79 pub detached: bool,
81 pub session: Option<String>,
83 pub session_id: Option<String>,
85 pub image: Option<String>,
87 pub volumes: Vec<String>,
89 pub mounts: Vec<String>,
91 pub env: Vec<String>,
93 pub labels: Vec<String>,
95 pub help: bool,
96 pub no_sanitize: bool,
97 pub remove_original: bool,
98 pub privileged: bool,
100 pub init: Option<bool>,
102 pub network: Option<String>,
104 pub networks: Vec<String>,
106 pub network_aliases: Vec<String>,
108 pub on_kill_resume: Option<u32>,
110 pub recovery_command: Option<String>,
112 pub on_kill_resume_delay: Option<String>,
114 pub cpu_penalty: bool,
115 pub cpu_penalty_config: Option<crate::cpu_penalty::Config>,
116 pub memory: Option<String>,
117 pub memory_swap: Option<String>,
118 pub memory_reservation: Option<String>,
119 pub cpus: Option<String>,
120 pub on_kill_resume_memory: Option<String>,
121 pub resource_limits: Vec<String>,
122 pub resolved_limits: serde_json::Value,
123 pub endpoint: Option<String>,
125 pub user: bool,
127 pub user_name: Option<String>,
129 pub keep_user: bool,
131 pub keep_alive: bool,
133 pub auto_remove_docker_container: bool,
135 pub always_cleanup_container: bool,
137 pub keep_container: bool,
139 pub keep_container_on_fail: bool,
141 pub shell: String,
143 pub use_command_stream: bool,
145 pub use_formal_ai: bool,
147 pub status: Option<String>,
149 pub list: bool,
151 pub running: bool,
153 pub upload_log: Option<String>,
155 pub output_format: Option<String>,
157 pub stop: Option<String>,
159 pub terminate: Option<String>,
161 pub attach: Option<String>,
163 pub read_only: bool,
165 pub resume: Option<String>,
167 pub resume_all: bool,
169 pub cleanup: bool,
171 pub cleanup_dry_run: bool,
173 pub cleanup_target: Option<String>,
175 pub alias: Option<String>,
177 pub rename: Option<String>,
179 pub new_name: Option<String>,
181 pub append_attempt: bool,
183 pub continues: Option<String>,
185 pub snapshot: crate::snapshot_resume_options::SnapshotResumeOptions,
187}
188
189impl Default for WrapperOptions {
190 fn default() -> Self {
191 WrapperOptions {
192 isolated: None,
193 attached: false,
194 detached: false,
195 session: None,
196 session_id: None,
197 image: None,
198 volumes: Vec::new(),
199 mounts: Vec::new(),
200 env: Vec::new(),
201 labels: Vec::new(),
202 help: false,
203 no_sanitize: false,
204 remove_original: false,
205 privileged: false,
206 init: None,
207 network: None,
208 networks: Vec::new(),
209 network_aliases: Vec::new(),
210 on_kill_resume: None,
211 recovery_command: None,
212 on_kill_resume_delay: None,
213 cpu_penalty: false,
214 cpu_penalty_config: None,
215 memory: None,
216 memory_swap: None,
217 memory_reservation: None,
218 cpus: None,
219 on_kill_resume_memory: None,
220 resource_limits: Vec::new(),
221 resolved_limits: serde_json::json!({}),
222 endpoint: None,
223 user: false,
224 user_name: None,
225 keep_user: false,
226 keep_alive: false,
227 auto_remove_docker_container: false,
228 always_cleanup_container: false,
229 keep_container: false,
230 keep_container_on_fail: false,
231 shell: "auto".to_string(),
232 use_command_stream: false,
233 use_formal_ai: false,
234 status: None,
235 list: false,
236 running: false,
237 upload_log: None,
238 output_format: None,
239 stop: None,
240 terminate: None,
241 attach: None,
242 read_only: false,
243 resume: None,
244 resume_all: false,
245 cleanup: false,
246 cleanup_dry_run: false,
247 cleanup_target: None,
248 alias: None,
249 rename: None,
250 new_name: None,
251 append_attempt: false,
252 continues: None,
253 snapshot: Default::default(),
254 }
255 }
256}
257
258#[derive(Debug)]
260pub struct ParsedArgs {
261 pub wrapper_options: WrapperOptions,
263 pub command: String,
266 pub raw_command: Vec<String>,
268}
269
270pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
272 let mut wrapper_options = WrapperOptions::default();
273 let mut command_args: Vec<String> = Vec::new();
274
275 let separator_index = args.iter().position(|a| a == "--");
277
278 if let Some(sep_idx) = separator_index {
279 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
281 command_args = args[sep_idx + 1..].to_vec();
282 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
283 } else {
284 let mut i = 0;
286 while i < args.len() {
287 let arg = &args[i];
288 if arg.starts_with('-') {
289 match parse_option(args, i, &mut wrapper_options)? {
290 0 => {
291 return Err(format!("Unknown wrapper option: {}", arg));
292 }
293 consumed => {
294 i += consumed;
295 }
296 }
297 } else {
298 command_args = args[i..].to_vec();
300 break;
301 }
302 }
303 }
304
305 if !wrapper_options.help {
307 validate_options(&mut wrapper_options)?;
308 }
309
310 Ok(ParsedArgs {
311 wrapper_options,
312 command: crate::isolation::build_command_string(&command_args),
313 raw_command: command_args,
314 })
315}
316
317fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
319 let mut i = 0;
320 while i < args.len() {
321 match parse_option(args, i, options)? {
322 0 => {
323 if args[i].starts_with('-') {
324 return Err(format!("Unknown wrapper option: {}", args[i]));
325 }
326 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
327 eprintln!("Unknown wrapper option: {}", args[i]);
328 }
329 i += 1;
330 }
331 consumed => {
332 i += consumed;
333 }
334 }
335 }
336 Ok(())
337}
338
339fn parse_option(
342 args: &[String],
343 index: usize,
344 options: &mut WrapperOptions,
345) -> Result<usize, String> {
346 let arg = &args[index];
347
348 if arg == "--help" || arg == "-h" {
349 options.help = true;
350 return Ok(1);
351 }
352 if arg == "--no-sanitize" {
353 options.no_sanitize = true;
354 return Ok(1);
355 }
356 if arg == "--remove-original" {
357 options.remove_original = true;
358 return Ok(1);
359 }
360 if arg == "--label" || arg.starts_with("--label=") {
361 let label = if arg == "--label" {
362 args.get(index + 1).map(String::as_str).unwrap_or("")
363 } else {
364 &arg[8..]
365 };
366 let Some((key, _)) = label.split_once('=') else {
367 return Err("--label requires KEY=VALUE".to_string());
368 };
369 if key.is_empty() || key.chars().any(char::is_whitespace) {
370 return Err("--label requires a nonempty key without whitespace".to_string());
371 }
372 if key.starts_with("start-command.") {
373 return Err(
374 "start-command.* labels are reserved for execution attribution".to_string(),
375 );
376 }
377 options.labels.push(label.to_string());
378 return Ok(if arg == "--label" { 2 } else { 1 });
379 }
380
381 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
383 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
384 options.isolated = Some(args[index + 1].to_lowercase());
385 return Ok(2);
386 } else {
387 return Err(format!(
388 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
389 arg
390 ));
391 }
392 }
393
394 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
396 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
397 return Ok(1);
398 }
399
400 if arg == "--attached" || arg == "-a" {
402 options.attached = true;
403 return Ok(1);
404 }
405
406 if arg == "--detached" || arg == "-d" {
408 options.detached = true;
409 return Ok(1);
410 }
411
412 if arg == "--session" || arg == "-s" {
414 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
415 options.session = Some(args[index + 1].clone());
416 return Ok(2);
417 } else {
418 return Err(format!("Option {} requires a session name argument", arg));
419 }
420 }
421
422 if arg.starts_with("--session=") {
424 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
425 return Ok(1);
426 }
427
428 if arg == "--image" {
430 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
431 options.image = Some(args[index + 1].clone());
432 return Ok(2);
433 } else {
434 return Err(format!("Option {} requires an image name argument", arg));
435 }
436 }
437
438 if arg.starts_with("--image=") {
440 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
441 return Ok(1);
442 }
443
444 if arg == "--volume" || arg == "-v" {
446 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
447 options.volumes.push(args[index + 1].clone());
448 return Ok(2);
449 } else {
450 return Err(format!(
451 "Option {} requires a volume argument (host:container[:mode])",
452 arg
453 ));
454 }
455 }
456
457 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
459 options
460 .volumes
461 .push(arg[arg.find('=').unwrap() + 1..].to_string());
462 return Ok(1);
463 }
464
465 if arg == "--mount" {
467 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
468 options.mounts.push(args[index + 1].clone());
469 return Ok(2);
470 } else {
471 return Err(format!("Option {} requires a mount spec argument", arg));
472 }
473 }
474
475 if let Some(value) = arg.strip_prefix("--mount=") {
477 options.mounts.push(value.to_string());
478 return Ok(1);
479 }
480
481 if arg == "--env" || arg == "-e" {
483 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
484 options.env.push(args[index + 1].clone());
485 return Ok(2);
486 } else {
487 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
488 }
489 }
490
491 if arg.starts_with("--env=") || arg.starts_with("-e=") {
493 options
494 .env
495 .push(arg[arg.find('=').unwrap() + 1..].to_string());
496 return Ok(1);
497 }
498
499 if arg == "--privileged" {
501 options.privileged = true;
502 return Ok(1);
503 }
504
505 let init_consumed = crate::docker_init_option::parse(args, index, options);
507 if init_consumed > 0 {
508 return Ok(init_consumed);
509 }
510
511 if arg == "--network" {
513 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
514 let network = args[index + 1].clone();
515 options.networks.push(network.clone());
516 if options.network.is_none() {
517 options.network = Some(network);
518 }
519 return Ok(2);
520 }
521 return Err(format!("Option {} requires a network name argument", arg));
522 }
523
524 if let Some(value) = arg.strip_prefix("--network=") {
526 let network = value.to_string();
527 options.networks.push(network.clone());
528 if options.network.is_none() {
529 options.network = Some(network);
530 }
531 return Ok(1);
532 }
533
534 if arg == "--network-alias" {
536 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
537 options.network_aliases.push(args[index + 1].clone());
538 return Ok(2);
539 }
540 return Err(format!("Option {} requires an alias argument", arg));
541 }
542
543 if let Some(value) = arg.strip_prefix("--network-alias=") {
545 options.network_aliases.push(value.to_string());
546 return Ok(1);
547 }
548
549 if arg == "--endpoint" {
551 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
552 options.endpoint = Some(args[index + 1].clone());
553 return Ok(2);
554 } else {
555 return Err(format!("Option {} requires an endpoint argument", arg));
556 }
557 }
558
559 if arg.starts_with("--endpoint=") {
561 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
562 return Ok(1);
563 }
564
565 if arg == "--isolated-user" || arg == "-u" {
567 options.user = true;
568 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
570 let next_arg = &args[index + 1];
571 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
573 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
574 options.user_name = Some(next_arg.clone());
575 return Ok(2);
576 }
577 }
578 return Ok(1);
579 }
580
581 if arg.starts_with("--isolated-user=") {
583 options.user = true;
584 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
585 return Ok(1);
586 }
587
588 if arg == "--keep-user" {
590 options.keep_user = true;
591 return Ok(1);
592 }
593
594 if arg == "--keep-alive" || arg == "-k" {
596 options.keep_alive = true;
597 return Ok(1);
598 }
599
600 if arg == "--auto-remove-docker-container" {
602 options.auto_remove_docker_container = true;
603 return Ok(1);
604 }
605
606 if arg == "--always-cleanup-container" {
608 options.always_cleanup_container = true;
609 return Ok(1);
610 }
611
612 if arg == "--keep-container" {
614 options.keep_container = true;
615 return Ok(1);
616 }
617
618 if arg == "--keep-container-on-fail" {
620 options.keep_container_on_fail = true;
621 return Ok(1);
622 }
623
624 if arg == "--shell" {
626 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
627 options.shell = args[index + 1].to_lowercase();
628 return Ok(2);
629 } else {
630 return Err(format!(
631 "Option {} requires a shell argument (auto, bash, zsh, sh)",
632 arg
633 ));
634 }
635 }
636
637 if arg.starts_with("--shell=") {
639 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
640 return Ok(1);
641 }
642
643 if arg == "--use-command-stream" {
645 options.use_command_stream = true;
646 return Ok(1);
647 }
648
649 if arg == "--use-formal-ai" {
650 options.use_formal_ai = true;
651 return Ok(1);
652 }
653
654 if arg == "--session-id" || arg == "--session-name" {
656 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
657 options.session_id = Some(args[index + 1].clone());
658 return Ok(2);
659 } else {
660 return Err(format!("Option {} requires a UUID argument", arg));
661 }
662 }
663
664 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
666 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
667 return Ok(1);
668 }
669
670 let cpu_consumed = crate::cpu_penalty::parse(args, index, options)?;
671 if cpu_consumed > 0 {
672 return Ok(cpu_consumed);
673 }
674 let resource_consumed = crate::docker_resource_options::parse(args, index, options)?;
675 if resource_consumed > 0 {
676 return Ok(resource_consumed);
677 }
678 let recovery_consumed = parse_docker_recovery_option(args, index, options)?;
680 if recovery_consumed > 0 {
681 return Ok(recovery_consumed);
682 }
683
684 let snapshot_consumed = crate::snapshot_resume_options::parse(args, index, options)?;
686 if snapshot_consumed > 0 {
687 return Ok(snapshot_consumed);
688 }
689
690 let query_consumed = parse_query_option(args, index, options)?;
692 if query_consumed > 0 {
693 return Ok(query_consumed);
694 }
695
696 let identity_consumed = crate::launch_identity_options::parse(args, index, options)?;
698 if identity_consumed > 0 {
699 return Ok(identity_consumed);
700 }
701
702 Ok(0)
704}
705
706pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
708 if options.no_sanitize && options.upload_log.is_none() {
709 return Err("--no-sanitize is only valid with --upload-log".to_string());
710 }
711 if options.remove_original && options.resume.is_none() {
712 return Err("--remove-original is only valid with --resume".to_string());
713 }
714 if !options.labels.is_empty()
715 && options.isolated.as_deref() != Some("docker")
716 && options.resume.is_none()
717 {
718 return Err("--label is only valid with docker isolation or --resume".to_string());
719 }
720 if options.attached && options.detached {
722 return Err(
723 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
724 .to_string(),
725 );
726 }
727
728 if let Some(ref backend) = options.isolated {
730 if !VALID_BACKENDS.contains(&backend.as_str()) {
731 return Err(format!(
732 "Invalid isolation backend: \"{}\". Valid options are: {}",
733 backend,
734 VALID_BACKENDS.join(", ")
735 ));
736 }
737
738 if backend == "docker" && options.image.is_none() {
740 options.image = Some(get_default_docker_image());
741 }
742
743 if backend == "ssh" && options.endpoint.is_none() {
745 return Err(
746 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
747 .to_string(),
748 );
749 }
750 }
751
752 if options.session.is_some() && options.isolated.is_none() {
754 return Err("--session option is only valid with --isolated".to_string());
755 }
756
757 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
759 return Err("--image option is only valid with --isolated docker".to_string());
760 }
761
762 let is_docker = options.isolated.as_deref() == Some("docker");
764 if !options.volumes.is_empty() && !is_docker {
765 return Err("--volume option is only valid with --isolated docker".to_string());
766 }
767 if !options.mounts.is_empty() && !is_docker {
768 return Err("--mount option is only valid with --isolated docker".to_string());
769 }
770 if !options.env.is_empty() && !is_docker {
771 return Err("--env option is only valid with --isolated docker".to_string());
772 }
773 if options.privileged && !is_docker {
774 return Err("--privileged option is only valid with --isolated docker".to_string());
775 }
776 if options.network.is_some() && !is_docker {
777 return Err("--network option is only valid with --isolated docker".to_string());
778 }
779 if !options.network_aliases.is_empty() && !is_docker {
780 return Err("--network-alias option is only valid with --isolated docker".to_string());
781 }
782
783 validate_docker_recovery_options(options)?;
784
785 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
787 return Err("--endpoint option is only valid with --isolated ssh".to_string());
788 }
789
790 if options.keep_alive && options.isolated.is_none() {
792 return Err("--keep-alive option is only valid with --isolated".to_string());
793 }
794
795 let cleanup_flags = [
797 (
798 "--auto-remove-docker-container",
799 options.auto_remove_docker_container,
800 ),
801 (
802 "--always-cleanup-container",
803 options.always_cleanup_container,
804 ),
805 ("--keep-container", options.keep_container),
806 ("--keep-container-on-fail", options.keep_container_on_fail),
807 ];
808 for (flag, enabled) in cleanup_flags {
809 if enabled && !is_docker {
810 return Err(format!(
811 "{} option is only valid with --isolated docker",
812 flag
813 ));
814 }
815 }
816
817 let selected_cleanup_policies = [
818 options.auto_remove_docker_container || options.always_cleanup_container,
819 options.keep_container,
820 options.keep_container_on_fail,
821 ]
822 .into_iter()
823 .filter(|enabled| *enabled)
824 .count();
825 if selected_cleanup_policies > 1 {
826 return Err(
827 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
828 .to_string(),
829 );
830 }
831
832 if options.user {
834 if options.isolated.as_deref() == Some("docker") {
836 return Err(
837 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
838 .to_string(),
839 );
840 }
841 if let Some(ref username) = options.user_name {
843 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
844 if !username_regex.is_match(username) {
845 return Err(format!(
846 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
847 username
848 ));
849 }
850 if username.len() > 32 {
851 return Err(format!(
852 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
853 username
854 ));
855 }
856 }
857 }
858
859 if options.keep_user && !options.user {
861 return Err("--keep-user option is only valid with --isolated-user".to_string());
862 }
863
864 crate::snapshot_resume_options::validate(options)?;
865 validate_query_options(options)?;
867 crate::launch_identity_options::validate(options)?;
868 crate::formal_ai_cli::validate_options(options)?;
869 crate::docker_resource_options::validate(options)?;
870 crate::docker_init_option::validate(options)?;
871 crate::cpu_penalty::validate(options)?;
872
873 if !VALID_SHELLS.contains(&options.shell.as_str()) {
875 return Err(format!(
876 "Invalid shell: \"{}\". Valid options are: {}",
877 options.shell,
878 VALID_SHELLS.join(", ")
879 ));
880 }
881
882 if let Some(ref session_id) = options.session_id {
884 if !is_valid_uuid(session_id) {
885 return Err(format!(
886 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
887 session_id
888 ));
889 }
890 }
891
892 Ok(())
893}
894
895pub fn generate_session_name(prefix: Option<&str>) -> String {
897 use std::cell::RefCell;
898 use std::time::{SystemTime, UNIX_EPOCH};
899
900 thread_local! {
901 static STATE: RefCell<u64> = RefCell::new(
902 SystemTime::now()
903 .duration_since(UNIX_EPOCH)
904 .unwrap()
905 .as_nanos() as u64
906 );
907 }
908
909 fn next_random() -> u64 {
910 STATE.with(|state| {
911 let mut s = state.borrow_mut();
912 *s ^= *s << 13;
913 *s ^= *s >> 7;
914 *s ^= *s << 17;
915 *s
916 })
917 }
918
919 let prefix = prefix.unwrap_or("start");
920 let timestamp = chrono::Utc::now().timestamp_millis();
921 let random: String = (0..6)
922 .map(|_| {
923 let idx = (next_random() % 36) as u8;
924 if idx < 10 {
925 (b'0' + idx) as char
926 } else {
927 (b'a' + idx - 10) as char
928 }
929 })
930 .collect();
931 format!("{}-{}-{}", prefix, timestamp, random)
932}
933
934pub fn has_isolation(options: &WrapperOptions) -> bool {
936 options.isolated.is_some()
937}
938
939pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
942 if options.detached {
943 "detached"
944 } else {
945 "attached"
947 }
948}
949
950#[cfg(test)]
951#[path = "args_parser_cases.rs"]
952mod tests;