Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

633 lines
20 KiB
Python

"""Read-only discovery of local AI-agent processes."""
from __future__ import annotations
import logging
import os
import shlex
import subprocess
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
from tools.system.fleet_monitoring.provider_ids import provider_from_classified_name
from tools.system.fleet_monitoring.registry import AgentRecord, AgentRegistry
logger = logging.getLogger(__name__)
_DEFAULT_CURSOR_PROJECTS_DIR = Path.home() / ".cursor" / "projects"
_PS_COMMAND = ("ps", "-axo", "pid=,ppid=,args=")
_MAX_DISPLAY_COMMAND_LENGTH = 120
_CODEX_LAUNCHER_TOKENS: frozenset[str] = frozenset({"codex", "codex.js", "codex.mjs", "codex.cjs"})
_NODE_EXECUTABLES: frozenset[str] = frozenset({"node", "nodejs"})
_NOISE_PROCESS_TOKENS: tuple[str, ...] = (
"chrome_crashpad_handler",
"shipit",
"helper",
"extension-host",
"filewatcher",
"pty-host",
"shared-process",
"language-server",
"languageserver",
"serverworker",
"rust-analyzer",
"esbuild",
)
_NOISE_ARG_PREFIXES: tuple[str, ...] = ("--type=", "--utility-sub-type=")
_LOOSE_AGENT_SIGNATURES: tuple[tuple[str, str], ...] = (
("claude", "claude-code"),
("claude-code", "claude-code"),
("cursor", "cursor"),
("aider", "aider"),
("codex", "codex"),
("gemini", "gemini-cli"),
("agy", "antigravity-cli"),
)
_CLAUDE_CODE_CLI_ARG_TOKENS: frozenset[str] = frozenset(
{"--resume", "--prefill", "--print", "--continue", "-p", "-c", "-r"}
)
_CLAUDE_DESKTOP_PATH_HINTS: tuple[str, ...] = (
"claude.app/contents/",
"/claude-desktop/",
"/snap/claude/",
"/usr/lib/claude-desktop/",
"/.mount_claude",
"\\program files\\claude\\",
"\\appdata\\local\\programs\\claude\\",
)
def process_has_open_codex_rollout(pid: int) -> bool:
"""Return whether ``pid`` owns a Codex rollout file when psutil is available."""
try:
from tools.system.fleet_monitoring.probe import (
process_has_open_codex_rollout as probe_rollout_owner,
)
except ModuleNotFoundError as exc:
if exc.name != "psutil":
raise
logger.debug("psutil is unavailable; skipping Codex rollout-owner probe")
return False
return probe_rollout_owner(pid)
# macOS hints target the main bundle binary at
# `<App>.app/Contents/MacOS/<App>` only, not the whole bundle. This keeps
# Electron helper processes living under `<App>.app/Contents/Frameworks/`
# eligible for the loose matcher — e.g. Cursor's agent runs as a
# `Cursor Helper (Plugin)` subprocess that callers still expect to see
# surfaced as `cursor` in `opensre fleet scan --all`.
_CODEX_DESKTOP_PATH_HINTS: tuple[str, ...] = (
"codex.app/contents/macos/codex/",
"/snap/codex/",
"/.mount_codex",
"/var/lib/flatpak/app/com.openai.codex/",
"\\program files\\codex\\",
"\\appdata\\local\\programs\\codex\\",
)
_CURSOR_DESKTOP_PATH_HINTS: tuple[str, ...] = (
"cursor.app/contents/macos/cursor/",
"/snap/cursor/",
"/.mount_cursor",
"/var/lib/flatpak/app/com.cursor.cursor/",
"\\program files\\cursor\\",
"\\appdata\\local\\programs\\cursor\\",
)
@dataclass(frozen=True)
class DiscoveredAgent:
"""Candidate process discovered for the ``opensre fleet scan`` command."""
name: str
pid: int
command: str
def to_record(self) -> AgentRecord:
return AgentRecord(
name=self.name,
pid=self.pid,
command=self.command,
source="discovered",
provider=provider_from_classified_name(self.name),
)
@dataclass(frozen=True)
class ProcessRow:
"""Minimal process-list row used by the discovery rules."""
pid: int
command: str
ppid: int | None = None
def discover_agent_processes(*, include_all: bool = False) -> list[DiscoveredAgent]:
"""Return likely local AI-agent sessions visible to the current user."""
candidates_by_pid: dict[int, tuple[str, ProcessRow]] = {}
current_pid = os.getpid()
rows = _current_process_rows()
for row in rows:
if row.pid <= 0 or row.pid == current_pid:
continue
cmdline = _split_command(row.command)
process_name = _normalized_token(cmdline[0]) if cmdline else ""
agent_name = _classify_agent(process_name, cmdline, include_all=include_all)
if agent_name is None:
continue
candidates_by_pid[row.pid] = (agent_name, row)
for pid in _codex_duplicate_pids_to_drop(
rows,
{pid for pid, (name, _) in candidates_by_pid.items() if name == "codex"},
):
candidates_by_pid.pop(pid, None)
candidates = [
DiscoveredAgent(name=f"{agent_name}-{row.pid}", pid=row.pid, command=row.command)
for agent_name, row in candidates_by_pid.values()
]
return sorted(candidates, key=lambda item: (item.name, item.pid))
def discover_agents(
*,
process_rows: Iterable[ProcessRow] | None = None,
cursor_projects_dir: Path = _DEFAULT_CURSOR_PROJECTS_DIR,
) -> list[AgentRecord]:
"""Return agent-like processes discovered from the local machine.
Discovery is intentionally read-only. The registry remains the source of
explicit user-tracked agents; this function surfaces obvious Cursor,
Claude Code, Codex, Aider, and Gemini CLI processes that are already
running but have not been registered.
"""
records_by_pid: dict[int, AgentRecord] = {}
rows = list(process_rows) if process_rows is not None else _current_process_rows()
for row in rows:
name = _agent_name_for_command(row.command)
if name is None:
continue
records_by_pid[row.pid] = AgentRecord(
name=name,
pid=row.pid,
command=row.command,
source="discovered",
provider=provider_from_classified_name(name),
)
for record in _discover_cursor_terminal_agents(cursor_projects_dir):
records_by_pid.setdefault(record.pid, record)
for pid in _codex_duplicate_pids_to_drop(
rows,
{pid for pid, record in records_by_pid.items() if record.name == "codex"},
):
records_by_pid.pop(pid, None)
return sorted(records_by_pid.values(), key=lambda record: (record.name, record.pid))
def registered_and_discovered_agents(
registry: AgentRegistry | None = None,
) -> list[AgentRecord]:
"""Merge explicit registry rows with read-only discovered agent rows."""
registry = registry or AgentRegistry()
records_by_pid = {record.pid: record for record in registry.list()}
for record in discover_agents():
records_by_pid.setdefault(record.pid, record)
return sorted(records_by_pid.values(), key=lambda record: (record.name, record.pid))
def process_command(pid: int) -> str | None:
"""Best-effort command line for a PID, or ``None`` if unavailable."""
try:
proc = subprocess.run(
("ps", "-p", str(pid), "-o", "args="),
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=2.0,
)
except (OSError, subprocess.TimeoutExpired, ValueError):
return None
if proc.returncode != 0:
return None
return proc.stdout.strip() or None
def display_command(command: str) -> str:
"""Return a terminal-friendly one-line command for scan output."""
collapsed = " ".join(command.split())
if len(collapsed) <= _MAX_DISPLAY_COMMAND_LENGTH:
return collapsed
return f"{collapsed[: _MAX_DISPLAY_COMMAND_LENGTH - 3]}..."
def classify_command_provider(command: str) -> str | None:
"""Return the canonical provider id for ``command``, or ``None``.
Strict argv[0]-only matching for claude / aider / gemini, plus
Cursor-extension substring rules, plus ``_is_codex_cmdline`` so
Node-launched Codex wrappers (``node /opt/codex.js`` and friends)
classify correctly without re-introducing the loose token-scan
false-positives that ``provider_from_command`` was tightened to
avoid.
"""
cmdline = _split_command(command)
if not cmdline:
return None
executable = _normalized_token(cmdline[0])
lower = command.lower()
if ".cursor/extensions/anthropic.claude-code" in lower:
return "claude-code"
if "extension-host (agent-exec)" in lower:
return "cursor"
if "cursor-agent" in lower or "cursor agent" in lower:
return "cursor"
if executable in {"claude", "claude-code"}:
return "claude-code"
if executable == "aider":
return "aider"
if executable == "gemini":
return "gemini-cli"
if executable == "agy":
return "antigravity-cli"
if _is_codex_cmdline(cmdline):
return "codex"
return None
def _current_process_rows() -> list[ProcessRow]:
try:
proc = subprocess.run(
_PS_COMMAND,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=2.0,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("agent discovery: failed to run ps", exc_info=True)
return []
if proc.returncode != 0:
logger.debug("agent discovery: ps exited with code %s", proc.returncode)
return []
rows: list[ProcessRow] = []
for line in proc.stdout.splitlines():
row = _parse_ps_line(line)
if row is not None:
rows.append(row)
return rows
def _parse_ps_line(line: str) -> ProcessRow | None:
stripped = line.strip()
if not stripped:
return None
parts = stripped.split(maxsplit=2)
if len(parts) < 2:
return None
try:
pid = int(parts[0])
except ValueError:
return None
if len(parts) == 2:
try:
ppid = int(parts[1])
except ValueError:
ppid = None
return ProcessRow(pid=pid, ppid=ppid, command="")
try:
ppid = int(parts[1])
except ValueError:
ppid = None
return ProcessRow(pid=pid, ppid=ppid, command=parts[2])
def _split_command(command: str) -> list[str]:
try:
return shlex.split(command)
except ValueError:
return command.split()
def _codex_duplicate_pids_to_drop(rows: Iterable[ProcessRow], codex_pids: set[int]) -> set[int]:
if len(codex_pids) < 2:
return set()
rows_by_pid = {row.pid: row for row in rows if row.pid in codex_pids}
rollout_owner_cache: dict[int, bool] = {}
pids_to_drop: set[int] = set()
def owns_rollout(pid: int) -> bool:
if pid not in rollout_owner_cache:
rollout_owner_cache[pid] = process_has_open_codex_rollout(pid)
return rollout_owner_cache[pid]
for child in rows_by_pid.values():
if child.ppid is None:
continue
parent = rows_by_pid.get(child.ppid)
if parent is None or not _is_codex_wrapper_native_pair(parent, child):
continue
keep_pid = _preferred_codex_pair_pid(parent, child, owns_rollout)
drop_pid = parent.pid if keep_pid == child.pid else child.pid
pids_to_drop.add(drop_pid)
return pids_to_drop
def _is_codex_wrapper_native_pair(parent: ProcessRow, child: ProcessRow) -> bool:
return _is_node_codex_wrapper(parent.command) and _is_native_codex_process(child.command)
def _is_node_codex_wrapper(command: str) -> bool:
cmdline = _split_command(command)
return _is_node_codex_cmdline(cmdline)
def _is_native_codex_process(command: str) -> bool:
cmdline = _split_command(command)
return bool(cmdline) and _is_codex_launcher_token(cmdline[0])
def _is_codex_command(command: str) -> bool:
return _is_codex_cmdline(_split_command(command))
def _is_codex_cmdline(cmdline: list[str]) -> bool:
if not cmdline:
return False
if _is_codex_launcher_token(cmdline[0]):
return True
return _is_node_codex_cmdline(cmdline)
def _is_node_codex_cmdline(cmdline: list[str]) -> bool:
if not cmdline or _normalized_token(cmdline[0]) not in _NODE_EXECUTABLES:
return False
return any(_is_codex_launcher_token(part) for part in cmdline[1:])
def _is_codex_launcher_token(value: str) -> bool:
return _normalized_token(value) in _CODEX_LAUNCHER_TOKENS
def _preferred_codex_pair_pid(
parent: ProcessRow,
child: ProcessRow,
owns_rollout: Callable[[int], bool],
) -> int:
parent_owns_rollout = owns_rollout(parent.pid)
child_owns_rollout = owns_rollout(child.pid)
if parent_owns_rollout and not child_owns_rollout:
return parent.pid
return child.pid
def _discover_cursor_terminal_agents(cursor_projects_dir: Path) -> list[AgentRecord]:
if not cursor_projects_dir.is_dir():
return []
records: list[AgentRecord] = []
for path in sorted(cursor_projects_dir.glob("*/terminals/*.txt")):
record = _record_from_cursor_terminal(path)
if record is not None:
records.append(record)
return records
def _record_from_cursor_terminal(path: Path) -> AgentRecord | None:
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[:8]
except OSError:
return None
metadata: dict[str, str] = {}
for line in lines:
if line == "---":
continue
key, sep, value = line.partition(":")
if sep:
metadata[key.strip()] = value.strip()
raw_pid = metadata.get("pid")
command = metadata.get("active_command") or metadata.get("last_command")
if raw_pid is None or command is None:
return None
try:
pid = int(raw_pid)
except ValueError:
return None
name = _agent_name_for_command(command)
if name is None:
return None
return AgentRecord(
name=name,
pid=pid,
command=command,
source="discovered",
provider=provider_from_classified_name(name),
)
def _agent_name_for_command(command: str) -> str | None:
lower = command.lower()
cmdline = _split_command(command)
if ".cursor/extensions/anthropic.claude-code" in lower:
return "cursor-claude-code"
if "extension-host (agent-exec)" in lower:
return "cursor-agent-exec"
if "cursor-agent" in lower or "cursor agent" in lower:
return "cursor-agent"
if not _is_claude_desktop_artifact(cmdline) and _claude_code_cli_matches_cmdline(cmdline):
return "claude-code"
if not _is_codex_desktop_artifact(cmdline) and _is_codex_command(command):
return "codex"
# No cursor desktop guard here: this function never returns a bare
# `cursor` label (only `cursor-agent` / `cursor-agent-exec` / `cursor-
# claude-code`), so Cursor.app cannot reach a labelling path. Add one
# if a bare-`cursor` return is ever introduced.
if _has_command_token(lower, "aider"):
return "aider"
if _has_command_token(lower, "gemini"):
return "gemini-cli"
if _has_command_token(lower, "agy"):
return "antigravity-cli"
return None
def _claude_code_cli_matches_cmdline(cmdline: list[str]) -> bool:
if not cmdline:
return False
if _normalized_token(cmdline[0]) != "claude":
return False
lowered = [part.lower() for part in cmdline]
args = [_normalized_token(part) for part in cmdline[1:]]
if "code" in args:
return True
if _has_option_pair(lowered, "--input-format", "stream-json"):
return True
if _has_option_pair(lowered, "--output-format", "stream-json"):
return True
if any(_option_name(token) in _CLAUDE_CODE_CLI_ARG_TOKENS for token in lowered[1:]):
return True
return len(cmdline) == 1
def _option_name(arg: str) -> str:
return arg.split("=", 1)[0]
def _classify_agent(
process_name: str,
cmdline: list[str],
*,
include_all: bool,
) -> str | None:
if not cmdline:
return None
executable = _normalized_token(cmdline[0])
if executable == "claude":
if _is_claude_desktop_artifact(cmdline):
return None
if _claude_code_cli_matches_cmdline(cmdline):
return "claude-code"
# Intentional fall-through: an unrecognised `claude …` shape still
# reaches the noise / loose-classification path below so that `--all`
# surfaces it via the `claude` token signature. Do not add an early
# `return None` here — it would silently break `opensre fleet scan --all`.
if _is_noise_process(process_name, cmdline):
return _classify_agent_loose(process_name, cmdline) if include_all else None
if executable == "aider":
return "aider"
if executable == "codex":
if _is_codex_desktop_artifact(cmdline):
return None
return "codex"
if executable == "gemini":
return "gemini-cli"
if executable == "agy":
return "antigravity-cli"
if executable in {"cursor-agent", "cursor-agent-cli"}:
return "cursor"
if executable == "cursor" and _is_cursor_desktop_artifact(cmdline):
# Belt-and-suspenders: no current strict branch returns "cursor" for a
# bare `cursor` executable (Cursor's CLI is `cursor-agent`), so this is
# a no-op today. Kept explicit so a future `if executable == "cursor":
# return "cursor"` cannot silently reintroduce the desktop mislabel.
return None
if include_all:
return _classify_agent_loose(process_name, cmdline)
return None
def _classify_agent_loose(process_name: str, cmdline: list[str]) -> str | None:
if (
_is_claude_desktop_artifact(cmdline)
or _is_codex_desktop_artifact(cmdline)
or _is_cursor_desktop_artifact(cmdline)
):
return None
haystack = f"{process_name} {' '.join(cmdline)}".lower()
tokens = {_normalized_token(part) for part in cmdline}
tokens.add(_normalized_token(process_name))
for signature, label in _LOOSE_AGENT_SIGNATURES:
if label == "codex":
if _is_codex_cmdline(cmdline):
return label
continue
if signature in tokens or signature in haystack:
return label
return None
def _matches_desktop_path_hints(cmdline: list[str], hints: tuple[str, ...]) -> bool:
# Match desktop-bundle / installer hints against argv[0] only — never against
# the full command. Prompt-bearing flags (e.g. `claude --print "inspect
# /Applications/Claude.app/..."`) must not falsely trip the negative filter.
#
# The trailing-slash sentinel lets directory hints (e.g. `/<agent>-desktop/`)
# match both directories (`/usr/lib/<agent>-desktop/...`) and end-of-string
# binaries (`/.../bin/<agent>-desktop`) while rejecting substrings like
# `/.../my-<agent>-desktop-tools/`.
if not cmdline:
return False
lowered = cmdline[0].lower() + "/"
return any(hint in lowered for hint in hints)
def _is_claude_desktop_artifact(cmdline: list[str]) -> bool:
return _matches_desktop_path_hints(cmdline, _CLAUDE_DESKTOP_PATH_HINTS)
def _is_codex_desktop_artifact(cmdline: list[str]) -> bool:
return _matches_desktop_path_hints(cmdline, _CODEX_DESKTOP_PATH_HINTS)
def _is_cursor_desktop_artifact(cmdline: list[str]) -> bool:
return _matches_desktop_path_hints(cmdline, _CURSOR_DESKTOP_PATH_HINTS)
def _is_noise_process(process_name: str, cmdline: list[str]) -> bool:
# Restrict the haystack to process_name + argv[0] so prompt-bearing flags
# like `claude --print "...helper..."` do not falsely trip the noise filter.
haystack_parts = [_normalized_token(process_name)]
if cmdline:
haystack_parts.append(_normalized_token(cmdline[0]))
haystack = " ".join(part for part in haystack_parts if part)
if any(token in haystack for token in _NOISE_PROCESS_TOKENS):
return True
return any(arg.startswith(_NOISE_ARG_PREFIXES) for arg in cmdline[1:])
def _has_option_pair(cmdline: list[str], option: str, value: str) -> bool:
for index, part in enumerate(cmdline):
if part == option and index + 1 < len(cmdline) and cmdline[index + 1] == value:
return True
if part == f"{option}={value}":
return True
return False
def _has_command_token(command: str, token: str) -> bool:
normalized = command.replace("/", " ").replace("\\", " ")
normalized = normalized.replace("-", " ").replace("_", " ")
return token in normalized.split()
def _normalized_token(value: str) -> str:
return Path(value.strip("'\"")).name.lower()
__all__ = [
"DiscoveredAgent",
"ProcessRow",
"discover_agent_processes",
"discover_agents",
"display_command",
"process_command",
"registered_and_discovered_agents",
]