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. Grok's native binary is also selected by its CLI
193/// marker; sslsniff performs the stricter rustls signature check. Dynamically-
194/// linked runtimes like CPython call into a separate `libssl.so` (via `_ssl.so`)
195/// and do NOT contain these markers in the executable, so they keep using
196/// sslsniff's system-libssl attachment with comm filtering intact.
197pub fn binary_embeds_ssl(path: &str) -> bool {
198    use std::io::Read;
199    const NEEDLES: &[&[u8]] = &[
200        b"SSL_write",
201        b"BoringSSLError",
202        b"OPENSSL_internal",
203        b"grok-cli",
204    ];
205    let mut f = match std::fs::File::open(path) {
206        Ok(f) => f,
207        Err(_) => return false,
208    };
209    let mut buf = vec![0u8; 1 << 20]; // 1 MiB chunks
210    // Carry the tail of each chunk so a match spanning a boundary isn't missed.
211    let mut carry: Vec<u8> = Vec::new();
212    let keep = NEEDLES
213        .iter()
214        .map(|needle| needle.len())
215        .max()
216        .unwrap_or(1)
217        .saturating_sub(1);
218    loop {
219        let n = match f.read(&mut buf) {
220            Ok(0) => break,
221            Ok(n) => n,
222            Err(_) => return false,
223        };
224        carry.extend_from_slice(&buf[..n]);
225        if NEEDLES
226            .iter()
227            .any(|needle| carry.windows(needle.len()).any(|w| w == *needle))
228        {
229            return true;
230        }
231        if carry.len() > keep {
232            carry.drain(..carry.len() - keep);
233        }
234    }
235    false
236}
237
238fn codex_native_binary_from_launcher(launcher: &std::path::Path) -> Option<String> {
239    let package_root = openai_codex_package_root(launcher)?;
240
241    let nested_scope = package_root.join("node_modules").join("@openai");
242    if let Some(path) = codex_native_binary_in_scope(&nested_scope) {
243        return Some(path);
244    }
245    if let Some(scope) = package_root.parent() {
246        if let Some(path) = codex_native_binary_in_scope(scope) {
247            return Some(path);
248        }
249    }
250
251    None
252}
253
254fn codex_native_binary_in_scope(openai_scope: &std::path::Path) -> Option<String> {
255    const CANDIDATES: &[(&str, &str)] = &[
256        ("codex-linux-x64", "x86_64-unknown-linux-musl"),
257        ("codex-linux-arm64", "aarch64-unknown-linux-musl"),
258    ];
259
260    for (package, target) in CANDIDATES {
261        let path = openai_scope
262            .join(package)
263            .join("vendor")
264            .join(target)
265            .join("bin")
266            .join("codex");
267        if is_elf_file(&path) {
268            return Some(canonicalize_path(&path));
269        }
270    }
271    None
272}
273
274fn is_openai_codex_native_binary(path: &std::path::Path) -> bool {
275    if !is_elf_file(path) || !file_name_eq(path, "codex") {
276        return false;
277    }
278
279    let Some(bin_dir) = path.parent().filter(|p| file_name_eq(p, "bin")) else {
280        return false;
281    };
282    let Some(target_dir) = bin_dir.parent().filter(|p| {
283        file_name_eq(p, "x86_64-unknown-linux-musl")
284            || file_name_eq(p, "aarch64-unknown-linux-musl")
285    }) else {
286        return false;
287    };
288    let Some(vendor_dir) = target_dir.parent().filter(|p| file_name_eq(p, "vendor")) else {
289        return false;
290    };
291    let Some(package_dir) = vendor_dir
292        .parent()
293        .filter(|p| file_name_eq(p, "codex-linux-x64") || file_name_eq(p, "codex-linux-arm64"))
294    else {
295        return false;
296    };
297    package_dir
298        .parent()
299        .is_some_and(|parent| file_name_eq(parent, "@openai"))
300}
301
302fn openai_codex_package_root(path: &std::path::Path) -> Option<std::path::PathBuf> {
303    for ancestor in path.ancestors() {
304        if file_name_eq(ancestor, "codex")
305            && ancestor
306                .parent()
307                .is_some_and(|parent| file_name_eq(parent, "@openai"))
308            && ancestor
309                .parent()
310                .and_then(|parent| parent.parent())
311                .is_some_and(|parent| file_name_eq(parent, "node_modules"))
312        {
313            return Some(ancestor.to_path_buf());
314        }
315    }
316    None
317}
318
319fn file_name_eq(path: &std::path::Path, expected: &str) -> bool {
320    path.file_name().and_then(|name| name.to_str()) == Some(expected)
321}
322
323fn is_elf_file(path: &std::path::Path) -> bool {
324    use std::io::Read;
325    let mut header = [0u8; 4];
326    let mut f = match std::fs::File::open(path) {
327        Ok(f) => f,
328        Err(_) => return false,
329    };
330    f.read_exact(&mut header).is_ok() && header == *b"\x7fELF"
331}
332
333fn canonicalize_path(path: &std::path::Path) -> String {
334    std::fs::canonicalize(path)
335        .map(|p| p.to_string_lossy().into_owned())
336        .unwrap_or_else(|_| path.to_string_lossy().into_owned())
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
340struct KubernetesRef<'a> {
341    namespace: &'a str,
342    pod: &'a str,
343    container: Option<&'a str>,
344}
345
346impl KubernetesRef<'_> {
347    fn label(&self) -> String {
348        match self.container {
349            Some(container) => format!("k8s://{}/{}/{}", self.namespace, self.pod, container),
350            None => format!("k8s://{}/{}", self.namespace, self.pod),
351        }
352    }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
356struct RuntimeContainerRef {
357    runtime: String,
358    id: String,
359}
360
361/// Strip a `docker://<ref>` or `docker:<ref>` scheme from a `--binary-path`
362/// value, returning the container reference (name or id). Returns `None` for
363/// ordinary filesystem paths, which are passed through to sslsniff unchanged.
364pub fn parse_container_ref(binary_path: &str) -> Option<&str> {
365    binary_path
366        .strip_prefix("docker://")
367        .or_else(|| binary_path.strip_prefix("docker:"))
368        .filter(|r| !r.is_empty() && !r.contains('/'))
369}
370
371fn has_docker_scheme(binary_path: &str) -> bool {
372    binary_path.starts_with("docker://") || binary_path.starts_with("docker:")
373}
374
375/// Parse a Kubernetes pod reference from `--binary-path`.
376///
377/// Supported forms:
378///   - `k8s://pod` (default namespace)
379///   - `k8s://namespace/pod`
380///   - `k8s://namespace/pod/container`
381///   - the same forms with `k8s:` or `kubernetes://` prefixes
382fn parse_kubernetes_ref(binary_path: &str) -> Option<KubernetesRef<'_>> {
383    let reference = binary_path
384        .strip_prefix("k8s://")
385        .or_else(|| binary_path.strip_prefix("k8s:"))
386        .or_else(|| binary_path.strip_prefix("kubernetes://"))
387        .or_else(|| binary_path.strip_prefix("kubernetes:"))?;
388
389    let parts = reference.split('/').collect::<Vec<_>>();
390    if parts.is_empty() || parts.iter().any(|part| part.is_empty()) {
391        return None;
392    }
393
394    match parts.as_slice() {
395        [pod] => Some(KubernetesRef {
396            namespace: "default",
397            pod,
398            container: None,
399        }),
400        [namespace, pod] => Some(KubernetesRef {
401            namespace,
402            pod,
403            container: None,
404        }),
405        [namespace, pod, container] => Some(KubernetesRef {
406            namespace,
407            pod,
408            container: Some(container),
409        }),
410        _ => None,
411    }
412}
413
414fn has_kubernetes_scheme(binary_path: &str) -> bool {
415    binary_path.starts_with("k8s://")
416        || binary_path.starts_with("k8s:")
417        || binary_path.starts_with("kubernetes://")
418        || binary_path.starts_with("kubernetes:")
419}
420
421pub fn resolve_container_binary_arg(
422    binary_path: Option<&str>,
423) -> Result<Option<(String, String)>, String> {
424    let Some(binary_path) = binary_path else {
425        return Ok(None);
426    };
427
428    if let Some(reference) = parse_container_ref(binary_path) {
429        return resolve_container_binary_path(reference)
430            .map(|path| Some((reference.to_string(), path)));
431    }
432    if has_docker_scheme(binary_path) {
433        return Err(format!(
434            "invalid Docker container reference '{}'; expected docker://<name|id>",
435            binary_path
436        ));
437    }
438
439    if let Some(reference) = parse_kubernetes_ref(binary_path) {
440        let label = reference.label();
441        return resolve_kubernetes_binary_path(&reference).map(|path| Some((label, path)));
442    }
443    if has_kubernetes_scheme(binary_path) {
444        return Err(format!(
445            "invalid Kubernetes pod reference '{}'; expected k8s://pod, k8s://namespace/pod, or k8s://namespace/pod/container",
446            binary_path
447        ));
448    }
449
450    Ok(None)
451}
452
453/// Resolve a Docker container reference to the explicit host path that
454/// sslsniff should attach its SSL uprobe to.
455///
456/// This handles both statically-linked TLS runtimes (`/proc/<pid>/exe`, common
457/// for Node.js/OpenClaw) and dynamically-linked OpenSSL (`/proc/<pid>/root/...`
458/// for a loaded `libssl.so`). The host PID comes from `docker inspect`, so this
459/// requires the Docker CLI and permission to read the target's `/proc` entries.
460///
461/// `docker inspect .State.Pid` returns the container's *init* process, which is
462/// often a wrapper such as `tini` (OpenClaw's image uses `tini -s -- node …`).
463/// That wrapper does not embed SSL, so we walk its descendant process tree and
464/// require an actual SSL target.
465pub fn resolve_container_binary_path(reference: &str) -> Result<String, String> {
466    let init_pid = resolve_docker_container_pid(reference)?;
467
468    find_ssl_target_in_tree(init_pid).ok_or_else(|| {
469        format!(
470            "container '{}' is running at host PID {}, but no SSL attach target was found in its process tree",
471            reference, init_pid
472        )
473    })
474}
475
476fn resolve_docker_container_pid(reference: &str) -> Result<u32, String> {
477    let output = std::process::Command::new("docker")
478        .args(["inspect", "--format", "{{.State.Pid}}", reference])
479        .output()
480        .map_err(|e| format!(
481            "failed to run `docker inspect` for container '{}': {} (is the Docker CLI installed and on $PATH?)",
482            reference, e
483        ))?;
484
485    if !output.status.success() {
486        let stderr = String::from_utf8_lossy(&output.stderr);
487        return Err(format!(
488            "`docker inspect {}` failed: {}",
489            reference,
490            stderr.trim()
491        ));
492    }
493
494    let init_pid: u32 = String::from_utf8_lossy(&output.stdout)
495        .trim()
496        .parse()
497        .map_err(|_| format!("could not determine host PID for container '{}'", reference))?;
498
499    if init_pid == 0 {
500        return Err(format!(
501            "container '{}' is not running (host PID 0)",
502            reference
503        ));
504    }
505
506    Ok(init_pid)
507}
508
509fn resolve_kubernetes_binary_path(reference: &KubernetesRef<'_>) -> Result<String, String> {
510    let pod = kubectl_get_pod(reference)?;
511    let container_id = select_kubernetes_container_id(&pod, reference)?;
512    let runtime = parse_runtime_container_id(&container_id)?;
513    let init_pid = resolve_runtime_container_pid(&runtime)?;
514
515    find_ssl_target_in_tree(init_pid).ok_or_else(|| {
516        let node = pod
517            .pointer("/spec/nodeName")
518            .and_then(serde_json::Value::as_str)
519            .unwrap_or("unknown");
520        format!(
521            "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: {}).",
522            reference.namespace,
523            reference.pod,
524            runtime.id,
525            init_pid,
526            node
527        )
528    })
529}
530
531fn kubectl_get_pod(reference: &KubernetesRef<'_>) -> Result<serde_json::Value, String> {
532    let output = kubectl_command()
533        .args([
534            "get",
535            "pod",
536            reference.pod,
537            "-n",
538            reference.namespace,
539            "-o",
540            "json",
541        ])
542        .output()
543        .map_err(|e| format!(
544            "failed to run `kubectl get pod {}` in namespace '{}': {} (is kubectl installed and configured?)",
545            reference.pod, reference.namespace, e
546        ))?;
547
548    if !output.status.success() {
549        let stderr = String::from_utf8_lossy(&output.stderr);
550        return Err(format!(
551            "`kubectl get pod {} -n {}` failed: {}",
552            reference.pod,
553            reference.namespace,
554            stderr.trim()
555        ));
556    }
557
558    serde_json::from_slice(&output.stdout).map_err(|e| {
559        format!(
560            "`kubectl get pod {} -n {} -o json` returned invalid JSON: {}",
561            reference.pod, reference.namespace, e
562        )
563    })
564}
565
566fn kubectl_command() -> std::process::Command {
567    let mut command = std::process::Command::new("kubectl");
568    if std::env::var_os("KUBECONFIG").is_none()
569        && let Some(user) = std::env::var_os("SUDO_USER")
570        && let Some(home) = sudo_user_home(&user)
571    {
572        let kubeconfig = home.join(".kube/config");
573        if kubeconfig.is_file() {
574            command.env("KUBECONFIG", kubeconfig);
575        }
576    }
577    command
578}
579
580fn select_kubernetes_container_id(
581    pod: &serde_json::Value,
582    reference: &KubernetesRef<'_>,
583) -> Result<String, String> {
584    let statuses = pod
585        .pointer("/status/containerStatuses")
586        .and_then(serde_json::Value::as_array)
587        .ok_or_else(|| {
588            format!(
589                "Kubernetes pod '{}/{}' has no status.containerStatuses yet",
590                reference.namespace, reference.pod
591            )
592        })?;
593
594    if let Some(container) = reference.container {
595        let status = statuses
596            .iter()
597            .find(|status| {
598                status.get("name").and_then(serde_json::Value::as_str) == Some(container)
599            })
600            .ok_or_else(|| {
601                format!(
602                    "Kubernetes pod '{}/{}' has no container named '{}'",
603                    reference.namespace, reference.pod, container
604                )
605            })?;
606        return container_id_from_status(status).ok_or_else(|| {
607            format!(
608                "Kubernetes pod '{}/{}' container '{}' has no containerID yet (is it running?)",
609                reference.namespace, reference.pod, container
610            )
611        });
612    }
613
614    let containers = statuses
615        .iter()
616        .filter_map(|status| {
617            let name = status.get("name").and_then(serde_json::Value::as_str)?;
618            let id = container_id_from_status(status)?;
619            Some((name, id))
620        })
621        .collect::<Vec<_>>();
622
623    match containers.as_slice() {
624        [(_, id)] => Ok(id.clone()),
625        [] => Err(format!(
626            "Kubernetes pod '{}/{}' has no running containers with a containerID",
627            reference.namespace, reference.pod
628        )),
629        _ => {
630            let names = containers
631                .iter()
632                .map(|(name, _)| *name)
633                .collect::<Vec<_>>()
634                .join(", ");
635            Err(format!(
636                "Kubernetes pod '{}/{}' has multiple containers ({}); specify one as k8s://{}/{}/<container>",
637                reference.namespace, reference.pod, names, reference.namespace, reference.pod
638            ))
639        }
640    }
641}
642
643fn container_id_from_status(status: &serde_json::Value) -> Option<String> {
644    status.pointer("/state/running")?;
645    let id = status.get("containerID")?.as_str()?.trim();
646    (!id.is_empty()).then(|| id.to_string())
647}
648
649fn parse_runtime_container_id(container_id: &str) -> Result<RuntimeContainerRef, String> {
650    let (runtime, id) = container_id.split_once("://").ok_or_else(|| {
651        format!(
652            "Kubernetes containerID '{}' is missing a runtime scheme",
653            container_id
654        )
655    })?;
656    let id = id.trim();
657    if runtime.trim().is_empty() || id.is_empty() {
658        return Err(format!(
659            "Kubernetes containerID '{}' is incomplete",
660            container_id
661        ));
662    }
663    Ok(RuntimeContainerRef {
664        runtime: runtime.to_string(),
665        id: id.to_string(),
666    })
667}
668
669fn resolve_runtime_container_pid(container: &RuntimeContainerRef) -> Result<u32, String> {
670    match container.runtime.as_str() {
671        "docker" => resolve_docker_container_pid(&container.id),
672        "containerd" | "cri-o" | "crio" => resolve_cri_container_pid(&container.id),
673        other => resolve_cri_container_pid(&container.id).map_err(|e| {
674            format!(
675                "unsupported Kubernetes container runtime '{}' for container '{}': {}",
676                other, container.id, e
677            )
678        }),
679    }
680}
681
682fn resolve_cri_container_pid(container_id: &str) -> Result<u32, String> {
683    let output = std::process::Command::new("crictl")
684        .args(["inspect", "--output", "json", container_id])
685        .output()
686        .map_err(|e| format!(
687            "failed to run `crictl inspect` for container '{}': {} (is crictl installed and configured for this node's CRI runtime?)",
688            container_id, e
689        ))?;
690
691    if !output.status.success() {
692        let stderr = String::from_utf8_lossy(&output.stderr);
693        return Err(format!(
694            "`crictl inspect {}` failed: {}",
695            container_id,
696            stderr.trim()
697        ));
698    }
699
700    let value: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
701        format!(
702            "`crictl inspect --output json {}` returned invalid JSON: {}",
703            container_id, e
704        )
705    })?;
706
707    parse_crictl_pid(&value).ok_or_else(|| {
708        format!(
709            "could not determine host PID for CRI container '{}'",
710            container_id
711        )
712    })
713}
714
715fn parse_crictl_pid(value: &serde_json::Value) -> Option<u32> {
716    ["/info/pid", "/status/pid"]
717        .into_iter()
718        .filter_map(|path| value.pointer(path))
719        .find_map(value_as_u32)
720        .filter(|pid| *pid != 0)
721}
722
723fn value_as_u32(value: &serde_json::Value) -> Option<u32> {
724    if let Some(pid) = value.as_u64() {
725        return u32::try_from(pid).ok();
726    }
727    value.as_str()?.parse().ok()
728}
729
730/// Breadth-first search the descendant process tree rooted at `root_pid` for a
731/// concrete SSL attach path.
732///
733/// Children are read from `/proc/<pid>/task/<pid>/children`, which lists the
734/// immediate child PIDs of a process. Requires permission to read those entries
735/// (root in practice for containerized processes).
736fn find_ssl_target_in_tree(root_pid: u32) -> Option<String> {
737    let mut queue = std::collections::VecDeque::from([root_pid]);
738    let mut seen = std::collections::HashSet::new();
739    while let Some(pid) = queue.pop_front() {
740        if !seen.insert(pid) {
741            continue;
742        }
743        let exe = format!("/proc/{}/exe", pid);
744        if binary_embeds_ssl(&exe) {
745            return Some(canonicalize_attach_path(&exe));
746        }
747        if let Some(path) = find_loaded_ssl_library(pid) {
748            return Some(path);
749        }
750        let children_path = format!("/proc/{}/task/{}/children", pid, pid);
751        if let Ok(children) = std::fs::read_to_string(&children_path) {
752            for child in children
753                .split_whitespace()
754                .filter_map(|s| s.parse::<u32>().ok())
755            {
756                queue.push_back(child);
757            }
758        }
759    }
760    None
761}
762
763fn find_loaded_ssl_library(pid: u32) -> Option<String> {
764    let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).ok()?;
765    for line in maps.lines() {
766        let path = line.split_whitespace().last()?;
767        if !path.starts_with('/') || !path.contains("libssl.so") {
768            continue;
769        }
770        let host_path = format!("/proc/{pid}/root{path}");
771        if std::fs::metadata(&host_path).is_ok() {
772            return Some(canonicalize_attach_path(&host_path));
773        }
774    }
775    None
776}
777
778fn canonicalize_attach_path(path: &str) -> String {
779    std::fs::canonicalize(path)
780        .map(|p| p.to_string_lossy().into_owned())
781        .unwrap_or_else(|_| path.to_string())
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use serde_json::json;
788
789    #[test]
790    fn parses_docker_double_slash_scheme() {
791        assert_eq!(parse_container_ref("docker://openclaw"), Some("openclaw"));
792        assert_eq!(
793            parse_container_ref("docker://my-agent-1"),
794            Some("my-agent-1")
795        );
796    }
797
798    #[test]
799    fn parses_docker_colon_scheme() {
800        assert_eq!(parse_container_ref("docker:openclaw"), Some("openclaw"));
801        // A 64-char container id is a valid reference too.
802        assert_eq!(
803            parse_container_ref("docker:abc123def456"),
804            Some("abc123def456")
805        );
806    }
807
808    #[test]
809    fn ignores_plain_filesystem_paths() {
810        assert_eq!(parse_container_ref("/proc/1234/exe"), None);
811        assert_eq!(parse_container_ref("/usr/bin/node"), None);
812        assert_eq!(
813            parse_container_ref("~/.nvm/versions/node/v20.0.0/bin/node"),
814            None
815        );
816    }
817
818    #[test]
819    fn rejects_empty_container_reference() {
820        assert_eq!(parse_container_ref("docker://"), None);
821        assert_eq!(parse_container_ref("docker:"), None);
822    }
823
824    #[test]
825    fn rejects_slash_separated_docker_reference() {
826        assert_eq!(parse_container_ref("docker://foo/bar"), None);
827        assert_eq!(parse_container_ref("docker:foo/bar"), None);
828    }
829
830    #[test]
831    fn parses_kubernetes_pod_reference_with_default_namespace() {
832        assert_eq!(
833            parse_kubernetes_ref("k8s://openclaw"),
834            Some(KubernetesRef {
835                namespace: "default",
836                pod: "openclaw",
837                container: None,
838            })
839        );
840    }
841
842    #[test]
843    fn parses_kubernetes_namespaced_pod_reference() {
844        assert_eq!(
845            parse_kubernetes_ref("k8s://agents/openclaw"),
846            Some(KubernetesRef {
847                namespace: "agents",
848                pod: "openclaw",
849                container: None,
850            })
851        );
852    }
853
854    #[test]
855    fn parses_kubernetes_container_reference() {
856        let reference = parse_kubernetes_ref("kubernetes://agents/openclaw/gateway");
857        assert_eq!(
858            reference,
859            Some(KubernetesRef {
860                namespace: "agents",
861                pod: "openclaw",
862                container: Some("gateway"),
863            })
864        );
865        assert_eq!(
866            reference.as_ref().map(KubernetesRef::label),
867            Some("k8s://agents/openclaw/gateway".to_string())
868        );
869    }
870
871    #[test]
872    fn rejects_invalid_kubernetes_references() {
873        assert_eq!(parse_kubernetes_ref("k8s://"), None);
874        assert_eq!(parse_kubernetes_ref("k8s://agents/"), None);
875        assert_eq!(
876            parse_kubernetes_ref("k8s://agents/openclaw/gateway/extra"),
877            None
878        );
879        assert_eq!(parse_kubernetes_ref("/usr/bin/node"), None);
880    }
881
882    #[test]
883    fn invalid_container_scheme_errors_before_running_external_tools() {
884        assert!(
885            resolve_container_binary_arg(Some("docker://"))
886                .unwrap_err()
887                .contains("invalid Docker container reference")
888        );
889        assert!(
890            resolve_container_binary_arg(Some("k8s://agents/openclaw/gateway/extra"))
891                .unwrap_err()
892                .contains("invalid Kubernetes pod reference")
893        );
894    }
895
896    #[test]
897    fn selects_single_kubernetes_container_id() {
898        let pod = json!({
899            "status": {
900                "containerStatuses": [
901                    {
902                        "name": "gateway",
903                        "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
904                        "containerID": "containerd://abc123"
905                    }
906                ]
907            }
908        });
909        let reference = KubernetesRef {
910            namespace: "agents",
911            pod: "openclaw",
912            container: None,
913        };
914
915        assert_eq!(
916            select_kubernetes_container_id(&pod, &reference).unwrap(),
917            "containerd://abc123"
918        );
919    }
920
921    #[test]
922    fn selects_explicit_kubernetes_container_id() {
923        let pod = json!({
924            "status": {
925                "containerStatuses": [
926                    {
927                        "name": "sidecar",
928                        "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
929                        "containerID": "containerd://sidecar123"
930                    },
931                    {
932                        "name": "gateway",
933                        "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
934                        "containerID": "containerd://gateway123"
935                    }
936                ]
937            }
938        });
939        let reference = KubernetesRef {
940            namespace: "agents",
941            pod: "openclaw",
942            container: Some("gateway"),
943        };
944
945        assert_eq!(
946            select_kubernetes_container_id(&pod, &reference).unwrap(),
947            "containerd://gateway123"
948        );
949    }
950
951    #[test]
952    fn requires_container_name_for_multi_container_pod() {
953        let pod = json!({
954            "status": {
955                "containerStatuses": [
956                    {
957                        "name": "sidecar",
958                        "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
959                        "containerID": "containerd://sidecar123"
960                    },
961                    {
962                        "name": "gateway",
963                        "state": {"running": {"startedAt": "2026-07-01T00:00:00Z"}},
964                        "containerID": "containerd://gateway123"
965                    }
966                ]
967            }
968        });
969        let reference = KubernetesRef {
970            namespace: "agents",
971            pod: "openclaw",
972            container: None,
973        };
974
975        let err = select_kubernetes_container_id(&pod, &reference).unwrap_err();
976        assert!(err.contains("multiple containers"));
977        assert!(err.contains("k8s://agents/openclaw/<container>"));
978    }
979
980    #[test]
981    fn ignores_non_running_kubernetes_container_id() {
982        let pod = json!({
983            "status": {
984                "containerStatuses": [
985                    {
986                        "name": "gateway",
987                        "state": {"terminated": {"exitCode": 0}},
988                        "containerID": "containerd://old123"
989                    }
990                ]
991            }
992        });
993        let reference = KubernetesRef {
994            namespace: "agents",
995            pod: "openclaw",
996            container: None,
997        };
998
999        let err = select_kubernetes_container_id(&pod, &reference).unwrap_err();
1000        assert!(err.contains("no running containers"));
1001    }
1002
1003    #[test]
1004    fn parses_kubernetes_runtime_container_id() {
1005        assert_eq!(
1006            parse_runtime_container_id("containerd://abc123").unwrap(),
1007            RuntimeContainerRef {
1008                runtime: "containerd".to_string(),
1009                id: "abc123".to_string(),
1010            }
1011        );
1012        assert_eq!(
1013            parse_runtime_container_id("docker://def456").unwrap(),
1014            RuntimeContainerRef {
1015                runtime: "docker".to_string(),
1016                id: "def456".to_string(),
1017            }
1018        );
1019        assert!(parse_runtime_container_id("abc123").is_err());
1020    }
1021
1022    #[test]
1023    fn parses_crictl_pid_shapes() {
1024        assert_eq!(
1025            parse_crictl_pid(&json!({"info": {"pid": 1234}})),
1026            Some(1234)
1027        );
1028        assert_eq!(
1029            parse_crictl_pid(&json!({"status": {"pid": "5678"}})),
1030            Some(5678)
1031        );
1032        assert_eq!(parse_crictl_pid(&json!({"info": {"pid": 0}})), None);
1033    }
1034
1035    #[test]
1036    fn canonicalize_attach_path_resolves_proc_root_when_available() {
1037        assert_eq!(
1038            canonicalize_attach_path("/proc/self/root/etc/hosts"),
1039            "/etc/hosts"
1040        );
1041
1042        let dead_proc_path = "/proc/999999999/root/usr/lib/libssl.so";
1043        assert_eq!(canonicalize_attach_path(dead_proc_path), dead_proc_path);
1044    }
1045
1046    #[test]
1047    fn detects_boringssl_marker_in_static_binary() {
1048        let dir = tempfile::tempdir().unwrap();
1049        let path = dir.path().join("claude-like");
1050        std::fs::write(&path, b"prefix BoringSSLError suffix").unwrap();
1051
1052        assert!(binary_embeds_ssl(path.to_str().unwrap()));
1053    }
1054
1055    #[test]
1056    fn detects_grok_static_binary_marker() {
1057        let dir = tempfile::tempdir().unwrap();
1058        let path = dir.path().join("grok-like");
1059        std::fs::write(&path, b"prefix grok-cli suffix").unwrap();
1060
1061        assert!(binary_embeds_ssl(path.to_str().unwrap()));
1062    }
1063
1064    #[test]
1065    fn resolves_codex_npm_launcher_to_native_ssl_binary() {
1066        let dir = tempfile::tempdir().unwrap();
1067        let package_root = dir.path().join("node_modules/@openai/codex");
1068        let launcher = package_root.join("bin/codex.js");
1069        let native = package_root.join(
1070            "node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
1071        );
1072        std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1073        std::fs::create_dir_all(native.parent().unwrap()).unwrap();
1074        std::fs::write(&launcher, b"#!/usr/bin/env node\n").unwrap();
1075        std::fs::write(&native, b"\x7fELFnative codex binary").unwrap();
1076
1077        let resolved = resolve_binary_path_for_ssl(launcher.to_str().unwrap()).unwrap();
1078        let expected = native
1079            .canonicalize()
1080            .unwrap()
1081            .to_string_lossy()
1082            .into_owned();
1083
1084        assert_eq!(resolved.as_deref(), Some(expected.as_str()));
1085    }
1086
1087    #[test]
1088    fn resolves_codex_npm_launcher_to_sibling_native_package() {
1089        let dir = tempfile::tempdir().unwrap();
1090        let package_root = dir.path().join("node_modules/@openai/codex");
1091        let launcher = package_root.join("bin/codex.js");
1092        let native = dir.path().join(
1093            "node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
1094        );
1095        std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1096        std::fs::create_dir_all(native.parent().unwrap()).unwrap();
1097        std::fs::write(&launcher, b"#!/usr/bin/env node\n").unwrap();
1098        std::fs::write(&native, b"\x7fELFnative codex binary").unwrap();
1099
1100        let resolved = resolve_binary_path_for_ssl(launcher.to_str().unwrap()).unwrap();
1101        let expected = native
1102            .canonicalize()
1103            .unwrap()
1104            .to_string_lossy()
1105            .into_owned();
1106
1107        assert_eq!(resolved.as_deref(), Some(expected.as_str()));
1108    }
1109
1110    #[test]
1111    fn resolves_codex_native_package_binary_for_ssl() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let native = dir.path().join(
1114            "node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex",
1115        );
1116        std::fs::create_dir_all(native.parent().unwrap()).unwrap();
1117        std::fs::write(&native, b"\x7fELFnative codex binary").unwrap();
1118
1119        let resolved = resolve_binary_path_for_ssl(native.to_str().unwrap()).unwrap();
1120        let expected = native
1121            .canonicalize()
1122            .unwrap()
1123            .to_string_lossy()
1124            .into_owned();
1125
1126        assert_eq!(resolved.as_deref(), Some(expected.as_str()));
1127    }
1128
1129    #[test]
1130    fn ignores_binary_without_static_ssl_markers() {
1131        let dir = tempfile::tempdir().unwrap();
1132        let path = dir.path().join("plain");
1133        std::fs::write(&path, b"no tls marker here").unwrap();
1134
1135        assert!(!binary_embeds_ssl(path.to_str().unwrap()));
1136    }
1137
1138    #[test]
1139    fn path_search_prefers_earlier_dirs() {
1140        let first = tempfile::tempdir().unwrap();
1141        let second = tempfile::tempdir().unwrap();
1142        let first_cmd = first.path().join("agent");
1143        let second_cmd = second.path().join("agent");
1144        std::fs::write(&first_cmd, b"first").unwrap();
1145        std::fs::write(&second_cmd, b"second").unwrap();
1146
1147        let found = find_executable_in_dirs(
1148            "agent",
1149            [first.path().to_path_buf(), second.path().to_path_buf()],
1150        )
1151        .unwrap();
1152
1153        assert_eq!(found, first_cmd);
1154    }
1155}