aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 02:39:58 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 02:40:39 +0300
commit947328fa79fcefd2fa8087de014168d46a641312 (patch)
tree15d89163b821dcbd11a05460047e0ccd52e1b66c /src/platform
parenta22454f27ce096585e19d1787dba43f56d1505cf (diff)
feat(macos): support agent environment hints
refs https://github.com/ogulcancelik/herdr/discussions/679
Diffstat (limited to 'src/platform')
-rw-r--r--src/platform/linux.rs31
-rw-r--r--src/platform/macos.rs83
-rw-r--r--src/platform/mod.rs33
3 files changed, 105 insertions, 42 deletions
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index e700a7ff..48f1ef1c 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -280,18 +280,7 @@ pub fn process_agent_hint(pid: u32) -> Option<crate::detect::Agent> {
return None;
}
let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
- parse_agent_env_hint(&environ)
-}
-
-fn parse_agent_env_hint(environ: &[u8]) -> Option<crate::detect::Agent> {
- for record in environ.split(|&byte| byte == 0) {
- let Some(value) = record.strip_prefix(b"HERDR_AGENT=") else {
- continue;
- };
- let value = std::str::from_utf8(value).ok()?;
- return crate::detect::parse_agent_label(value);
- }
- None
+ super::parse_agent_env_hint(&environ)
}
pub fn session_processes(child_pid: u32) -> Vec<u32> {
@@ -809,24 +798,6 @@ mod tests {
}
#[test]
- fn parse_agent_env_hint_accepts_known_agents() {
- assert_eq!(
- parse_agent_env_hint(b"PATH=/bin\0HERDR_AGENT=claude\0TERM=xterm\0"),
- Some(crate::detect::Agent::Claude)
- );
- assert_eq!(
- parse_agent_env_hint(b"HERDR_AGENT=codex"),
- Some(crate::detect::Agent::Codex)
- );
- }
-
- #[test]
- fn parse_agent_env_hint_ignores_missing_or_unknown_agents() {
- assert_eq!(parse_agent_env_hint(b"PATH=/bin\0TERM=xterm\0"), None);
- assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None);
- }
-
- #[test]
fn clipboard_commands_prefer_wayland_when_available() {
let _guard = env_lock().lock().unwrap();
unsafe {
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index 70eea7ec..f35fd1eb 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -842,6 +842,33 @@ fn process_argv(pid: u32) -> Option<Vec<String>> {
procargs2_argv(&buf)
}
+/// Read a Herdr agent identity hint from a process environment.
+pub fn process_agent_hint(pid: u32) -> Option<crate::detect::Agent> {
+ if pid == 0 {
+ return None;
+ }
+ let buf = kern_procargs2(pid)?;
+ super::parse_agent_env_hint(procargs2_env(&buf)?)
+}
+
+fn procargs2_argv_start(rest: &[u8]) -> Option<usize> {
+ let exec_end = rest.iter().position(|&byte| byte == 0)?;
+ let mut pos = exec_end;
+ while pos < rest.len() && rest[pos] == 0 {
+ pos += 1;
+ }
+ (pos < rest.len()).then_some(pos)
+}
+
+fn skip_nul_strings(bytes: &[u8], start: usize, count: usize) -> Option<usize> {
+ let mut current = start;
+ for _ in 0..count {
+ let end = bytes.get(current..)?.iter().position(|&byte| byte == 0)?;
+ current = current.checked_add(end)?.checked_add(1)?;
+ }
+ Some(current)
+}
+
fn procargs2_argv(buf: &[u8]) -> Option<Vec<String>> {
if buf.len() < 4 {
return None;
@@ -852,19 +879,10 @@ fn procargs2_argv(buf: &[u8]) -> Option<Vec<String>> {
return None;
}
- // Layout: [argc: i32] [exec_path\0] [padding\0...] [argv[0]\0] [argv[1]\0] ... [env\0] ...
+ // Layout: [argc: i32] [exec_path\0] [padding\0...] [argv[0]\0] ... [env\0] ...
let rest = &buf[4..];
- let exec_end = rest.iter().position(|&b| b == 0)?;
- let mut pos = exec_end;
- while pos < rest.len() && rest[pos] == 0 {
- pos += 1;
- }
- if pos >= rest.len() {
- return None;
- }
-
+ let mut current = procargs2_argv_start(rest)?;
let mut argv = Vec::with_capacity(argc as usize);
- let mut current = pos;
for _ in 0..argc {
if current >= rest.len() {
return None;
@@ -884,6 +902,22 @@ fn procargs2_argv(buf: &[u8]) -> Option<Vec<String>> {
Some(argv)
}
+fn procargs2_env(buf: &[u8]) -> Option<&[u8]> {
+ if buf.len() < 4 {
+ return None;
+ }
+
+ let argc = i32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]);
+ if argc < 1 {
+ return None;
+ }
+
+ let rest = &buf[4..];
+ let argv_start = procargs2_argv_start(rest)?;
+ let env_start = skip_nul_strings(rest, argv_start, argc as usize)?;
+ rest.get(env_start..)
+}
+
/// Get the current working directory of a process.
///
/// Uses `proc_pidinfo(PROC_PIDVNODEPATHINFO)` to read `pvi_cdir.vip_path`.
@@ -1068,6 +1102,33 @@ mod tests {
}
#[test]
+ fn procargs2_env_reads_agent_hint_after_argv() {
+ let buf = build_procargs2(
+ "/opt/homebrew/bin/nono",
+ &["nono", "run", "HERDR_AGENT=codex", "--", "claude"],
+ &["PATH=/usr/bin", "HERDR_AGENT=claude", "TERM=xterm-256color"],
+ );
+
+ let env = procargs2_env(&buf).expect("expected env block");
+ assert_eq!(
+ crate::platform::parse_agent_env_hint(env),
+ Some(crate::detect::Agent::Claude)
+ );
+ }
+
+ #[test]
+ fn procargs2_env_does_not_treat_argv_as_environment() {
+ let buf = build_procargs2(
+ "/opt/homebrew/bin/nono",
+ &["nono", "run", "HERDR_AGENT=claude"],
+ &["PATH=/usr/bin"],
+ );
+
+ let env = procargs2_env(&buf).expect("expected env block");
+ assert_eq!(crate::platform::parse_agent_env_hint(env), None);
+ }
+
+ #[test]
fn terminal_bundle_identifier_maps_known_terminal_env() {
assert_eq!(
terminal_bundle_identifier_from_env(Some("ghostty"), None, false, false),
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index e396966b..3583df5e 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -148,11 +148,22 @@ mod fallback;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub use fallback::*;
-#[cfg(not(target_os = "linux"))]
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn process_agent_hint(_pid: u32) -> Option<crate::detect::Agent> {
None
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn parse_agent_env_hint(environ: &[u8]) -> Option<crate::detect::Agent> {
+ for record in environ.split(|&byte| byte == 0) {
+ let Some(value) = record.strip_prefix(b"HERDR_AGENT=") else {
+ continue;
+ };
+ return crate::detect::parse_agent_label(std::str::from_utf8(value).ok()?);
+ }
+ None
+}
+
#[cfg(not(target_os = "macos"))]
#[derive(Debug)]
pub(crate) struct InputSourceRestore;
@@ -229,6 +240,26 @@ mod tests {
);
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn parse_agent_env_hint_accepts_known_agents() {
+ assert_eq!(
+ parse_agent_env_hint(b"PATH=/bin\0HERDR_AGENT=claude\0TERM=xterm\0"),
+ Some(crate::detect::Agent::Claude)
+ );
+ assert_eq!(
+ parse_agent_env_hint(b"HERDR_AGENT=codex"),
+ Some(crate::detect::Agent::Codex)
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn parse_agent_env_hint_ignores_missing_or_unknown_agents() {
+ assert_eq!(parse_agent_env_hint(b"PATH=/bin\0TERM=xterm\0"), None);
+ assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None);
+ }
+
#[test]
fn read_limited_reader_returns_complete_data_under_limit() {
let input = std::io::Cursor::new(b"image".to_vec());