1pub fn resolve_binary_path(command: &str) -> Result<String, String> {
26 resolve_binary_path_inner(command, 0)
28}
29
30pub fn resolve_binary_path_for_ssl(command: &str) -> Result<Option<String>, String> {
38 let launcher = resolve_command_path(command)?;
39 if is_openai_codex_native_binary(&launcher) {
40 return Ok(Some(canonicalize_path(&launcher)));
41 }
42 if let Some(path) = codex_native_binary_from_launcher(&launcher) {
43 return Ok(Some(path));
44 }
45
46 let resolved = resolve_binary_path(command)?;
47 if binary_embeds_ssl(&resolved) {
48 Ok(Some(resolved))
49 } else {
50 Ok(None)
51 }
52}
53
54fn resolve_binary_path_inner(command: &str, depth: u8) -> Result<String, String> {
55 if depth > 5 {
56 return Err(format!(
57 "too many nested shebang wrappers resolving '{}'",
58 command
59 ));
60 }
61
62 let resolved = resolve_command_path(command)?;
63
64 let mut header = [0u8; 256];
66 let n = {
67 use std::io::Read;
68 let mut f = std::fs::File::open(&resolved)
69 .map_err(|e| format!("cannot open '{}': {}", resolved.display(), e))?;
70 f.read(&mut header)
71 .map_err(|e| format!("cannot read '{}': {}", resolved.display(), e))?
72 };
73 let header = &header[..n];
74
75 if header.starts_with(b"\x7fELF") {
76 return Ok(resolved.to_string_lossy().into_owned());
77 }
78
79 if header.starts_with(b"#!") {
80 let line_end = header
82 .iter()
83 .position(|&b| b == b'\n')
84 .unwrap_or(header.len());
85 let line = String::from_utf8_lossy(&header[2..line_end]);
86 let mut parts = line.split_whitespace();
87 let interp = parts
88 .next()
89 .ok_or_else(|| format!("'{}' has an empty shebang", resolved.display()))?;
90 let next = if interp.ends_with("/env") || interp == "env" {
92 parts
93 .next()
94 .ok_or_else(|| format!("'{}' uses env with no interpreter", resolved.display()))?
95 } else {
96 interp
97 };
98 return resolve_binary_path_inner(next, depth + 1);
99 }
100
101 Err(format!(
102 "'{}' is neither an ELF binary nor a shebang script; specify --binary-path explicitly",
103 resolved.display()
104 ))
105}
106
107fn resolve_command_path(command: &str) -> Result<std::path::PathBuf, String> {
109 let candidate = if command.contains('/') {
110 std::path::PathBuf::from(command)
111 } else {
112 find_in_path(command).ok_or_else(|| format!("'{}' not found in $PATH", command))?
113 };
114
115 std::fs::canonicalize(&candidate)
116 .map_err(|e| format!("cannot resolve '{}': {}", candidate.display(), e))
117}
118
119fn find_in_path(cmd: &str) -> Option<std::path::PathBuf> {
126 let mut dirs: Vec<std::path::PathBuf> = Vec::new();
127
128 if let Some(path) = std::env::var_os("PATH") {
129 dirs.extend(std::env::split_paths(&path));
130 }
131
132 if let Some(user) = std::env::var_os("SUDO_USER")
133 && let Some(home) = sudo_user_home(&user)
134 {
135 dirs.push(home.join(".local/bin"));
136 dirs.push(home.join("bin"));
137 if let Some(nvm_bin) = newest_nvm_bin(&home) {
139 dirs.push(nvm_bin);
140 }
141 }
142
143 find_executable_in_dirs(cmd, dirs)
144}
145
146fn find_executable_in_dirs(
147 cmd: &str,
148 dirs: impl IntoIterator<Item = std::path::PathBuf>,
149) -> Option<std::path::PathBuf> {
150 for dir in dirs {
151 let full = dir.join(cmd);
152 if let Ok(meta) = std::fs::metadata(&full)
153 && meta.is_file()
154 {
155 return Some(full);
156 }
157 }
158 None
159}
160
161fn sudo_user_home(user: &std::ffi::OsStr) -> Option<std::path::PathBuf> {
163 let user = user.to_str()?;
164 let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
165 for line in passwd.lines() {
166 let mut fields = line.split(':');
167 if fields.next() == Some(user) {
168 return fields.nth(4).map(std::path::PathBuf::from);
170 }
171 }
172 None
173}
174
175fn newest_nvm_bin(home: &std::path::Path) -> Option<std::path::PathBuf> {
177 let versions = home.join(".nvm/versions/node");
178 let mut entries: Vec<_> = std::fs::read_dir(&versions)
179 .ok()?
180 .filter_map(|e| e.ok())
181 .map(|e| e.path())
182 .collect();
183 entries.sort();
184 entries.last().map(|p| p.join("bin"))
185}
186
187pub fn binary_embeds_ssl(path: &str) -> bool {
197 use std::io::Read;
198 const NEEDLES: &[&[u8]] = &[b"SSL_write", b"BoringSSLError", b"OPENSSL_internal"];
199 let mut f = match std::fs::File::open(path) {
200 Ok(f) => f,
201 Err(_) => return false,
202 };
203 let mut buf = vec![0u8; 1 << 20]; let mut carry: Vec<u8> = Vec::new();
206 let keep = NEEDLES
207 .iter()
208 .map(|needle| needle.len())
209 .max()
210 .unwrap_or(1)
211 .saturating_sub(1);
212 loop {
213 let n = match f.read(&mut buf) {
214 Ok(0) => break,
215 Ok(n) => n,
216 Err(_) => return false,
217 };
218 carry.extend_from_slice(&buf[..n]);
219 if NEEDLES
220 .iter()
221 .any(|needle| carry.windows(needle.len()).any(|w| w == *needle))
222 {
223 return true;
224 }
225 if carry.len() > keep {
226 carry.drain(..carry.len() - keep);
227 }
228 }
229 false
230}
231
232fn codex_native_binary_from_launcher(launcher: &std::path::Path) -> Option<String> {
233 let package_root = openai_codex_package_root(launcher)?;
234
235 let nested_scope = package_root.join("node_modules").join("@openai");
236 if let Some(path) = codex_native_binary_in_scope(&nested_scope) {
237 return Some(path);
238 }
239 if let Some(scope) = package_root.parent() {
240 if let Some(path) = codex_native_binary_in_scope(scope) {
241 return Some(path);
242 }
243 }
244
245 None
246}
247
248fn codex_native_binary_in_scope(openai_scope: &std::path::Path) -> Option<String> {
249 const CANDIDATES: &[(&str, &str)] = &[
250 ("codex-linux-x64", "x86_64-unknown-linux-musl"),
251 ("codex-linux-arm64", "aarch64-unknown-linux-musl"),
252 ];
253
254 for (package, target) in CANDIDATES {
255 let path = openai_scope
256 .join(package)
257 .join("vendor")
258 .join(target)
259 .join("bin")
260 .join("codex");
261 if is_elf_file(&path) {
262 return Some(canonicalize_path(&path));
263 }
264 }
265 None
266}
267
268fn is_openai_codex_native_binary(path: &std::path::Path) -> bool {
269 if !is_elf_file(path) || !file_name_eq(path, "codex") {
270 return false;
271 }
272
273 let Some(bin_dir) = path.parent().filter(|p| file_name_eq(p, "bin")) else {
274 return false;
275 };
276 let Some(target_dir) = bin_dir.parent().filter(|p| {
277 file_name_eq(p, "x86_64-unknown-linux-musl")
278 || file_name_eq(p, "aarch64-unknown-linux-musl")
279 }) else {
280 return false;
281 };
282 let Some(vendor_dir) = target_dir.parent().filter(|p| file_name_eq(p, "vendor")) else {
283 return false;
284 };
285 let Some(package_dir) = vendor_dir
286 .parent()
287 .filter(|p| file_name_eq(p, "codex-linux-x64") || file_name_eq(p, "codex-linux-arm64"))
288 else {
289 return false;
290 };
291 package_dir
292 .parent()
293 .is_some_and(|parent| file_name_eq(parent, "@openai"))
294}
295
296fn openai_codex_package_root(path: &std::path::Path) -> Option<std::path::PathBuf> {
297 for ancestor in path.ancestors() {
298 if file_name_eq(ancestor, "codex")
299 && ancestor
300 .parent()
301 .is_some_and(|parent| file_name_eq(parent, "@openai"))
302 && ancestor
303 .parent()
304 .and_then(|parent| parent.parent())
305 .is_some_and(|parent| file_name_eq(parent, "node_modules"))
306 {
307 return Some(ancestor.to_path_buf());
308 }
309 }
310 None
311}
312
313fn file_name_eq(path: &std::path::Path, expected: &str) -> bool {
314 path.file_name().and_then(|name| name.to_str()) == Some(expected)
315}
316
317fn is_elf_file(path: &std::path::Path) -> bool {
318 use std::io::Read;
319 let mut header = [0u8; 4];
320 let mut f = match std::fs::File::open(path) {
321 Ok(f) => f,
322 Err(_) => return false,
323 };
324 f.read_exact(&mut header).is_ok() && header == *b"\x7fELF"
325}
326
327fn canonicalize_path(path: &std::path::Path) -> String {
328 std::fs::canonicalize(path)
329 .map(|p| p.to_string_lossy().into_owned())
330 .unwrap_or_else(|_| path.to_string_lossy().into_owned())
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334struct KubernetesRef<'a> {
335 namespace: &'a str,
336 pod: &'a str,
337 container: Option<&'a str>,
338}
339
340impl KubernetesRef<'_> {
341 fn label(&self) -> String {
342 match self.container {
343 Some(container) => format!("k8s://{}/{}/{}", self.namespace, self.pod, container),
344 None => format!("k8s://{}/{}", self.namespace, self.pod),
345 }
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
350struct RuntimeContainerRef {
351 runtime: String,
352 id: String,
353}
354
355pub fn parse_container_ref(binary_path: &str) -> Option<&str> {
359 binary_path
360 .strip_prefix("docker://")
361 .or_else(|| binary_path.strip_prefix("docker:"))
362 .filter(|r| !r.is_empty() && !r.contains('/'))
363}
364
365fn has_docker_scheme(binary_path: &str) -> bool {
366 binary_path.starts_with("docker://") || binary_path.starts_with("docker:")
367}
368
369fn parse_kubernetes_ref(binary_path: &str) -> Option<KubernetesRef<'_>> {
377 let reference = binary_path
378 .strip_prefix("k8s://")
379 .or_else(|| binary_path.strip_prefix("k8s:"))
380 .or_else(|| binary_path.strip_prefix("kubernetes://"))
381 .or_else(|| binary_path.strip_prefix("kubernetes:"))?;
382
383 let parts = reference.split('/').collect::<Vec<_>>();
384 if parts.is_empty() || parts.iter().any(|part| part.is_empty()) {
385 return None;
386 }
387
388 match parts.as_slice() {
389 [pod] => Some(KubernetesRef {
390 namespace: "default",
391 pod,
392 container: None,
393 }),
394 [namespace, pod] => Some(KubernetesRef {
395 namespace,
396 pod,
397 container: None,
398 }),
399 [namespace, pod, container] => Some(KubernetesRef {
400 namespace,
401 pod,
402 container: Some(container),
403 }),
404 _ => None,
405 }
406}
407
408fn has_kubernetes_scheme(binary_path: &str) -> bool {
409 binary_path.starts_with("k8s://")
410 || binary_path.starts_with("k8s:")
411 || binary_path.starts_with("kubernetes://")
412 || binary_path.starts_with("kubernetes:")
413}
414
415pub fn resolve_container_binary_arg(
416 binary_path: Option<&str>,
417) -> Result<Option<(String, String)>, String> {
418 let Some(binary_path) = binary_path else {
419 return Ok(None);
420 };
421
422 if let Some(reference) = parse_container_ref(binary_path) {
423 return resolve_container_binary_path(reference)
424 .map(|path| Some((reference.to_string(), path)));
425 }
426 if has_docker_scheme(binary_path) {
427 return Err(format!(
428 "invalid Docker container reference '{}'; expected docker://<name|id>",
429 binary_path
430 ));
431 }
432
433 if let Some(reference) = parse_kubernetes_ref(binary_path) {
434 let label = reference.label();
435 return resolve_kubernetes_binary_path(&reference).map(|path| Some((label, path)));
436 }
437 if has_kubernetes_scheme(binary_path) {
438 return Err(format!(
439 "invalid Kubernetes pod reference '{}'; expected k8s://pod, k8s://namespace/pod, or k8s://namespace/pod/container",
440 binary_path
441 ));
442 }
443
444 Ok(None)
445}
446
447pub fn resolve_container_binary_path(reference: &str) -> Result<String, String> {
460 let init_pid = resolve_docker_container_pid(reference)?;
461
462 find_ssl_target_in_tree(init_pid).ok_or_else(|| {
463 format!(
464 "container '{}' is running at host PID {}, but no SSL attach target was found in its process tree",
465 reference, init_pid
466 )
467 })
468}
469
470fn resolve_docker_container_pid(reference: &str) -> Result<u32, String> {
471 let output = std::process::Command::new("docker")
472 .args(["inspect", "--format", "{{.State.Pid}}", reference])
473 .output()
474 .map_err(|e| format!(
475 "failed to run `docker inspect` for container '{}': {} (is the Docker CLI installed and on $PATH?)",
476 reference, e
477 ))?;
478
479 if !output.status.success() {
480 let stderr = String::from_utf8_lossy(&output.stderr);
481 return Err(format!(
482 "`docker inspect {}` failed: {}",
483 reference,
484 stderr.trim()
485 ));
486 }
487
488 let init_pid: u32 = String::from_utf8_lossy(&output.stdout)
489 .trim()
490 .parse()
491 .map_err(|_| format!("could not determine host PID for container '{}'", reference))?;
492
493 if init_pid == 0 {
494 return Err(format!(
495 "container '{}' is not running (host PID 0)",
496 reference
497 ));
498 }
499
500 Ok(init_pid)
501}
502
503fn resolve_kubernetes_binary_path(reference: &KubernetesRef<'_>) -> Result<String, String> {
504 let pod = kubectl_get_pod(reference)?;
505 let container_id = select_kubernetes_container_id(&pod, reference)?;
506 let runtime = parse_runtime_container_id(&container_id)?;
507 let init_pid = resolve_runtime_container_pid(&runtime)?;
508
509 find_ssl_target_in_tree(init_pid).ok_or_else(|| {
510 let node = pod
511 .pointer("/spec/nodeName")
512 .and_then(serde_json::Value::as_str)
513 .unwrap_or("unknown");
514 format!(
515 "Kubernetes pod '{}/{}' container target '{}' is running at host PID {}, but no SSL attach target was found in its process tree. AgentSight must run on the node that hosts the pod (node: {}).",
516 reference.namespace,
517 reference.pod,
518 runtime.id,
519 init_pid,
520 node
521 )
522 })
523}
524
525fn kubectl_get_pod(reference: &KubernetesRef<'_>) -> Result<serde_json::Value, String> {
526 let output = kubectl_command()
527 .args([
528 "get",
529 "pod",
530 reference.pod,
531 "-n",
532 reference.namespace,
533 "-o",
534 "json",
535 ])
536 .output()
537 .map_err(|e| format!(
538 "failed to run `kubectl get pod {}` in namespace '{}': {} (is kubectl installed and configured?)",
539 reference.pod, reference.namespace, e
540 ))?;
541
542 if !output.status.success() {
543 let stderr = String::from_utf8_lossy(&output.stderr);
544 return Err(format!(
545 "`kubectl get pod {} -n {}` failed: {}",
546 reference.pod,
547 reference.namespace,
548 stderr.trim()
549 ));
550 }
551
552 serde_json::from_slice(&output.stdout).map_err(|e| {
553 format!(
554 "`kubectl get pod {} -n {} -o json` returned invalid JSON: {}",
555 reference.pod, reference.namespace, e
556 )
557 })
558}
559
560fn kubectl_command() -> std::process::Command {
561 let mut command = std::process::Command::new("kubectl");
562 if std::env::var_os("KUBECONFIG").is_none()
563 && let Some(user) = std::env::var_os("SUDO_USER")
564 && let Some(home) = sudo_user_home(&user)
565 {
566 let kubeconfig = home.join(".kube/config");
567 if kubeconfig.is_file() {
568 command.env("KUBECONFIG", kubeconfig);
569 }
570 }
571 command
572}
573
574fn select_kubernetes_container_id(
575 pod: &serde_json::Value,
576 reference: &KubernetesRef<'_>,
577) -> Result<String, String> {
578 let statuses = pod
579 .pointer("/status/containerStatuses")
580 .and_then(serde_json::Value::as_array)
581 .ok_or_else(|| {
582 format!(
583 "Kubernetes pod '{}/{}' has no status.containerStatuses yet",
584 reference.namespace, reference.pod
585 )
586 })?;
587
588 if let Some(container) = reference.container {
589 let status = statuses
590 .iter()
591 .find(|status| {
592 status.get("name").and_then(serde_json::Value::as_str) == Some(container)
593 })
594 .ok_or_else(|| {
595 format!(
596 "Kubernetes pod '{}/{}' has no container named '{}'",
597 reference.namespace, reference.pod, container
598 )
599 })?;
600 return container_id_from_status(status).ok_or_else(|| {
601 format!(
602 "Kubernetes pod '{}/{}' container '{}' has no containerID yet (is it running?)",
603 reference.namespace, reference.pod, container
604 )
605 });
606 }
607
608 let containers = statuses
609 .iter()
610 .filter_map(|status| {
611 let name = status.get("name").and_then(serde_json::Value::as_str)?;
612 let id = container_id_from_status(status)?;
613 Some((name, id))
614 })
615 .collect::<Vec<_>>();
616
617 match containers.as_slice() {
618 [(_, id)] => Ok(id.clone()),
619 [] => Err(format!(
620 "Kubernetes pod '{}/{}' has no running containers with a containerID",
621 reference.namespace, reference.pod
622 )),
623 _ => {
624 let names = containers
625 .iter()
626 .map(|(name, _)| *name)
627 .collect::<Vec<_>>()
628 .join(", ");
629 Err(format!(
630 "Kubernetes pod '{}/{}' has multiple containers ({}); specify one as k8s://{}/{}/<container>",
631 reference.namespace, reference.pod, names, reference.namespace, reference.pod
632 ))
633 }
634 }
635}
636
637fn container_id_from_status(status: &serde_json::Value) -> Option<String> {
638 status.pointer("/state/running")?;
639 let id = status.get("containerID")?.as_str()?.trim();
640 (!id.is_empty()).then(|| id.to_string())
641}
642
643fn parse_runtime_container_id(container_id: &str) -> Result<RuntimeContainerRef, String> {
644 let (runtime, id) = container_id.split_once("://").ok_or_else(|| {
645 format!(
646 "Kubernetes containerID '{}' is missing a runtime scheme",
647 container_id
648 )
649 })?;
650 let id = id.trim();
651 if runtime.trim().is_empty() || id.is_empty() {
652 return Err(format!(
653 "Kubernetes containerID '{}' is incomplete",
654 container_id
655 ));
656 }
657 Ok(RuntimeContainerRef {
658 runtime: runtime.to_string(),
659 id: id.to_string(),
660 })
661}
662
663fn resolve_runtime_container_pid(container: &RuntimeContainerRef) -> Result<u32, String> {
664 match container.runtime.as_str() {
665 "docker" => resolve_docker_container_pid(&container.id),
666 "containerd" | "cri-o" | "crio" => resolve_cri_container_pid(&container.id),
667 other => resolve_cri_container_pid(&container.id).map_err(|e| {
668 format!(
669 "unsupported Kubernetes container runtime '{}' for container '{}': {}",
670 other, container.id, e
671 )
672 }),
673 }
674}
675
676fn resolve_cri_container_pid(container_id: &str) -> Result<u32, String> {
677 let output = std::process::Command::new("crictl")
678 .args(["inspect", "--output", "json", container_id])
679 .output()
680 .map_err(|e| format!(
681 "failed to run `crictl inspect` for container '{}': {} (is crictl installed and configured for this node's CRI runtime?)",
682 container_id, e
683 ))?;
684
685 if !output.status.success() {
686 let stderr = String::from_utf8_lossy(&output.stderr);
687 return Err(format!(
688 "`crictl inspect {}` failed: {}",
689 container_id,
690 stderr.trim()
691 ));
692 }
693
694 let value: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
695 format!(
696 "`crictl inspect --output json {}` returned invalid JSON: {}",
697 container_id, e
698 )
699 })?;
700
701 parse_crictl_pid(&value).ok_or_else(|| {
702 format!(
703 "could not determine host PID for CRI container '{}'",
704 container_id
705 )
706 })
707}
708
709fn parse_crictl_pid(value: &serde_json::Value) -> Option<u32> {
710 ["/info/pid", "/status/pid"]
711 .into_iter()
712 .filter_map(|path| value.pointer(path))
713 .find_map(value_as_u32)
714 .filter(|pid| *pid != 0)
715}
716
717fn value_as_u32(value: &serde_json::Value) -> Option<u32> {
718 if let Some(pid) = value.as_u64() {
719 return u32::try_from(pid).ok();
720 }
721 value.as_str()?.parse().ok()
722}
723
724fn find_ssl_target_in_tree(root_pid: u32) -> Option<String> {
731 let mut queue = std::collections::VecDeque::from([root_pid]);
732 let mut seen = std::collections::HashSet::new();
733 while let Some(pid) = queue.pop_front() {
734 if !seen.insert(pid) {
735 continue;
736 }
737 let exe = format!("/proc/{}/exe", pid);
738 if binary_embeds_ssl(&exe) {
739 return Some(canonicalize_attach_path(&exe));
740 }
741 if let Some(path) = find_loaded_ssl_library(pid) {
742 return Some(path);
743 }
744 let children_path = format!("/proc/{}/task/{}/children", pid, pid);
745 if let Ok(children) = std::fs::read_to_string(&children_path) {
746 for child in children
747 .split_whitespace()
748 .filter_map(|s| s.parse::<u32>().ok())
749 {
750 queue.push_back(child);
751 }
752 }
753 }
754 None
755}
756
757fn find_loaded_ssl_library(pid: u32) -> Option<String> {
758 let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).ok()?;
759 for line in maps.lines() {
760 let path = line.split_whitespace().last()?;
761 if !path.starts_with('/') || !path.contains("libssl.so") {
762 continue;
763 }
764 let host_path = format!("/proc/{pid}/root{path}");
765 if std::fs::metadata(&host_path).is_ok() {
766 return Some(canonicalize_attach_path(&host_path));
767 }
768 }
769 None
770}
771
772fn canonicalize_attach_path(path: &str) -> String {
773 std::fs::canonicalize(path)
774 .map(|p| p.to_string_lossy().into_owned())
775 .unwrap_or_else(|_| path.to_string())
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use serde_json::json;
782
783 #[test]
784 fn parses_docker_double_slash_scheme() {
785 assert_eq!(parse_container_ref("docker://openclaw"), Some("openclaw"));
786 assert_eq!(
787 parse_container_ref("docker://my-agent-1"),
788 Some("my-agent-1")
789 );
790 }
791
792 #[test]
793 fn parses_docker_colon_scheme() {
794 assert_eq!(parse_container_ref("docker:openclaw"), Some("openclaw"));
795 assert_eq!(
797 parse_container_ref("docker:abc123def456"),
798 Some("abc123def456")
799 );
800 }
801
802 #[test]
803 fn ignores_plain_filesystem_paths() {
804 assert_eq!(parse_container_ref("/proc/1234/exe"), None);
805 assert_eq!(parse_container_ref("/usr/bin/node"), None);
806 assert_eq!(
807 parse_container_ref("~/.nvm/versions/node/v20.0.0/bin/node"),
808 None
809 );
810 }
811
812 #[test]
813 fn rejects_empty_container_reference() {
814 assert_eq!(parse_container_ref("docker://"), None);
815 assert_eq!(parse_container_ref("docker:"), None);
816 }
817
818 #[test]
819 fn rejects_slash_separated_docker_reference() {
820 assert_eq!(parse_container_ref("docker://foo/bar"), None);
821 assert_eq!(parse_container_ref("docker:foo/bar"), None);
822 }
823
824 #[test]
825 fn parses_kubernetes_pod_reference_with_default_namespace() {
826 assert_eq!(
827 parse_kubernetes_ref("k8s://openclaw"),
828 Some(KubernetesRef {
829 namespace: "default",
830 pod: "openclaw",
831 container: None,
832 })
833 );
834 }
835
836 #[test]
837 fn parses_kubernetes_namespaced_pod_reference() {
838 assert_eq!(
839 parse_kubernetes_ref("k8s://agents/openclaw"),
840 Some(KubernetesRef {
841 namespace: "agents",
842 pod: "openclaw",
843 container: None,
844 })
845 );
846 }
847
848 #[test]
849 fn parses_kubernetes_container_reference() {
850 let reference = parse_kubernetes_ref("kubernetes://agents/openclaw/gateway");
851 assert_eq!(
852 reference,
853 Some(KubernetesRef {
854 namespace: "agents",
855 pod: "openclaw",
856 container: Some("gateway"),
857 })
858 );
859 assert_eq!(
860 reference.as_ref().map(KubernetesRef::label),
861 Some("k8s://agents/openclaw/gateway".to_string())
862 );
863 }
864
865 #[test]
866 fn rejects_invalid_kubernetes_references() {
867 assert_eq!(parse_kubernetes_ref("k8s://"), None);
868 assert_eq!(parse_kubernetes_ref("k8s://agents/"), None);
869 assert_eq!(
870 parse_kubernetes_ref("k8s://agents/openclaw/gateway/extra"),
871 None
872 );
873 assert_eq!(parse_kubernetes_ref("/usr/bin/node"), None);
874 }
875
876 #[test]
877 fn invalid_container_scheme_errors_before_running_external_tools() {
878 assert!(
879 resolve_container_binary_arg(Some("docker://"))
880 .unwrap_err()
881 .contains("invalid Docker container reference")
882 );
883 assert!(
884 resolve_container_binary_arg(Some("k8s://agents/openclaw/gateway/extra"))
885 .unwrap_err()
886 .contains("invalid Kubernetes pod reference")
887 );
888 }
889
890 #[test]
891 fn selects_single_kubernetes_container_id() {
892 let pod = json!({
893 "status": {
894 "containerStatuses": [
895 {
896 "name": "gateway",
897 "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
898 "containerID": "containerd://abc123"
899 }
900 ]
901 }
902 });
903 let reference = KubernetesRef {
904 namespace: "agents",
905 pod: "openclaw",
906 container: None,
907 };
908
909 assert_eq!(
910 select_kubernetes_container_id(&pod, &reference).unwrap(),
911 "containerd://abc123"
912 );
913 }
914
915 #[test]
916 fn selects_explicit_kubernetes_container_id() {
917 let pod = json!({
918 "status": {
919 "containerStatuses": [
920 {
921 "name": "sidecar",
922 "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
923 "containerID": "containerd://sidecar123"
924 },
925 {
926 "name": "gateway",
927 "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
928 "containerID": "containerd://gateway123"
929 }
930 ]
931 }
932 });
933 let reference = KubernetesRef {
934 namespace: "agents",
935 pod: "openclaw",
936 container: Some("gateway"),
937 };
938
939 assert_eq!(
940 select_kubernetes_container_id(&pod, &reference).unwrap(),
941 "containerd://gateway123"
942 );
943 }
944
945 #[test]
946 fn requires_container_name_for_multi_container_pod() {
947 let pod = json!({
948 "status": {
949 "containerStatuses": [
950 {
951 "name": "sidecar",
952 "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
953 "containerID": "containerd://sidecar123"
954 },
955 {
956 "name": "gateway",
957 "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
958 "containerID": "containerd://gateway123"
959 }
960 ]
961 }
962 });
963 let reference = KubernetesRef {
964 namespace: "agents",
965 pod: "openclaw",
966 container: None,
967 };
968
969 let err = select_kubernetes_container_id(&pod, &reference).unwrap_err();
970 assert!(err.contains("multiple containers"));
971 assert!(err.contains("k8s://agents/openclaw/<container>"));
972 }
973
974 #[test]
975 fn ignores_non_running_kubernetes_container_id() {
976 let pod = json!({
977 "status": {
978 "containerStatuses": [
979 {
980 "name": "gateway",
981 "state": {"terminated": {"exitCode": 0}},
982 "containerID": "containerd://old123"
983 }
984 ]
985 }
986 });
987 let reference = KubernetesRef {
988 namespace: "agents",
989 pod: "openclaw",
990 container: None,
991 };
992
993 let err = select_kubernetes_container_id(&pod, &reference).unwrap_err();
994 assert!(err.contains("no running containers"));
995 }
996
997 #[test]
998 fn parses_kubernetes_runtime_container_id() {
999 assert_eq!(
1000 parse_runtime_container_id("containerd://abc123").unwrap(),
1001 RuntimeContainerRef {
1002 runtime: "containerd".to_string(),
1003 id: "abc123".to_string(),
1004 }
1005 );
1006 assert_eq!(
1007 parse_runtime_container_id("docker://def456").unwrap(),
1008 RuntimeContainerRef {
1009 runtime: "docker".to_string(),
1010 id: "def456".to_string(),
1011 }
1012 );
1013 assert!(parse_runtime_container_id("abc123").is_err());
1014 }
1015
1016 #[test]
1017 fn parses_crictl_pid_shapes() {
1018 assert_eq!(
1019 parse_crictl_pid(&json!({"info": {"pid": 1234}})),
1020 Some(1234)
1021 );
1022 assert_eq!(
1023 parse_crictl_pid(&json!({"status": {"pid": "5678"}})),
1024 Some(5678)
1025 );
1026 assert_eq!(parse_crictl_pid(&json!({"info": {"pid": 0}})), None);
1027 }
1028
1029 #[test]
1030 fn canonicalize_attach_path_resolves_proc_root_when_available() {
1031 assert_eq!(
1032 canonicalize_attach_path("/proc/self/root/etc/hosts"),
1033 "/etc/hosts"
1034 );
1035
1036 let dead_proc_path = "/proc/999999999/root/usr/lib/libssl.so";
1037 assert_eq!(canonicalize_attach_path(dead_proc_path), dead_proc_path);
1038 }
1039
1040 #[test]
1041 fn detects_boringssl_marker_in_static_binary() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let path = dir.path().join("claude-like");
1044 std::fs::write(&path, b"prefix BoringSSLError suffix").unwrap();
1045
1046 assert!(binary_embeds_ssl(path.to_str().unwrap()));
1047 }
1048
1049 #[test]
1050 fn resolves_codex_npm_launcher_to_native_ssl_binary() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let package_root = dir.path().join("node_modules/@openai/codex");
1053 let launcher = package_root.join("bin/codex.js");
1054 let native = package_root.join(
1055 "node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
1056 );
1057 std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1058 std::fs::create_dir_all(native.parent().unwrap()).unwrap();
1059 std::fs::write(&launcher, b"#!/usr/bin/env node\n").unwrap();
1060 std::fs::write(&native, b"\x7fELFnative codex binary").unwrap();
1061
1062 let resolved = resolve_binary_path_for_ssl(launcher.to_str().unwrap()).unwrap();
1063 let expected = native
1064 .canonicalize()
1065 .unwrap()
1066 .to_string_lossy()
1067 .into_owned();
1068
1069 assert_eq!(resolved.as_deref(), Some(expected.as_str()));
1070 }
1071
1072 #[test]
1073 fn resolves_codex_npm_launcher_to_sibling_native_package() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let package_root = dir.path().join("node_modules/@openai/codex");
1076 let launcher = package_root.join("bin/codex.js");
1077 let native = dir.path().join(
1078 "node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
1079 );
1080 std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1081 std::fs::create_dir_all(native.parent().unwrap()).unwrap();
1082 std::fs::write(&launcher, b"#!/usr/bin/env node\n").unwrap();
1083 std::fs::write(&native, b"\x7fELFnative codex binary").unwrap();
1084
1085 let resolved = resolve_binary_path_for_ssl(launcher.to_str().unwrap()).unwrap();
1086 let expected = native
1087 .canonicalize()
1088 .unwrap()
1089 .to_string_lossy()
1090 .into_owned();
1091
1092 assert_eq!(resolved.as_deref(), Some(expected.as_str()));
1093 }
1094
1095 #[test]
1096 fn resolves_codex_native_package_binary_for_ssl() {
1097 let dir = tempfile::tempdir().unwrap();
1098 let native = dir.path().join(
1099 "node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
1100 );
1101 std::fs::create_dir_all(native.parent().unwrap()).unwrap();
1102 std::fs::write(&native, b"\x7fELFnative codex binary").unwrap();
1103
1104 let resolved = resolve_binary_path_for_ssl(native.to_str().unwrap()).unwrap();
1105 let expected = native
1106 .canonicalize()
1107 .unwrap()
1108 .to_string_lossy()
1109 .into_owned();
1110
1111 assert_eq!(resolved.as_deref(), Some(expected.as_str()));
1112 }
1113
1114 #[test]
1115 fn ignores_binary_without_static_ssl_markers() {
1116 let dir = tempfile::tempdir().unwrap();
1117 let path = dir.path().join("plain");
1118 std::fs::write(&path, b"no tls marker here").unwrap();
1119
1120 assert!(!binary_embeds_ssl(path.to_str().unwrap()));
1121 }
1122
1123 #[test]
1124 fn path_search_prefers_earlier_dirs() {
1125 let first = tempfile::tempdir().unwrap();
1126 let second = tempfile::tempdir().unwrap();
1127 let first_cmd = first.path().join("agent");
1128 let second_cmd = second.path().join("agent");
1129 std::fs::write(&first_cmd, b"first").unwrap();
1130 std::fs::write(&second_cmd, b"second").unwrap();
1131
1132 let found = find_executable_in_dirs(
1133 "agent",
1134 [first.path().to_path_buf(), second.path().to_path_buf()],
1135 )
1136 .unwrap();
1137
1138 assert_eq!(found, first_cmd);
1139 }
1140}