Skip to content

Commit 0675c9f

Browse files
Merge pull request #214 from websentry-ai/staging
2 parents 969fdc6 + 955b8b8 commit 0675c9f

23 files changed

Lines changed: 2144 additions & 229 deletions

scripts/coding_discovery_tools/ai_tools_discovery.py

Lines changed: 54 additions & 35 deletions
Large diffs are not rendered by default.

scripts/coding_discovery_tools/coding_tool_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,7 @@ def create_all_tool_detectors(os_name: Optional[str] = None) -> list:
736736
ToolDetectorFactory.create_roo_detector(os_name),
737737
]
738738

739-
# Add Claude Cowork detector for macOS and Windows
739+
# Add Claude Cowork detector for macOS, Windows, and Linux
740740
claude_cowork_detector = ToolDetectorFactory.create_claude_cowork_detector(os_name)
741741
if claude_cowork_detector is not None:
742742
detectors.append(claude_cowork_detector)

scripts/coding_discovery_tools/linux/claude_cowork/claude_cowork.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
44
Cowork is the agentic feature of the Claude Desktop app. We treat it as a
55
distinct tool from Claude Code (which is the CLI). A device is considered
6-
to have Cowork installed if the on-disk session tree exists at:
6+
to have Cowork installed if BOTH:
77
8-
~/.config/Claude/local-agent-mode-sessions/
8+
- A Claude Desktop installation is discoverable on disk, AND
9+
- The on-disk session tree exists at
10+
~/.config/Claude/local-agent-mode-sessions/
911
1012
(Claude Desktop is an Electron app and follows the XDG convention of
1113
storing its data under ~/.config/Claude/ on Linux.)
1214
13-
If only the app is present (Cowork never enabled / never used), there is
14-
nothing on disk to report so we return None. When running as root we check
15-
every user's home (and /root) so MDM-style deployments are covered.
15+
If only the app is present (Cowork never enabled / never used) there is
16+
nothing on disk to report, and if only the sessions tree is present (app
17+
uninstalled, config residue left behind) it is not a real install — in both
18+
cases we return None. When running as root we check every user's home (and
19+
/root) so MDM-style deployments are covered.
1620
"""
1721

1822
import logging
@@ -52,17 +56,34 @@ def detect(self) -> Optional[Dict]:
5256
for user_home in get_linux_user_homes():
5357
sessions_dir = _sessions_dir_for_user(user_home)
5458
try:
55-
if sessions_dir.exists() and sessions_dir.is_dir():
56-
app_install = self._find_install_dir()
57-
install_path = str(app_install) if app_install else str(sessions_dir)
58-
return {
59-
"name": self.tool_name,
60-
"version": self.get_version(),
61-
"install_path": install_path,
62-
}
59+
if not (sessions_dir.exists() and sessions_dir.is_dir()):
60+
continue
6361
except OSError as e:
6462
logger.debug(f"Error checking Cowork sessions dir {sessions_dir}: {e}")
6563
continue
64+
65+
# Require the Claude Desktop install to be present. The per-user
66+
# ``~/.config/Claude`` tree (which holds the sessions dir) survives
67+
# an uninstall (anthropics/claude-code#25013), so reporting on the
68+
# sessions dir alone produced false positives. Gate on a real
69+
# install dir.
70+
app_install = self._find_install_dir()
71+
if app_install is None:
72+
logger.debug(
73+
"Cowork sessions present under %s but no Claude Desktop "
74+
"install found; treating as residue (not installed).",
75+
user_home,
76+
)
77+
continue
78+
79+
return {
80+
"name": self.tool_name,
81+
"version": self.get_version(),
82+
# Report the sessions dir (consistent with the macOS detector and
83+
# the central ``_detect_claude_cowork`` path). ``app_install`` is
84+
# the gate, not the reported path.
85+
"install_path": str(sessions_dir),
86+
}
6687
return None
6788

6889
def get_version(self) -> Optional[str]:
@@ -74,7 +95,9 @@ def get_version(self) -> Optional[str]:
7495
"""
7596
return None
7697

77-
def _find_install_dir(self) -> Optional[Path]:
98+
def _find_install_dir(self, user_home: Optional[Path] = None) -> Optional[Path]:
99+
# ``user_home`` is accepted for a uniform call signature with the central
100+
# path; Linux install dirs are machine-global so it is unused here.
78101
for candidate in _candidate_install_dirs():
79102
try:
80103
if candidate.exists() and candidate.is_dir():

