From 947328fa79fcefd2fa8087de014168d46a641312 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 16 Jul 2026 02:39:58 +0300 Subject: [PATCH] feat(macos): support agent environment hints refs https://github.com/ogulcancelik/herdr/discussions/679 --- docs/next/CHANGELOG.md | 3 + docs/next/website/src/content/docs/agents.mdx | 2 +- .../website/src/content/docs/ja/agents.mdx | 2 +- .../website/src/content/docs/zh-cn/agents.mdx | 2 +- src/platform/linux.rs | 31 +------ src/platform/macos.rs | 83 ++++++++++++++++--- src/platform/mod.rs | 33 +++++++- 7 files changed, 111 insertions(+), 45 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index b15f009b..dec6d8b4 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added macOS support for the `HERDR_AGENT=` foreground-process hint, allowing agents hidden behind host-visible wrappers such as `nono` to use the named agent's screen manifest. (#679) + ### Fixed - Live handoff now preserves installed plugins and no longer lets the next plugin installation overwrite the existing registry. (#893) diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index b4c3146d..f62564e1 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -47,7 +47,7 @@ Claude Code, Codex, GitHub Copilot CLI, Droid, Qoder CLI, and Cursor Agent CLI i ## VMs and sandbox wrappers -On Linux, wrappers such as VMs, Bubblewrap, or `fence` can hide the real agent process from host `/proc`. Set `HERDR_AGENT=` on the command, for example `HERDR_AGENT=claude fence -- claude`, to tell Herdr which existing agent screen manifest to use. The hint is scoped to that foreground process; avoid exporting it globally from your shell unless every inherited foreground process should be treated as that agent. +On Linux and macOS, a host-visible wrapper can hide the real agent process from Herdr. Set `HERDR_AGENT=` on the wrapper command to tell Herdr which existing agent screen manifest to use. For example, run `HERDR_AGENT=claude fence -- claude` on Linux or `HERDR_AGENT=claude nono run --profile claude-code -- claude` on macOS. The hint is scoped to that foreground process; setting it only inside a VM or container is not visible to Herdr, and you should avoid exporting it globally unless every inherited foreground process should be treated as that agent. ## Blocked state diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 1208195c..8b311b3c 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -47,7 +47,7 @@ Claude Code、Codex、GitHub Copilot CLI、Droid、Qoder CLI、Cursor Agent CLI ## VM とサンドボックスラッパー -Linux では、VM、Bubblewrap、`fence` のようなラッパーがホストの `/proc` から実際のエージェントプロセスを隠すことがあります。コマンドに `HERDR_AGENT=` を設定して (例: `HERDR_AGENT=claude fence -- claude`)、どの既存エージェントのスクリーンマニフェストを使うべきか Herdr に伝えてください。このヒントはそのフォアグラウンドプロセスにスコープされます。継承されるすべてのフォアグラウンドプロセスをそのエージェントとして扱いたいのでない限り、シェルからグローバルに export するのは避けてください。 +Linux と macOS では、ホストから見えるラッパーが実際のエージェントプロセスを Herdr から隠すことがあります。ラッパーのコマンドに `HERDR_AGENT=` を設定して、どの既存エージェントのスクリーンマニフェストを使うべきか Herdr に伝えてください。たとえば、Linux では `HERDR_AGENT=claude fence -- claude`、macOS では `HERDR_AGENT=claude nono run --profile claude-code -- claude` を実行します。このヒントはそのフォアグラウンドプロセスにスコープされます。VM やコンテナの内部だけで設定しても Herdr からは見えません。また、継承されるすべてのフォアグラウンドプロセスをそのエージェントとして扱いたいのでない限り、グローバルな export は避けてください。 ## blocked 状態 diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index f169fbe1..e7fb0b64 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -47,7 +47,7 @@ Claude Code、Codex、GitHub Copilot CLI、Droid、Qoder CLI 和 Cursor Agent CL ## 虚拟机与沙箱包装器 -在 Linux 上,VM、Bubblewrap 或 `fence` 之类的包装器可能把真实的智能体进程从宿主的 `/proc` 中隐藏起来。在命令上设置 `HERDR_AGENT=`,例如 `HERDR_AGENT=claude fence -- claude`,告诉 Herdr 该使用哪个已有智能体的屏幕清单。这个提示的作用范围只限于那个前台进程;除非所有继承它的前台进程都应被视为该智能体,否则不要在 shell 里全局 export。 +在 Linux 和 macOS 上,宿主可见的包装器可能会向 Herdr 隐藏真实的智能体进程。在包装器命令上设置 `HERDR_AGENT=`,告诉 Herdr 应使用哪个已有智能体的屏幕清单。例如,在 Linux 上运行 `HERDR_AGENT=claude fence -- claude`,或在 macOS 上运行 `HERDR_AGENT=claude nono run --profile claude-code -- claude`。这个提示的作用范围只限于该前台进程;仅在 VM 或容器内部设置时 Herdr 无法看到它,而且除非所有继承的前台进程都应被视为该智能体,否则不要全局 export。 ## blocked 状态 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 { 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 { - 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 { @@ -808,24 +797,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(); 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> { procargs2_argv(&buf) } +/// Read a Herdr agent identity hint from a process environment. +pub fn process_agent_hint(pid: u32) -> Option { + 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 { + 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 { + 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> { if buf.len() < 4 { return None; @@ -852,19 +879,10 @@ fn procargs2_argv(buf: &[u8]) -> Option> { 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> { 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`. @@ -1067,6 +1101,33 @@ mod tests { assert!(!argv.join(" ").contains("codex.system")); } + #[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!( 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 { None } +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn parse_agent_env_hint(environ: &[u8]) -> Option { + 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());