Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Fixed
- Windows panes now keep bare `cursor-agent` launches detected after Cursor hands off to its bundled Node process. (#3032)
- Claude Code panes now use visible turn, background shell, and background agent activity as working-state fallbacks when OSC titles are unavailable or disabled. (#1630, #2241)
- Tab bar status commands now remove ESC-prefixed terminal control sequences instead of displaying their sequence bodies as text. (#3001)
- Unix plugin pane commands now default `PWD` to their resolved working directory, so direct popup tools open at explicit `--cwd` paths while preserving caller-provided `PWD` values. (#2984)
Expand Down
90 changes: 89 additions & 1 deletion src/detect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,9 @@ fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>)
let runtime_name = normalized_agent_lookup_name(path_basename(runtime));

match runtime_name.as_str() {
"node" | "bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]),
"node" => cursor_agent_name_from_bundled_node_argv(argv)
.or_else(|| script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[])),
"bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
name if is_python_runtime(name) => script_arg_agent_name(argv, &["-c"], &["-m"]),
"sh" | "bash" | "zsh" | "fish" => script_arg_agent_name(argv, &["-c"], &[]),
"cmd" => windows_cmd_arg_agent_name(argv),
Expand All @@ -388,6 +390,36 @@ fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>)
}
}

fn cursor_agent_name_from_bundled_node_argv(argv: &[String]) -> Option<String> {
let (runtime_parent, runtime_name) = path_parent_and_basename(argv.first()?)?;
let (script_parent, script_name) = path_parent_and_basename(argv.get(1)?)?;
if !runtime_name.eq_ignore_ascii_case("node.exe")
|| !script_name.eq_ignore_ascii_case("index.js")
|| !runtime_parent.eq_ignore_ascii_case(script_parent)
{
return None;
}

let mut tail = runtime_parent
.rsplit(['/', '\\'])
.filter(|component| !component.is_empty());
let (Some(version), Some(versions), Some(package)) = (tail.next(), tail.next(), tail.next())
else {
return None;
};
(package.eq_ignore_ascii_case("cursor-agent")
&& versions.eq_ignore_ascii_case("versions")
&& !version.trim().is_empty())
Comment on lines +410 to +412

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cursor path suffix remains broad

When an unrelated node.exe and index.js share any directory ending in cursor-agent/versions/<nonempty>, this matcher identifies the process as Cursor, causing spurious Cursor-specific agent and status tracking.

Knowledge Base Used: Agent detection, Ghostty integration, and configuration

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disagree: Herdr process identity is signature-based rather than tied to a trusted install root. This requires the exact bundled node.exe/index.js pair in one cursor-agent/versions directory; requiring the default AppData root would break redirected LOCALAPPDATA. A process deliberately recreating the full official bundle signature is no more distinguishable than one naming itself cursor-agent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — the signature is more constrained than my initial comment implied. The conjunction of node.exe + index.js in the same directory, under a path component sequence of exactly cursor-agent/versions/<nonempty>, is specific enough that an accidental collision is extremely unlikely. And you're right that anchoring to AppData\Local would break redirected LOCALAPPDATA environments, which is a real Windows enterprise concern.

The threat model here is also relevant: this is process identification for tracking, not a trust boundary, so the bar for "specific enough" is whether false positives are plausible in normal operation — not whether the path is cryptographically verified.

I'll withdraw the concern. The current implementation is appropriate for Herdr's signature-based identity model.

Should I remember this as a rule: "Herdr process detection is signature-based; do not flag detectors for lacking a trusted install-root anchor, as path anchoring would break redirected LOCALAPPDATA and is outside the threat model"?

.then(|| agent_label(Agent::Cursor).to_string())
}

fn path_parent_and_basename(path: &str) -> Option<(&str, &str)> {
let split = path.rfind(['/', '\\'])?;
let parent = path[..split].trim_end_matches(['/', '\\']);
let basename = &path[split + 1..];
(!parent.is_empty() && !basename.is_empty()).then_some((parent, basename))
}

fn windows_cmd_arg_agent_name(argv: &[String]) -> Option<String> {
let mut args = argv.iter().skip(1);
while let Some(arg) = args.next() {
Expand Down Expand Up @@ -894,6 +926,62 @@ mod tests {
}
}

#[test]
fn identify_agent_in_job_detects_windows_cursor_install() {
let job = crate::platform::ForegroundJob {
process_group_id: 123,
processes: vec![foreground_process(
123,
"node.exe",
&[
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\node.exe",
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\index.js",
],
)],
};

assert_eq!(
identify_agent_in_job(&job),
Some((Agent::Cursor, "cursor".to_string()))
);
}

#[test]
fn identify_agent_in_job_ignores_invalid_windows_cursor_install_paths() {
for script in [
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\scripts\postinstall.js",
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\index",
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\index.exe",
] {
let job = crate::platform::ForegroundJob {
process_group_id: 123,
processes: vec![foreground_process(
123,
"node.exe",
&[
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\node.exe",
script,
],
)],
};

assert_eq!(identify_agent_in_job(&job), None, "script: {script}");
}

let lookalike = crate::platform::ForegroundJob {
process_group_id: 123,
processes: vec![foreground_process(
123,
"node.exe",
&[
r"C:\Program Files\nodejs\node.exe",
r"C:\workspace\cursor-agent\versions\test\index.js",
],
)],
};
assert_eq!(identify_agent_in_job(&lookalike), None);
}

#[test]
fn identify_agent_in_job_prefers_recognized_process_group_leader() {
let job = crate::platform::ForegroundJob {
Expand Down
Loading