scripts/coding_discovery_tools/linux/junie/junie.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from ...coding_tool_base import BaseToolDetector
1111
from ...linux_extraction_helpers import get_linux_user_homes
12+
from ...user_tool_detector import find_junie_binary_for_user
13+
from ..jetbrains.jetbrains import LinuxJetBrainsDetector
1214

1315
logger = logging.getLogger(__name__)
1416

@@ -48,22 +50,75 @@ def get_version(self) -> Optional[str]:
4850
return None
4951

5052
def _detect_junie_for_user(self, user_home: Path) -> Optional[Dict]:
51-
"""Detect Junie installation for a specific user."""
52-
junie_dir = user_home / self.JUNIE_DIR_NAME
53+
"""Detect Junie installation for a specific user.
5354
54-
if not junie_dir.exists() or not junie_dir.is_dir():
55+
Gates on a real install signal — the Junie CLI binary OR the Junie
56+
plugin in a JetBrains IDE — not on the ``~/.junie`` directory, which is
57+
user-authored guidelines residue that survives uninstall. ``~/.junie``
58+
is still used as the version source.
59+
"""
60+
junie_bin = find_junie_binary_for_user(user_home)
61+
install_path: Optional[str] = junie_bin
62+
63+
if not install_path:
64+
install_path = self._has_junie_jetbrains_plugin(user_home)
65+
66+
if not install_path:
5567
return None
5668

57-
logger.debug(f"Found Junie directory at: {junie_dir}")
69+
logger.debug(f"Detected Junie install signal at: {install_path}")
5870

59-
version = self._get_version_from_config(junie_dir)
71+
version = self._get_version_from_config(user_home / self.JUNIE_DIR_NAME)
6072

6173
return {
6274
"name": self.tool_name,
6375
"version": version or "Unknown",
64-
"install_path": str(junie_dir)
76+
"install_path": install_path
6577
}
6678

