Skip to main content

agentsight_capture/
binary_resolver.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 eunomia-bpf org.
3
4//! Resolution of the ELF binary that sslsniff should attach its SSL uprobe to.
5//!
6//! Three entry points are used by the CLI handlers in `main.rs`:
7//!   - [`resolve_binary_path`] turns a command name/path into the underlying ELF
8//!     (PATH search, symlink canonicalization, shebang interpreter resolution).
9//!   - [`resolve_binary_path_for_ssl`] finds the concrete static TLS target.
10//!   - [`binary_embeds_ssl`] detects statically-linked TLS (Node.js/OpenClaw).
11//!   - [`resolve_container_binary_arg`] maps a container reference such as
12//!     `docker://<container>` or `k8s://<namespace>/<pod>/<container>` to an
13//!     explicit host SSL attach target.
14
15/// Resolve a command name/path to the real ELF binary that should be passed
16/// to sslsniff as `--binary-path`.
17///
18/// Handles three cases automatically:
19///   1. A command on `$PATH` (e.g. `claude`, `node`) -> located via PATH search.
20///   2. A symlink (e.g. `~/.local/bin/claude` -> `.../versions/2.1.150`) -> followed.
21///   3. A shebang wrapper script (`#!/usr/bin/env node`) -> the interpreter ELF.
22///
23/// Returns the canonical path of the underlying ELF executable, or an error
24/// describing why discovery failed.
25pub fn resolve_binary_path(command: &str) -> Result<String, String> {
26    // Limit shebang chasing so a pathological wrapper chain cannot loop forever.
27    resolve_binary_path_inner(command, 0)
28}
29
30/// Resolve a command to the concrete ELF path sslsniff should attach to, when
31/// one is needed for statically-linked TLS.
32///
33/// Codex's npm launcher is a Node.js script, but the network client runs in the
34/// platform-native `@openai/codex-linux-*` binary. Prefer that native binary
35/// before following the launcher's shebang to `node`; otherwise `record -- codex`
36/// attaches to the wrapper and misses Codex's TLS traffic.
37pub 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    // Inspect the file header: ELF magic vs. shebang.
65    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        // Parse the shebang line: `#!/usr/bin/env node` or `#!/usr/bin/python3`.
81        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        // `/usr/bin/env foo` -> resolve `foo` on PATH instead of `env` itself.
91        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
107/// Locate a command and follow symlinks without chasing shebang interpreters.
108fn 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
119/// Minimal `which`: find an executable file named `cmd` in the `$PATH` dirs.
120///
121/// When invoked under `sudo`, the inherited `$PATH` is often root's secure path,
122/// which misses user-local installs like `~/.local/bin/claude`. Honor an
123/// explicit `$PATH` first, then fall back to the invoking user's common bin dirs
124/// derived from `$SUDO_USER`.
125fn 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        // NVM keeps node under ~/.nvm/versions/node/<ver>/bin; pick the newest.
138        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
161/// Resolve the home directory of the `$SUDO_USER` by reading `/etc/passwd`.
162fn 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            // username:x:uid:gid:gecos:home:shell -> home is field index 5.
169            return fields.nth(4).map(std::path::PathBuf::from);
170        }
171    }
172    None
173}
174
175/// Find the newest NVM-installed node bin dir under a user's home, if any.
176fn 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
187/// Heuristic: does this ELF statically embed its own SSL implementation?
188///
189/// Node.js bundles OpenSSL directly into the `node` binary, so there is no
190/// system `libssl.so` for sslsniff to hook — it must attach to the binary
191/// itself. We detect this by scanning for static OpenSSL/BoringSSL marker
192/// strings in the file. Dynamically-linked runtimes like CPython call into a
193/// separate `libssl.so` (via `_ssl.so`) and do NOT contain these markers in the
194/// executable, so they keep using sslsniff's system-libssl attachment with comm
195/// filtering intact.
196pub 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]; // 1 MiB chunks
204    // Carry the tail of each chunk so a match spanning a boundary isn't missed.
205    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
355/// Strip a `docker://<ref>` or `docker:<ref>` scheme from a `--binary-path`
356/// value, returning the container reference (name or id). Returns `None` for
357/// ordinary filesystem paths, which are passed through to sslsniff unchanged.
358pub 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
369/// Parse a Kubernetes pod reference from `--binary-path`.
370///
371/// Supported forms:
372///   - `k8s://pod` (default namespace)
373///   - `k8s://namespace/pod`
374///   - `k8s://namespace/pod/container`
375///   - the same forms with `k8s:` or `kubernetes://` prefixes
376fn 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
447/// Resolve a Docker container reference to the explicit host path that
448/// sslsniff should attach its SSL uprobe to.
449///
450/// This handles both statically-linked TLS runtimes (`/proc/<pid>/exe`, common
451/// for Node.js/OpenClaw) and dynamically-linked OpenSSL (`/proc/<pid>/root/...`
452/// for a loaded `libssl.so`). The host PID comes from `docker inspect`, so this
453/// requires the Docker CLI and permission to read the target's `/proc` entries.
454///
455/// `docker inspect .State.Pid` returns the container's *init* process, which is
456/// often a wrapper such as `tini` (OpenClaw's image uses `tini -s -- node …`).
457/// That wrapper does not embed SSL, so we walk its descendant process tree and
458/// require an actual SSL target.
459pub 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
724/// Breadth-first search the descendant process tree rooted at `root_pid` for a
725/// concrete SSL attach path.
726///
727/// Children are read from `/proc/<pid>/task/<pid>/children`, which lists the
728/// immediate child PIDs of a process. Requires permission to read those entries
729/// (root in practice for containerized processes).
730fn 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        // A 64-char container id is a valid reference too.
796        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}