Skip to content
Open
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
4 changes: 4 additions & 0 deletions crates/detectord/crates/edison-detectord/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ default = [
"zed",
"jetbrains",
"codex",
"chatgpt",
]
# Each agent can be opted into independently. `vscode` and `cursor` pull in
# rusqlite (for reading `state.vscdb`) + serde_json_lenient (JSONC); `codex`
Expand All @@ -35,6 +36,9 @@ claude_cowork = []
windsurf = []
zed = []
jetbrains = []
# Presence detection only: ChatGPT keeps its MCP servers as server-side
# Connectors, so there is no config to parse and no parser dep to pull in.
chatgpt = []

[dependencies]
dirs = "6.0.0"
Expand Down
19 changes: 19 additions & 0 deletions crates/detectord/crates/edison-detectord/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ pub trait Agent: Send + Sync {
/// produces no servers.
fn is_installed(&self) -> bool;

/// Whether Edison can manage this agent at all: install the `edison-watch`
/// entry, inject hooks, read a config back. False for presence-only agents
/// whose MCP servers live in the vendor's account (ChatGPT's Connectors)
/// rather than in a file on this machine.
///
/// Declared, not inferred from an empty
/// [`edison_installs`](Agent::edison_installs): "no install target right
/// now" and "never has one" are different facts. JetBrains reports no
/// targets when no IDE is installed and is still perfectly manageable the
/// moment one appears.
///
/// An unmanageable agent is dropped from the enrolled selection, so nothing
/// downstream tries to install into it or reports it as unconfigured. It is
/// still discovered and still reported as installed - the app's job is to
/// tell the user it is there and outside Edison's reach.
fn is_manageable(&self) -> bool {
true
}

/// Filesystem locations to watch for this agent's MCP config.
///
/// A driver subscribes to each [`files`](WatchTargets::files) entry's
Expand Down
210 changes: 210 additions & 0 deletions crates/detectord/crates/edison-detectord/src/clients/chatgpt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! ChatGPT desktop app [`Agent`] — presence detection only.
//!
//! ChatGPT's MCP servers are **Connectors**: they are configured in the OpenAI
//! account and run server-side, so unlike every other agent here there is no
//! local config file. Nothing to watch, nothing to discover, and nowhere to
//! install the `edison-watch` entry.
//!
//! It is still worth reporting, because the app uses "is it installed?" to warn
//! the user that their ChatGPT connectors are outside Edison's reach — the same
//! bucket as the Claude hosts' connectors, minus even the local file those have
//! to fall back on. Detection therefore keys off the *app bundle / executable*
//! rather than a config path.

use std::path::PathBuf;

use crate::agent::Agent;
use crate::error::Result;
use crate::types::DiscoveredServer;
use crate::watch::WatchTargets;

const CLIENT_NAME: &str = "chatgpt";

pub struct ChatGpt {
/// Places the app can live; present when any one of them exists.
candidates: Vec<PathBuf>,
}

impl ChatGpt {
pub fn discover() -> Result<Self> {
Ok(Self {
candidates: default_app_paths(),
})
}

/// Construct from explicit candidate paths (tests / non-standard installs).
pub fn from_paths(candidates: Vec<PathBuf>) -> Self {
Self { candidates }
}
}

impl Agent for ChatGpt {
fn name(&self) -> &'static str {
CLIENT_NAME
}

fn is_installed(&self) -> bool {
self.candidates.iter().any(|p| p.exists())
}

fn is_manageable(&self) -> bool {
false
}

fn watch_targets(&self) -> WatchTargets {
// No local config exists, so there is no file whose change could mean
// "a connector was added". Watching the app bundle would only report
// updates to ChatGPT itself.
WatchTargets {
files: Vec::new(),
dirs: Vec::new(),
needs_periodic_rescan: false,
}
}

fn discover(&self) -> Result<Vec<DiscoveredServer>> {
// Connectors live in the OpenAI account; the daemon cannot enumerate
// them and must not imply "ChatGPT has no MCP servers" — the app says
// so explicitly in the wizard's partially-supported section instead.
Ok(Vec::new())
}

// `edison_installs` / `hook_install` stay at their empty defaults: there is
// no local surface to install into, so ChatGPT is never an install target.
}