79+
def _has_junie_jetbrains_plugin(self, user_home: Path) -> Optional[str]:
80+
"""Return an install_path if the Junie plugin is present in a JetBrains
81+
IDE belonging to ``user_home``, else None.
82+
83+
Scoping matters: ``LinuxJetBrainsDetector.detect()`` ignores ``user_home``
84+
and iterates every user home internally, so calling it would attribute
85+
another user's Junie plugin to whichever user is currently being scanned
86+
(cross-user false positive). Instead we drive the detector's per-user
87+
config-dir scan for ``user_home`` only, then enrich with plugins. The
88+
JetBrains detector itself is never modified — only its existing per-user
89+
methods are reused read-only.
90+
"""
91+
try:
92+
jetbrains_detector = LinuxJetBrainsDetector()
93+
scoped_ides = jetbrains_detector._scan_jetbrains_config_dir(user_home)
94+
except (PermissionError, OSError) as e:
95+
logger.debug(f"JetBrains scan for Junie failed under {user_home}: {e}")
96+
return None
97+
98+
for ide in scoped_ides:
99+
config_path = ide.get("config_path")
100+
if not self._path_under_user_home(config_path, user_home):
101+
continue
102+
try:
103+
plugins = jetbrains_detector._get_plugins(config_path)
104+
except (PermissionError, OSError) as e:
105+
logger.debug(f"Plugin scan for Junie failed under {config_path}: {e}")
106+
continue
107+
for plugin_name in plugins:
108+
if "junie" in plugin_name.lower():
109+
return config_path
110+
return None
111+
112+
@staticmethod
113+
def _path_under_user_home(config_path: Optional[str], user_home: Path) -> bool:
114+
"""True if ``config_path`` is inside ``user_home`` (strict scoping guard)."""
115+
if not config_path:
116+
return False
117+
try:
118+
return Path(config_path).resolve().is_relative_to(user_home.resolve())
119+
except (OSError, ValueError):
120+
return False
121+
67122
def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
68123
"""Try to extract Junie version from configuration files."""
69124
config_files = [
@@ -76,7 +131,7 @@ def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
76131
if config_file.exists():
77132
with open(config_file, 'r', encoding='utf-8') as f:
78133
data = json.load(f)
79-
if 'version' in data:
134+
if isinstance(data, dict) and isinstance(data.get('version'), str):
80135
return data['version']
81136
except (json.JSONDecodeError, OSError, PermissionError) as e:
82137
logger.debug(f"Could not read config file {config_file}: {e}")

scripts/coding_discovery_tools/macos/junie/junie.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from typing import Optional, Dict, List
88

99
from ...coding_tool_base import BaseToolDetector
10+
from ...macos.jetbrains.jetbrains import MacOSJetBrainsDetector
1011
from ...macos_extraction_helpers import is_running_as_root
12+
from ...user_tool_detector import find_junie_binary_for_user
1113

1214
logger = logging.getLogger(__name__)
1315

@@ -56,22 +58,74 @@ def get_version(self) -> Optional[str]:
5658
def _detect_junie_for_user(self, user_home: Path) -> Optional[Dict]:
5759
"""
5860
Detect Junie installation for a specific user.
61+
62+
Gates on a real install signal — the Junie CLI binary OR the Junie
63+
plugin in a JetBrains IDE — not on the ``~/.junie`` directory, which is
64+
user-authored guidelines residue that survives uninstall. ``~/.junie``
65+
is still used as the version source.
5966
"""
60-
junie_dir = user_home / self.JUNIE_DIR_NAME
67+
junie_bin = find_junie_binary_for_user(user_home)
68+
install_path: Optional[str] = junie_bin
69+
70+
if not install_path:
71+
install_path = self._has_junie_jetbrains_plugin(user_home)
6172

62-
if not junie_dir.exists() or not junie_dir.is_dir():
73+
if not install_path:
6374
return None
6475

65-
logger.debug(f"Found Junie directory at: {junie_dir}")
76+
logger.debug(f"Detected Junie install signal at: {install_path}")
6677

67-
version = self._get_version_from_config(junie_dir)
78+
version = self._get_version_from_config(user_home / self.JUNIE_DIR_NAME)
6879

6980
return {
7081
"name": self.tool_name,
7182
"version": version or "Unknown",
72-
"install_path": str(junie_dir)
83+
"install_path": install_path
7384
}
7485

86+
def _has_junie_jetbrains_plugin(self, user_home: Path) -> Optional[str]:
87+
"""Return an install_path if the Junie plugin is present in a JetBrains
88+
IDE belonging to ``user_home``, else None.
89+
90+
Scoping matters: ``MacOSJetBrainsDetector.detect()`` ignores ``user_home``
91+
and, under root, scans every user under ``/Users``. Calling it here would
92+
attribute another user's Junie plugin to whichever user is currently
93+
being scanned (cross-user false positive). Instead we drive the
94+
detector's per-user config-dir scan for ``user_home`` only, then enrich
95+
with plugins. The JetBrains detector itself is never modified — only its
96+
existing per-user methods are reused read-only.
97+
"""
98+
try:
99+
jetbrains_detector = MacOSJetBrainsDetector()
100+
scoped_ides = jetbrains_detector._scan_jetbrains_config_dir(user_home)
101+
except (PermissionError, OSError) as e:
102+
logger.debug(f"JetBrains scan for Junie failed under {user_home}: {e}")
103+
return None
104+
105+
for ide in scoped_ides:
106+
config_path = ide.get("config_path")
107+
if not self._path_under_user_home(config_path, user_home):
108+
continue
109+
try:
110+
plugins = jetbrains_detector._get_plugins(config_path)
111+
except (PermissionError, OSError) as e:
112+
logger.debug(f"Plugin scan for Junie failed under {config_path}: {e}")
113+
continue
114+
for plugin_name in plugins:
115+
if "junie" in plugin_name.lower():
116+
return config_path
117+
return None
118+
119+
@staticmethod
120+
def _path_under_user_home(config_path: Optional[str], user_home: Path) -> bool:
121+
"""True if ``config_path`` is inside ``user_home`` (strict scoping guard)."""
122+
if not config_path:
123+
return False
124+
try:
125+
return Path(config_path).resolve().is_relative_to(user_home.resolve())
126+
except (OSError, ValueError):
127+
return False
128+
75129
def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
76130
"""
77131
Try to extract Junie version from configuration files.
@@ -87,7 +141,7 @@ def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
87141
import json
88142
with open(config_file, 'r', encoding='utf-8') as f:
89143
data = json.load(f)
90-
if 'version' in data:
144+
if isinstance(data, dict) and isinstance(data.get('version'), str):
91145
return data['version']
92146
except (json.JSONDecodeError, OSError, PermissionError) as e:
93147
logger.debug(f"Could not read config file {config_file}: {e}")

0 commit comments

Comments
 (0)