Skip to content

Commit 6cdf3ee

Browse files
feat: implement desktop detectors for Cursor and Codex App (issue #20)
* feat(rust): add desktop detector error signaling for missing and permission failures (issue #20) * test(ts): add desktop detector contracts and update cli reason-code assertions (issue #20)
1 parent 7cc0ee3 commit 6cdf3ee

File tree

4 files changed

+215
-29
lines changed

4 files changed

+215
-29
lines changed

src-tauri/src/detection/path_based.rs

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,21 @@ pub fn evaluate_path_based_detector(
2929
let config_probe =
3030
probe_config_path(config.config_override_env_var, config.config_fallback_paths);
3131

32-
let (config_path, override_invalid_path) = match config_probe {
32+
let (config_path, probe_issue) = match config_probe {
3333
ConfigProbe::Resolved(path) => (Some(path), None),
34-
ConfigProbe::OverrideInvalid(path) => (None, Some(path)),
34+
ConfigProbe::OverrideMissing(path) => (None, Some(ProbeIssue::OverrideMissing(path))),
35+
ConfigProbe::OverridePermissionDenied(path) => {
36+
(None, Some(ProbeIssue::OverridePermissionDenied(path)))
37+
}
38+
ConfigProbe::PermissionDenied(path) => (None, Some(ProbeIssue::PermissionDenied(path))),
3539
ConfigProbe::Missing => (None, None),
3640
};
3741

3842
let (status, confidence, note) = resolve_status_and_note(
3943
config,
4044
binary_path.is_some(),
4145
config_path.is_some(),
42-
override_invalid_path.as_deref(),
46+
probe_issue.as_ref(),
4347
);
4448

4549
let evidence = DetectionEvidence {
@@ -61,21 +65,46 @@ pub fn evaluate_path_based_detector(
6165
}
6266
}
6367

68+
#[derive(Debug, Clone)]
69+
enum ProbeIssue {
70+
OverrideMissing(String),
71+
OverridePermissionDenied(String),
72+
PermissionDenied(String),
73+
}
74+
6475
fn resolve_status_and_note(
6576
config: &PathBasedDetectorConfig,
6677
binary_found: bool,
6778
config_found: bool,
68-
override_invalid_path: Option<&str>,
79+
probe_issue: Option<&ProbeIssue>,
6980
) -> (DetectionStatus, u8, String) {
70-
if let Some(invalid_path) = override_invalid_path {
71-
return (
72-
DetectionStatus::Partial,
73-
20,
74-
format!(
75-
"[config_override_invalid] {} override '{}' is set but unreadable: {}",
76-
config.display_name, config.config_override_env_var, invalid_path
81+
if let Some(issue) = probe_issue {
82+
return match issue {
83+
ProbeIssue::OverrideMissing(path) => (
84+
DetectionStatus::Partial,
85+
20,
86+
format!(
87+
"[config_override_missing] {} override '{}' points to missing config: {}",
88+
config.display_name, config.config_override_env_var, path
89+
),
7790
),
78-
);
91+
ProbeIssue::OverridePermissionDenied(path) => (
92+
DetectionStatus::Error,
93+
0,
94+
format!(
95+
"[config_permission_denied] {} override '{}' is not readable: {}",
96+
config.display_name, config.config_override_env_var, path
97+
),
98+
),
99+
ProbeIssue::PermissionDenied(path) => (
100+
DetectionStatus::Error,
101+
0,
102+
format!(
103+
"[config_permission_denied] {} fallback config is not readable: {}",
104+
config.display_name, path
105+
),
106+
),
107+
};
79108
}
80109

81110
match config.kind {
@@ -150,10 +179,10 @@ fn resolve_status_and_note(
150179
mod tests {
151180
use crate::contracts::{common::ClientKind, detect::DetectionStatus};
152181

153-
use super::{DetectorKind, PathBasedDetectorConfig, resolve_status_and_note};
182+
use super::{DetectorKind, PathBasedDetectorConfig, ProbeIssue, resolve_status_and_note};
154183

155184
#[test]
156-
fn invalid_override_resolves_to_partial_state() {
185+
fn missing_override_resolves_to_partial_state() {
157186
let config = PathBasedDetectorConfig {
158187
client: ClientKind::CodexCli,
159188
display_name: "Test CLI",
@@ -163,13 +192,19 @@ mod tests {
163192
config_fallback_paths: &[],
164193
};
165194

166-
let (status, confidence, note) =
167-
resolve_status_and_note(&config, false, false, Some("/definitely/not/a/file.json"));
195+
let (status, confidence, note) = resolve_status_and_note(
196+
&config,
197+
false,
198+
false,
199+
Some(&ProbeIssue::OverrideMissing(
200+
"/definitely/not/a/file.json".to_string(),
201+
)),
202+
);
168203

169204
assert!(matches!(status, DetectionStatus::Partial));
170205
assert_eq!(confidence, 20);
171206
assert!(note.contains("AI_MANAGER_TEST_INVALID_OVERRIDE"));
172-
assert!(note.contains("[config_override_invalid]"));
207+
assert!(note.contains("[config_override_missing]"));
173208
}
174209

175210
#[test]
@@ -202,4 +237,29 @@ mod tests {
202237
assert!(note.contains(reason_code));
203238
}
204239
}
240+
241+
#[test]
242+
fn desktop_detector_surfaces_permission_failures_as_error() {
243+
let config = PathBasedDetectorConfig {
244+
client: ClientKind::Cursor,
245+
display_name: "Cursor",
246+
kind: DetectorKind::Desktop,
247+
binary_candidates: &[],
248+
config_override_env_var: "AI_MANAGER_CURSOR_MCP_CONFIG",
249+
config_fallback_paths: &[],
250+
};
251+
252+
let (status, confidence, note) = resolve_status_and_note(
253+
&config,
254+
false,
255+
false,
256+
Some(&ProbeIssue::PermissionDenied(
257+
"/tmp/unreadable/mcp.json".to_string(),
258+
)),
259+
);
260+
261+
assert!(matches!(status, DetectionStatus::Error));
262+
assert_eq!(confidence, 0);
263+
assert!(note.contains("[config_permission_denied]"));
264+
}
205265
}

src-tauri/src/detection/probe.rs

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use std::path::{Path, PathBuf};
33

44
pub enum ConfigProbe {
55
Resolved(String),
6-
OverrideInvalid(String),
6+
OverrideMissing(String),
7+
OverridePermissionDenied(String),
8+
PermissionDenied(String),
79
Missing,
810
}
911

@@ -28,20 +30,40 @@ pub fn probe_config_path_with_override(
2830
) -> ConfigProbe {
2931
if let Some(override_value) = override_value {
3032
let expanded = expand_user_path(override_value);
31-
if is_readable_file(&expanded) {
32-
return ConfigProbe::Resolved(expanded.to_string_lossy().to_string());
33-
}
34-
35-
return ConfigProbe::OverrideInvalid(expanded.to_string_lossy().to_string());
33+
return match file_access_outcome(&expanded) {
34+
FileAccessOutcome::ReadableFile => {
35+
ConfigProbe::Resolved(expanded.to_string_lossy().to_string())
36+
}
37+
FileAccessOutcome::PermissionDenied => {
38+
ConfigProbe::OverridePermissionDenied(expanded.to_string_lossy().to_string())
39+
}
40+
FileAccessOutcome::NotFoundOrNotFile => {
41+
ConfigProbe::OverrideMissing(expanded.to_string_lossy().to_string())
42+
}
43+
};
3644
}
3745

46+
let mut first_permission_denied: Option<String> = None;
47+
3848
for fallback in fallbacks {
3949
let expanded = expand_user_path(fallback);
40-
if is_readable_file(&expanded) {
41-
return ConfigProbe::Resolved(expanded.to_string_lossy().to_string());
50+
match file_access_outcome(&expanded) {
51+
FileAccessOutcome::ReadableFile => {
52+
return ConfigProbe::Resolved(expanded.to_string_lossy().to_string());
53+
}
54+
FileAccessOutcome::PermissionDenied => {
55+
if first_permission_denied.is_none() {
56+
first_permission_denied = Some(expanded.to_string_lossy().to_string());
57+
}
58+
}
59+
FileAccessOutcome::NotFoundOrNotFile => {}
4260
}
4361
}
4462

63+
if let Some(path) = first_permission_denied {
64+
return ConfigProbe::PermissionDenied(path);
65+
}
66+
4567
ConfigProbe::Missing
4668
}
4769

@@ -62,8 +84,25 @@ fn expand_user_path(value: &str) -> PathBuf {
6284
PathBuf::from(value)
6385
}
6486

65-
fn is_readable_file(path: &Path) -> bool {
66-
path.is_file() && std::fs::File::open(path).is_ok()
87+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88+
enum FileAccessOutcome {
89+
ReadableFile,
90+
PermissionDenied,
91+
NotFoundOrNotFile,
92+
}
93+
94+
fn file_access_outcome(path: &Path) -> FileAccessOutcome {
95+
if !path.is_file() {
96+
return FileAccessOutcome::NotFoundOrNotFile;
97+
}
98+
99+
match std::fs::File::open(path) {
100+
Ok(_) => FileAccessOutcome::ReadableFile,
101+
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
102+
FileAccessOutcome::PermissionDenied
103+
}
104+
Err(_) => FileAccessOutcome::NotFoundOrNotFile,
105+
}
67106
}
68107

69108
fn find_command_in_path(command: &str) -> Option<PathBuf> {
@@ -111,7 +150,7 @@ mod tests {
111150
let _ = fs::remove_file(&fallback_path);
112151
let _ = fs::remove_dir(&temp_dir);
113152

114-
assert!(matches!(outcome, ConfigProbe::OverrideInvalid(_)));
153+
assert!(matches!(outcome, ConfigProbe::OverrideMissing(_)));
115154
}
116155

117156
#[test]
@@ -136,4 +175,53 @@ mod tests {
136175

137176
assert!(matches!(outcome, ConfigProbe::Resolved(_)));
138177
}
178+
179+
#[test]
180+
fn override_path_reports_permission_denied_when_file_is_unreadable() {
181+
let temp_dir = std::env::temp_dir().join(format!(
182+
"ai-manager-detection-test-permission-{}",
183+
std::process::id()
184+
));
185+
let _ = fs::create_dir_all(&temp_dir);
186+
187+
let protected_file = temp_dir.join("protected-config.json");
188+
fs::write(&protected_file, "{}").expect("should create protected fixture");
189+
190+
#[cfg(unix)]
191+
{
192+
use std::os::unix::fs::PermissionsExt;
193+
let mut perms = fs::metadata(&protected_file)
194+
.expect("protected file metadata should exist")
195+
.permissions();
196+
perms.set_mode(0o000);
197+
fs::set_permissions(&protected_file, perms).expect("should change protected mode");
198+
}
199+
200+
let protected_str = protected_file
201+
.to_str()
202+
.expect("protected path should be valid utf-8");
203+
204+
let outcome = probe_config_path_with_override(Some(protected_str), &[]);
205+
206+
#[cfg(unix)]
207+
{
208+
use std::os::unix::fs::PermissionsExt;
209+
let mut perms = fs::metadata(&protected_file)
210+
.expect("protected file metadata should exist")
211+
.permissions();
212+
perms.set_mode(0o644);
213+
let _ = fs::set_permissions(&protected_file, perms);
214+
}
215+
216+
let _ = fs::remove_file(&protected_file);
217+
let _ = fs::remove_dir(&temp_dir);
218+
219+
#[cfg(unix)]
220+
assert!(matches!(outcome, ConfigProbe::OverridePermissionDenied(_)));
221+
#[cfg(not(unix))]
222+
assert!(matches!(
223+
outcome,
224+
ConfigProbe::OverridePermissionDenied(_) | ConfigProbe::Resolved(_)
225+
));
226+
}
139227
}

tests/cli-detectors.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,5 @@ test("cli detector reason codes distinguish missing binary vs missing config", (
3434
assert.match(pathBasedDetector, /\[config_missing\]/);
3535
assert.match(pathBasedDetector, /\[binary_missing\]/);
3636
assert.match(pathBasedDetector, /\[binary_and_config_missing\]/);
37-
assert.match(pathBasedDetector, /\[config_override_invalid\]/);
37+
assert.match(pathBasedDetector, /\[config_override_missing\]/);
3838
});

tests/desktop-detectors.test.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import assert from "node:assert/strict";
2+
import { readFileSync } from "node:fs";
3+
import test from "node:test";
4+
5+
const cursorDetector = readFileSync(
6+
new URL("../src-tauri/src/detection/clients/cursor.rs", import.meta.url),
7+
"utf8",
8+
);
9+
const codexAppDetector = readFileSync(
10+
new URL("../src-tauri/src/detection/clients/codex_app.rs", import.meta.url),
11+
"utf8",
12+
);
13+
const pathBasedDetector = readFileSync(
14+
new URL("../src-tauri/src/detection/path_based.rs", import.meta.url),
15+
"utf8",
16+
);
17+
const probeImplementation = readFileSync(
18+
new URL("../src-tauri/src/detection/probe.rs", import.meta.url),
19+
"utf8",
20+
);
21+
22+
test("cursor detector includes app-data fallback candidates", () => {
23+
assert.match(cursorDetector, /config_override_env_var: "AI_MANAGER_CURSOR_MCP_CONFIG"/);
24+
assert.match(cursorDetector, /~\/.cursor\/mcp\.json/);
25+
assert.match(cursorDetector, /Library\/Application Support\/Cursor\/User\/mcp\.json/);
26+
});
27+
28+
test("codex app detector includes app-data fallback candidates", () => {
29+
assert.match(codexAppDetector, /config_override_env_var: "AI_MANAGER_CODEX_APP_MCP_CONFIG"/);
30+
assert.match(codexAppDetector, /Library\/Application Support\/Codex\/mcp\.json/);
31+
assert.match(codexAppDetector, /~\/.config\/Codex\/mcp\.json/);
32+
});
33+
34+
test("desktop detector and probe surface permission failures clearly", () => {
35+
assert.match(pathBasedDetector, /\[config_permission_denied\]/);
36+
assert.match(probeImplementation, /OverridePermissionDenied/);
37+
assert.match(probeImplementation, /PermissionDenied/);
38+
});

0 commit comments

Comments
 (0)