fn default_app_paths() -> Vec<PathBuf> {
if cfg!(target_os = "macos") {
// Both bundle names OpenAI has shipped the desktop app under. Probing
// for both is cheap; picking wrong is not, because the failure is
// silent - a user with ChatGPT installed just never sees the warning
// and has nothing to report. (The Codex *CLI* is a separate, fully
// supported agent - see `clients/codex.rs`.)
const NAMES: [&str; 2] = ["ChatGPT.app", "ChatGPT Classic.app"];
let mut out: Vec<PathBuf> = NAMES
.iter()
.map(|n| PathBuf::from("/Applications").join(n))
.collect();
if let Some(home) = dirs::home_dir() {
out.extend(NAMES.iter().map(|n| home.join("Applications").join(n)));
}
out
} else if cfg!(target_os = "windows") {
// A Store install registers an app-execution alias under
// `%LOCALAPPDATA%\Microsoft\WindowsApps`; `Programs` covers a direct
// one. The alias is assumed to be named `ChatGPT.exe` (the MSIX
// convention) - unverified against a real Windows install, and the
// one line to change if detection turns out never to fire there.
match dirs::data_local_dir() {
Some(local) => vec![
local
.join("Microsoft")
.join("WindowsApps")
.join("ChatGPT.exe"),
local.join("Programs").join("ChatGPT").join("ChatGPT.exe"),
],
None => Vec::new(),
}
} else {
// No official Linux desktop app — never detected.
Vec::new()
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn installed_when_any_candidate_exists() {
let dir = tempdir().unwrap();
let app = dir.path().join("ChatGPT.app");
let missing = dir.path().join("ChatGPT Classic.app");

assert!(!ChatGpt::from_paths(vec![app.clone(), missing.clone()]).is_installed());
std::fs::create_dir(&app).unwrap();
assert!(ChatGpt::from_paths(vec![app, missing]).is_installed());
}

#[test]
fn discovers_nothing_and_is_not_an_install_target() {
// The null-object contract the whole app-side design rests on: ChatGPT
// is reported as present and nothing else. If any of these ever returns
// something, the app has to stop calling it unmanageable.
let dir = tempdir().unwrap();
let app = dir.path().join("ChatGPT.app");
std::fs::create_dir(&app).unwrap();
let agent = ChatGpt::from_paths(vec![app]);

assert!(agent.is_installed());
assert!(!agent.is_manageable());
assert!(agent.discover().unwrap().is_empty());
assert!(agent.edison_installs(dir.path()).is_empty());
assert!(agent.hook_install(dir.path()).is_none());
assert!(agent.watch_targets().files.is_empty());
}

// `default_app_paths` is the only part of this file with real logic, and
// the only way it can fail is silently: probe the wrong place and ChatGPT
// is simply never detected, which no user reports because all they see is
// the absence of a warning. The platform it runs on is the platform under
// test - these run in CI on all three.

#[test]
#[cfg(target_os = "macos")]
fn macos_probes_both_bundles_in_both_application_dirs() {
let paths = default_app_paths();
let ends_with = |name: &str| {
paths
.iter()
.filter(|p| p.file_name().is_some_and(|f| f == name))
.count()
};
// `/Applications` is unconditional; `~/Applications` needs a home dir,
// which the code treats as optional - so the test does too. Asserting
// more than the code promises fails on the code's own valid states.
assert!(paths.iter().any(|p| p.starts_with("/Applications")));
match dirs::home_dir() {
Some(home) => {
assert_eq!(ends_with("ChatGPT.app"), 2);
assert_eq!(ends_with("ChatGPT Classic.app"), 2);
assert!(
paths
.iter()
.any(|p| p.starts_with(home.join("Applications")))
);
}
None => {
assert_eq!(ends_with("ChatGPT.app"), 1);
assert_eq!(ends_with("ChatGPT Classic.app"), 1);
}
}
}

#[test]
#[cfg(target_os = "windows")]
fn windows_probes_the_store_alias_and_a_direct_install() {
let paths = default_app_paths();
assert!(
paths
.iter()
.any(|p| p.ends_with("Microsoft\\WindowsApps\\ChatGPT.exe"))
);
assert!(
paths
.iter()
.any(|p| p.ends_with("Programs\\ChatGPT\\ChatGPT.exe"))
);
}

#[test]
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn linux_probes_nothing_so_chatgpt_is_never_reported() {
// There is no official Linux desktop app. Detecting one would put an
// unremovable "partially supported" warning in front of a user who
// cannot possibly have it installed.
assert!(default_app_paths().is_empty());
assert!(!ChatGpt::discover().unwrap().is_installed());
}
}
7 changes: 7 additions & 0 deletions crates/detectord/crates/edison-detectord/src/clients/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ mod transport;
#[cfg(any(feature = "vscode", feature = "cursor"))]
mod statedb;

// ChatGPT is presence-detection only - server-side Connectors, no local config
// to parse - so it is deliberately absent from the `common`/`transport` gates
// above.
#[cfg(feature = "chatgpt")]
pub mod chatgpt;
#[cfg(feature = "claude_code")]
pub mod claude_code;
#[cfg(feature = "claude_cowork")]
Expand All @@ -50,6 +55,8 @@ pub mod windsurf;
#[cfg(feature = "zed")]
pub mod zed;

#[cfg(feature = "chatgpt")]
pub use chatgpt::ChatGpt;
#[cfg(feature = "claude_code")]
pub use claude_code::ClaudeCode;
#[cfg(feature = "claude_cowork")]
Expand Down
7 changes: 6 additions & 1 deletion crates/detectord/crates/mcp_detector_daemon/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use std::sync::Arc;

use edison_detectord::Agent;
use edison_detectord::clients::{
ClaudeCode, ClaudeCowork, ClaudeDesktop, Codex, Cursor, JetBrains, VsCode, Windsurf, Zed,
ChatGpt, ClaudeCode, ClaudeCowork, ClaudeDesktop, Codex, Cursor, JetBrains, VsCode, Windsurf,
Zed,
};

/// Discover the locally-available agents. An agent whose `discover()`
Expand All @@ -29,6 +30,10 @@ pub fn build() -> Vec<Arc<dyn Agent>> {
add!(Windsurf::discover(), "windsurf");
add!(Zed::discover(), "zed");
add!(Codex::discover(), "codex");
// Detect-only: reports whether the ChatGPT desktop app is installed so the
// app can warn that its Connectors are outside Edison's reach. It
// contributes no servers and is never an install target.
add!(ChatGpt::discover(), "chatgpt");
add!(JetBrains::intellij(), "intellij");
add!(JetBrains::pycharm(), "pycharm");
add!(JetBrains::webstorm(), "webstorm");
Expand Down
Loading
Loading