diff --git a/crates/detectord/crates/edison-detectord/Cargo.toml b/crates/detectord/crates/edison-detectord/Cargo.toml index c20c555..d91b4d0 100644 --- a/crates/detectord/crates/edison-detectord/Cargo.toml +++ b/crates/detectord/crates/edison-detectord/Cargo.toml @@ -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` @@ -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" diff --git a/crates/detectord/crates/edison-detectord/src/agent.rs b/crates/detectord/crates/edison-detectord/src/agent.rs index 4ef7ee7..7040075 100644 --- a/crates/detectord/crates/edison-detectord/src/agent.rs +++ b/crates/detectord/crates/edison-detectord/src/agent.rs @@ -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 diff --git a/crates/detectord/crates/edison-detectord/src/clients/chatgpt.rs b/crates/detectord/crates/edison-detectord/src/clients/chatgpt.rs new file mode 100644 index 0000000..a8bedfc --- /dev/null +++ b/crates/detectord/crates/edison-detectord/src/clients/chatgpt.rs @@ -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, +} + +impl ChatGpt { + pub fn discover() -> Result { + Ok(Self { + candidates: default_app_paths(), + }) + } + + /// Construct from explicit candidate paths (tests / non-standard installs). + pub fn from_paths(candidates: Vec) -> 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> { + // 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 { + 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 = 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()); + } +} diff --git a/crates/detectord/crates/edison-detectord/src/clients/mod.rs b/crates/detectord/crates/edison-detectord/src/clients/mod.rs index afb2fa0..160e10b 100644 --- a/crates/detectord/crates/edison-detectord/src/clients/mod.rs +++ b/crates/detectord/crates/edison-detectord/src/clients/mod.rs @@ -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")] @@ -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")] diff --git a/crates/detectord/crates/mcp_detector_daemon/src/agents.rs b/crates/detectord/crates/mcp_detector_daemon/src/agents.rs index 407a9fb..6d777df 100644 --- a/crates/detectord/crates/mcp_detector_daemon/src/agents.rs +++ b/crates/detectord/crates/mcp_detector_daemon/src/agents.rs @@ -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()` @@ -29,6 +30,10 @@ pub fn build() -> Vec> { 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"); diff --git a/crates/detectord/crates/mcp_detector_daemon/src/ops.rs b/crates/detectord/crates/mcp_detector_daemon/src/ops.rs index 83f8b6d..cc54c44 100644 --- a/crates/detectord/crates/mcp_detector_daemon/src/ops.rs +++ b/crates/detectord/crates/mcp_detector_daemon/src/ops.rs @@ -2,6 +2,7 @@ //! user. Returning protocol DTOs keeps the two front-ends in sync. use std::path::PathBuf; +use std::sync::LazyLock; use anyhow::Context; use edison_detectord::{DiscoveredServer, EdisonInstall, ServerConfig, fingerprint}; @@ -18,6 +19,38 @@ use crate::platform; use crate::protocol::{AgentInfo, Choice, IntegrationChange, ServerView, Status}; use crate::quarantined::{QuarantinedEntry, QuarantinedState}; +/// Agent names this build cannot manage, computed once. +/// +/// `is_manageable()` is declared per agent type, so unlike the rest of what +/// `agents::build()` reports it cannot change while the process runs - no +/// filesystem state feeds it. Deriving it on every selection filter meant +/// `apply_integrations` alone rebuilt the whole agent set twice more per +/// request, re-running each constructor's discovery and re-emitting any +/// "discover failed" warning with it. +static UNMANAGEABLE: LazyLock> = LazyLock::new(|| { + agents::build() + .iter() + .filter(|a| !a.is_manageable()) + .map(|a| a.name()) + .collect() +}); + +/// Drop agent names Edison cannot manage (ChatGPT and any future host whose +/// MCP servers live in the vendor's account) from a selection list. +/// +/// An unknown name is kept: it is most likely an agent this build doesn't +/// compile in, and silently dropping it would erase a selection that a fuller +/// build understands. +fn retain_manageable(agents: &mut Vec) { + agents.retain(|name| { + let keep = !UNMANAGEABLE.iter().any(|u| u == name); + if !keep { + tracing::debug!(agent = %name, "dropping unmanageable agent from selection"); + } + keep + }); +} + /// Which agents are present on the machine, with their workspace hook coverage. /// /// The hook counts are computed here rather than by the UI: the workspace @@ -60,6 +93,7 @@ pub fn list_agents(user: &str) -> Vec { edison_url: installed_edison_entry(a.name(), &installs, &observed) .and_then(|s| edison_entry_url(&s.config)), config_path: installs.first().map(|i| i.path.display().to_string()), + manageable: a.is_manageable(), } }) .collect() @@ -116,20 +150,29 @@ pub fn apply_integrations( user: &str, agents_to_add: &[String], ) -> anyhow::Result> { + // Same guard as `enroll`: an unmanageable agent has nothing to install into, + // and the selection is additive, so letting one in means carrying it for + // good. Dropped up front so it reaches neither the selection nor the + // installer. The app selects every detected app by default, so this is the + // ordinary path, not an edge case. + let mut wanted = agents_to_add.to_vec(); + retain_manageable(&mut wanted); + let mut e = Enrollment::load_for(user)?.ok_or_else(|| anyhow::anyhow!("not enrolled"))?; - for a in agents_to_add { + for a in &wanted { if !e.selected_agents.contains(a) { e.selected_agents.push(a.clone()); } } + retain_manageable(&mut e.selected_agents); e.save_for(user)?; let home = user_home(user); - let changes = install_edison_entries_for(user, &e, &home, agents_to_add); + let changes = install_edison_entries_for(user, &e, &home, &wanted); purge_shadowing_edison_entries(user); // Hooks only for what was asked for. The machine-wide sweep is enroll's job // (`apply_install`), which runs on every app start. - apply_hooks_for(&home, Some(agents_to_add)); + apply_hooks_for(&home, Some(&wanted)); Ok(changes) } @@ -385,7 +428,7 @@ pub async fn enroll( .as_ref() .map(|e| e.selected_agents.clone()) .unwrap_or_default(); - let new_agents = match selected_agents { + let mut new_agents = match selected_agents { Some(provided) => { let mut set = old_agents.clone(); for a in provided { @@ -397,6 +440,12 @@ pub async fn enroll( } None => old_agents, }; + // Selecting an unmanageable agent is meaningless - there is nothing to + // install into - and it does not stay harmless: the selection is additive + // and only `unenroll` removes from it, so one such name would sit in every + // later self-heal pass forever. Filtering the whole union (not just what + // was provided) also prunes any that a previous version let through. + retain_manageable(&mut new_agents); let mcp_base_url = mcp_base_url.or_else(|| existing.as_ref().and_then(|e| e.mcp_base_url.clone())); let edison_secret_key = @@ -703,6 +752,12 @@ pub fn heal_edison_install(user: &str, e: &Enrollment) -> usize { if present.contains(agent.name()) { continue; // already installed — don't rewrite (avoids fs-watch churn) } + // Count and report only what was actually written. An agent with no + // install targets (JetBrains with no IDE on the machine) reaches here + // and writes nothing; logging it as healed anyway made the self-heal + // signal permanently non-zero, so a real heal - somebody's config got + // clobbered - was indistinguishable from the every-20s background hum. + let mut wrote = false; for inst in agent.edison_installs(&home) { let done_via_cli = inst.prefer_cli && { let url = mcp_quarantine::edison_url(mcp_base, &e.api_key, &inst.client_id); @@ -711,6 +766,10 @@ pub fn heal_edison_install(user: &str, e: &Enrollment) -> usize { if !done_via_cli { let _ = mcp_quarantine::install_edison(&inst, mcp_base, &e.api_key, secret); } + wrote = true; + } + if !wrote { + continue; } tracing::info!( agent = agent.name(), @@ -1035,6 +1094,33 @@ fn conflict_detail(err: &BackendError, name: &str) -> String { mod tests { use super::*; + #[test] + fn selection_drops_unmanageable_agents_but_keeps_everything_else() { + // The app sends its saved app selection on every start (enroll) as well + // as on apply, and the selection is additive - only `unenroll` empties + // it. So one unmanageable name getting in is permanent, and it used to + // make every self-heal pass report a heal that never happened. + let mut agents = vec![ + "claude_code".to_string(), + "chatgpt".to_string(), + "cursor".to_string(), + ]; + retain_manageable(&mut agents); + assert_eq!( + agents, + vec!["claude_code".to_string(), "cursor".to_string()] + ); + } + + #[test] + fn selection_keeps_names_this_build_does_not_know() { + // An agent compiled out of this build is not the same as one we refuse + // to manage; dropping it would erase a selection a fuller build honours. + let mut agents = vec!["some_future_agent".to_string()]; + retain_manageable(&mut agents); + assert_eq!(agents, vec!["some_future_agent".to_string()]); + } + fn quarantine_record(path: &str) -> QuarantineRecord { QuarantineRecord { kind: SourceKind::Json, diff --git a/crates/detectord/crates/mcp_detector_daemon/src/protocol.rs b/crates/detectord/crates/mcp_detector_daemon/src/protocol.rs index eee053e..515c99c 100644 --- a/crates/detectord/crates/mcp_detector_daemon/src/protocol.rs +++ b/crates/detectord/crates/mcp_detector_daemon/src/protocol.rs @@ -219,6 +219,15 @@ pub struct AgentInfo { pub workspace_hooks_total: u32, #[serde(default)] pub workspace_hooks_installed: u32, + /// Whether Edison can manage this agent at all, or only report that it is + /// there. False for hosts whose MCP servers are Connectors in the vendor's + /// account (ChatGPT), where there is no local config to read or write. + /// + /// Defaults to `true` — what every agent predating this field is. An older + /// daemon omitting it must not make the app treat real clients as + /// unmanageable and quietly stop reporting their setup status. + #[serde(default = "default_true")] + pub manageable: bool, } /// One discovered server instance (not deduped — carries its source path). diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 25dab63..794ea73 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -53,6 +53,8 @@ Modern AI tools (Claude, Cursor, VS Code, and friends) connect to MCP servers th Claude Code · Claude Desktop · Claude Cowork · Cursor · VS Code · Windsurf · Zed · JetBrains IDEs · Codex +The ChatGPT desktop app is detected but not managed: its MCP servers are Connectors hosted in your OpenAI account rather than in a local config file, so the setup wizard flags them for you to remove instead of claiming to protect them. + ## Getting Started 1. **Install** the app - see [Installation](#installation) (or [build from source](#build-from-source) until signed installers ship). diff --git a/packages/desktop/src/main/__tests__/hookStatus.test.ts b/packages/desktop/src/main/__tests__/hookStatus.test.ts index 90ac4f0..0b947e1 100644 --- a/packages/desktop/src/main/__tests__/hookStatus.test.ts +++ b/packages/desktop/src/main/__tests__/hookStatus.test.ts @@ -31,6 +31,7 @@ function agent(over: Partial = {}): AgentFacts { workspaceHooksTotal: 0, edisonUrl: EXPECTED_URL, configPath: '/home/u/.claude.json', + manageable: true, ...over } } diff --git a/packages/desktop/src/main/__tests__/unmanageableClients.test.ts b/packages/desktop/src/main/__tests__/unmanageableClients.test.ts new file mode 100644 index 0000000..8d0a0f6 --- /dev/null +++ b/packages/desktop/src/main/__tests__/unmanageableClients.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +import type { AgentFacts } from '../detectord/agents' +import type { McpClientId } from '../discovery/types' + +/** + * ChatGPT is presence-only: its MCP servers are Connectors in the user's OpenAI + * account, so there is nothing local to read, write, hook, or proxy. Two things + * have to hold, and both have been wrong at some point: + * + * - it never enters the enrolled selection (every path, not just the obvious + * one), because the selection is additive and only `unenroll` empties it; + * - it is still REPORTED, with a status of its own. Dropping it from the + * client list is how a user ends up assuming an unprotected app is covered. + */ + +let facts: Map | null = new Map() +/** `list_agents` is a full discovery pass, so who asks for it matters. */ +let factsCalls = 0 + +vi.mock('../detectord/agents', async (importOriginal) => ({ + ...(await importOriginal()), + getAgentFacts: () => { + factsCalls += 1 + return Promise.resolve(facts) + } +})) + +// hookStatus reaches DetectordUnavailableError through mcpDiscovery, whose +// import graph ends up in electron. `ipcMain` is a registry the readConfig +// tests below reach into to get at the handler. +const handlers = new Map unknown>() +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp' }, + BrowserWindow: { getAllWindows: () => [] }, + ipcMain: { handle: (channel: string, fn: never) => handlers.set(channel, fn) } +})) + +let readConfigResult: () => Promise<{ content: string | null }> = async () => ({ content: '{}' }) +let listAgentsResult: Record[] = [] +vi.mock('../detectord/lifecycle', () => ({ + getDetectordClient: () => ({ + connect: async () => {}, + readConfig: () => readConfigResult(), + listAgents: async () => listAgentsResult + }) +})) + +import { getHookStatus } from '../runtime/hookStatus' + +const EXPECTED_URL = 'https://mcp.edison.watch/mcp' + +function agent(over: Partial = {}): AgentFacts { + return { + installed: true, + hooksInstalled: 0, + hooksTotal: 0, + workspaceHooksInstalled: 0, + workspaceHooksTotal: 0, + edisonUrl: null, + configPath: null, + manageable: true, + ...over + } +} + +describe('unmanageable clients', () => { + beforeEach(() => { + facts = new Map([['chatgpt' as McpClientId, agent({ manageable: false })]]) + }) + + it('reports ChatGPT rather than hiding it', async () => { + const all = await getHookStatus(EXPECTED_URL, true) + const entry = all.find((s) => s.client === 'chatgpt') + expect(entry).toBeDefined() + expect(entry?.installed).toBe(true) + }) + + it('marks it unmanageable and scores it against no setup conditions', async () => { + // Both would otherwise render as a lie: an unmet MCP condition reads + // "gateway not configured" (unfixable), and a met one paints it green. + const entry = (await getHookStatus(EXPECTED_URL, true)).find((s) => s.client === 'chatgpt') + expect(entry?.manageable).toBe(false) + expect(entry?.mcpApplicable).toBe(false) + expect(entry?.hooksApplicable).toBe(false) + }) + + it('leaves manageable clients scored as before', async () => { + facts = new Map([ + ['cursor' as McpClientId, agent({ edisonUrl: EXPECTED_URL, hooksTotal: 4, hooksInstalled: 4 })] + ]) + const entry = (await getHookStatus(EXPECTED_URL, true)).find((s) => s.client === 'cursor') + expect(entry?.manageable).toBe(true) + expect(entry?.mcpApplicable).toBe(true) + expect(entry?.mcpConnected).toBe(true) + }) + + it('treats a daemon that never heard of the field as fully manageable', async () => { + // An older daemon omits `manageable`; defaulting it to false would silently + // drop every real client out of setup reporting. + const { UNKNOWN_AGENT_FACTS } = await import('../detectord/agents') + expect(UNKNOWN_AGENT_FACTS.manageable).toBe(true) + }) +}) + +/** + * The wire-to-app hop, which the tests above mock past. `manageable` crosses + * here as snake-case JSON from a daemon that may predate the field, and nothing + * upstream of this point is typechecked - the payload is parsed, not compiled. + */ +describe('AgentInfo -> AgentFacts', () => { + // The real module; the rest of this file replaces getAgentFacts with a stub. + const real = async () => await vi.importActual('../detectord/agents') + + it('carries an unmanageable agent through as unmanageable', async () => { + listAgentsResult = [{ name: 'chatgpt', installed: true, manageable: false }] + const facts = await (await real()).getAgentFacts() + expect(facts?.get('chatgpt' as McpClientId)?.manageable).toBe(false) + }) + + it('reads an omitted field as manageable, not as false', async () => { + // The older-daemon case. Defaulting to false here would strip every client + // of its setup conditions the moment the app outran the daemon. + listAgentsResult = [{ name: 'cursor', installed: true }] + const facts = await (await real()).getAgentFacts() + expect(facts?.get('cursor' as McpClientId)?.manageable).toBe(true) + }) +}) + +describe('mcp:readConfig', () => { + let readConfig: (event: unknown, client: string) => Promise<{ content: string | null; error?: string }> + + beforeEach(async () => { + factsCalls = 0 + facts = new Map([['chatgpt' as McpClientId, agent({ manageable: false })]]) + const { registerMcpSubmitHandlers } = await import('../ipc/ipcHandlersMcpSubmit') + registerMcpSubmitHandlers() + readConfig = handlers.get('mcp:readConfig') as typeof readConfig + }) + + it('explains Connectors instead of surfacing an error nobody can act on', async () => { + readConfigResult = async () => { + throw new Error("agent 'chatgpt' has no user-scope config") + } + const { content, error } = await readConfig(null, 'chatgpt') + expect(content).toMatch(/Connectors in your account/) + expect(error).toBeUndefined() + }) + + it('costs no extra discovery pass when the read succeeds', async () => { + // The check used to run first, so every successful read paid for a + // `list_agents` - and AppsStep re-reads every expanded client on refresh, + // turning one wasted scan into one per open panel. + readConfigResult = async () => ({ content: '{"mcpServers":{}}' }) + const { content } = await readConfig(null, 'cursor') + expect(content).toBe('{"mcpServers":{}}') + expect(factsCalls).toBe(0) + }) + + it('still passes a real failure through for a manageable client', async () => { + readConfigResult = async () => { + throw new Error('permission denied') + } + const { content, error } = await readConfig(null, 'cursor') + expect(content).toBeNull() + expect(error).toBe('permission denied') + }) +}) diff --git a/packages/desktop/src/main/clients/displayMeta.ts b/packages/desktop/src/main/clients/displayMeta.ts index e79faf6..5368723 100644 --- a/packages/desktop/src/main/clients/displayMeta.ts +++ b/packages/desktop/src/main/clients/displayMeta.ts @@ -1,7 +1,8 @@ /** * Display metadata (name + brand color) for every supported client. * - * Mirrors the entries in `@edison-watch/shared/agent-registry`. Duplicated here so + * `name` and `brandColor` mirror `@edison-watch/shared/agent-registry`; `configLabel` + * is app-local copy with no counterpart there. Duplicated here so * main-process code can build ClientIntegration objects without dragging the * shared package into test module graphs (vitest can't resolve subpath * exports of an unbuilt package). Keep in sync with the shared registry. @@ -11,6 +12,16 @@ import type { McpClientId } from '../discovery/types' export interface ClientDisplay { name: string brandColor: string + /** + * What to show where a config path would go, for clients that have none. + * The daemon reports a null path for them (there is no file), and a blank + * line under the app name reads as "we couldn't find it" rather than "there + * is nothing to find". + * + * Display copy only - whether Edison can manage a client is the daemon's + * `manageable`, never the presence of this string. + */ + configLabel?: string } export const CLIENT_DISPLAY: Record = { @@ -25,4 +36,9 @@ export const CLIENT_DISPLAY: Record = { intellij: { name: 'IntelliJ IDEA', brandColor: '#000000' }, pycharm: { name: 'PyCharm', brandColor: '#21D789' }, webstorm: { name: 'WebStorm', brandColor: '#07C3F2' }, + chatgpt: { + name: 'ChatGPT', + brandColor: '#000000', + configLabel: 'Connectors · managed server-side in your ChatGPT account', + }, } diff --git a/packages/desktop/src/main/detectord/agents.ts b/packages/desktop/src/main/detectord/agents.ts index a2b8c37..105f4b4 100644 --- a/packages/desktop/src/main/detectord/agents.ts +++ b/packages/desktop/src/main/detectord/agents.ts @@ -29,6 +29,12 @@ export interface AgentFacts { edisonUrl: string | null /** The agent's user-scope config file, when it has one. */ configPath: string | null + /** + * Whether Edison can manage this agent, or only report that it's installed. + * False for connector-only hosts (ChatGPT), whose MCP servers live in the + * vendor's account rather than in a file on this machine. + */ + manageable: boolean } const UNKNOWN: AgentFacts = { @@ -38,7 +44,8 @@ const UNKNOWN: AgentFacts = { workspaceHooksInstalled: 0, workspaceHooksTotal: 0, edisonUrl: null, - configPath: null + configPath: null, + manageable: true } function toFacts(a: AgentInfo): AgentFacts { @@ -49,7 +56,9 @@ function toFacts(a: AgentInfo): AgentFacts { workspaceHooksInstalled: a.workspace_hooks_installed ?? 0, workspaceHooksTotal: a.workspace_hooks_total ?? 0, edisonUrl: a.edison_url ?? null, - configPath: a.config_path ?? null + configPath: a.config_path ?? null, + // Absent means an older daemon, where every agent was manageable. + manageable: a.manageable ?? true } } diff --git a/packages/desktop/src/main/detectord/integrations.ts b/packages/desktop/src/main/detectord/integrations.ts index c3d96e6..097b4e3 100644 --- a/packages/desktop/src/main/detectord/integrations.ts +++ b/packages/desktop/src/main/detectord/integrations.ts @@ -13,7 +13,13 @@ import type { IntegrationChange } from './protocol' export type { IntegrationChange } -/** Install the edison-watch entry + hooks for these client ids. */ +/** + * Install the edison-watch entry + hooks for these client ids. + * + * Unmanageable clients are not filtered here. The daemon drops them, because + * it is not the only caller: enroll sends the saved app selection on every + * start, and a guard in front of one caller left the other wide open. + */ export async function applyIntegrations(clients: string[]): Promise { return withDetectordHealth('apply_integrations', async () => { const daemon = getDetectordClient() diff --git a/packages/desktop/src/main/detectord/protocol.ts b/packages/desktop/src/main/detectord/protocol.ts index df71b14..d77a519 100644 --- a/packages/desktop/src/main/detectord/protocol.ts +++ b/packages/desktop/src/main/detectord/protocol.ts @@ -81,6 +81,16 @@ export interface AgentInfo { */ workspace_hooks_total?: number workspace_hooks_installed?: number + /** + * Whether Edison can manage this agent at all, or only report that it's + * there. False for hosts whose MCP servers are Connectors in the vendor's + * account (ChatGPT) - nothing local to read, write, hook, or proxy. + * + * Absent from daemons predating the field, and absence must read as `true`: + * every agent that existed before it is manageable, and defaulting the other + * way would silently drop real clients out of setup status. + */ + manageable?: boolean } /** One discovered server instance. `state`: edison | known | new | opaque | report. */ diff --git a/packages/desktop/src/main/discovery/types.ts b/packages/desktop/src/main/discovery/types.ts index 5dbf992..7d8d0e0 100644 --- a/packages/desktop/src/main/discovery/types.ts +++ b/packages/desktop/src/main/discovery/types.ts @@ -14,6 +14,10 @@ export type McpClientId = | 'intellij' | 'pycharm' | 'webstorm' + // Detect-only: its MCP servers are server-side Connectors, so it never + // appears as the `client` of a discovered server - only in the installed-app + // list, where the wizard flags it as partially supported. + | 'chatgpt' export type McpServerTransport = 'stdio' | 'http' | 'sse' diff --git a/packages/desktop/src/main/ipc/ipcHandlers.ts b/packages/desktop/src/main/ipc/ipcHandlers.ts index c574989..7315dab 100644 --- a/packages/desktop/src/main/ipc/ipcHandlers.ts +++ b/packages/desktop/src/main/ipc/ipcHandlers.ts @@ -470,13 +470,18 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { // Distinguish "no agents installed" from "nobody answered": the renderer // shows the daemon warning for the latter instead of an empty app list. if (!facts) return { clients: [], daemonUnavailable: true } - const clients: Array<{ id: string; name: string; configPath: string }> = [] + const clients: Array<{ id: string; name: string; configPath: string; manageable: boolean }> = [] for (const [id, f] of facts) { if (!f.installed) continue clients.push({ id, name: CLIENT_DISPLAY[id]?.name ?? id, - configPath: f.configPath ?? '' + // A client with no local config gets an advisory label in place of the + // path, saying where its MCP config actually lives. + configPath: f.configPath ?? CLIENT_DISPLAY[id]?.configLabel ?? '', + // Drives whether the wizard offers a checkbox at all: selecting a + // client Edison can't configure does nothing. + manageable: f.manageable }) } return { clients, daemonUnavailable: false } diff --git a/packages/desktop/src/main/ipc/ipcHandlersMcpSubmit.ts b/packages/desktop/src/main/ipc/ipcHandlersMcpSubmit.ts index e2f0e5f..3732552 100644 --- a/packages/desktop/src/main/ipc/ipcHandlersMcpSubmit.ts +++ b/packages/desktop/src/main/ipc/ipcHandlersMcpSubmit.ts @@ -14,8 +14,9 @@ import { submitOneViaDetectord, } from "../detectord/submit"; import { getDetectordClient } from "../detectord/lifecycle"; -import { toAgentName } from "../detectord/agents"; +import { toAgentName, getAgentFacts } from "../detectord/agents"; import { applyIntegrations, revertIntegrations, integrationErrors } from "../detectord/integrations"; +import { CLIENT_DISPLAY } from "../clients/displayMeta"; import { detectSecrets } from "../discovery/secretDetection"; import type { TemplatizedConfig } from "../discovery/secretDetection"; import { filterOutEdisonWatchServers } from "../runtime/mcpConfigMonitor"; @@ -145,6 +146,24 @@ export function registerMcpSubmitHandlers(): void { // as null content and anything else (permissions, a directory, non-UTF-8) // as an error, so pass the reason on rather than rendering "no config". const message = err instanceof Error ? err.message : String(err); + + // An unmanageable client has no local config, so the failure above is + // expected and its message is one the user can do nothing about. Say + // what's actually going on instead. The daemon is the authority on which + // clients those are, so this asks it - but only here, on a path that has + // already failed. Asking up front cost a `list_agents` (a full discovery + // pass, plus a workspace hook scan) on every successful read, and the + // refresh in AppsStep re-reads every expanded client at once. + const facts = await getAgentFacts(); + if (facts?.get(client as McpClientId)?.manageable === false) { + const name = CLIENT_DISPLAY[client as McpClientId]?.name ?? client; + return { + content: + `${name} keeps its MCP servers as Connectors in your account, not in a ` + + `local config file. There is nothing here for Edison Watch to read or protect.`, + }; + } + console.warn(`[mcp:readConfig] ${client}: ${message}`); return { content: null, error: message }; } diff --git a/packages/desktop/src/main/runtime/hookStatus.ts b/packages/desktop/src/main/runtime/hookStatus.ts index c6ae46c..9b70af1 100644 --- a/packages/desktop/src/main/runtime/hookStatus.ts +++ b/packages/desktop/src/main/runtime/hookStatus.ts @@ -24,6 +24,13 @@ export interface HookStatusEntry { mcpConfigured: boolean mcpApplicable: boolean hooksApplicable: boolean + /** + * False when Edison can only see this client, not configure it (ChatGPT's + * server-side Connectors). Reported rather than filtered out: the wizard is + * seen once, this list is the permanent surface, and a client silently + * missing from it is how a user ends up assuming they're covered. + */ + manageable: boolean mcpRuntimeStatus?: ClaudeCodeMcpStatus } @@ -75,7 +82,8 @@ export async function getHookStatus( mcpConnected: false, mcpConfigured: false, mcpApplicable: true, - hooksApplicable: false + hooksApplicable: false, + manageable: true } } @@ -106,8 +114,12 @@ export async function getHookStatus( totalHooks, mcpConnected, mcpConfigured, - mcpApplicable: true, - hooksApplicable: totalHooks > 0, + // An unmanageable client has no gateway entry and no hook surface, so + // neither condition applies to it. The UI renders it as its own state + // rather than scoring it against conditions it can never meet. + mcpApplicable: f.manageable, + hooksApplicable: f.manageable && totalHooks > 0, + manageable: f.manageable, ...(mcpRuntimeStatus !== undefined ? { mcpRuntimeStatus } : {}) } }) diff --git a/packages/desktop/src/preload/index.d.ts b/packages/desktop/src/preload/index.d.ts index 9af373e..94acd86 100644 --- a/packages/desktop/src/preload/index.d.ts +++ b/packages/desktop/src/preload/index.d.ts @@ -34,7 +34,7 @@ interface EdisonAPI { } mcp: { detectClients: () => Promise<{ - clients: Array<{ id: string; name: string; configPath: string }> + clients: Array<{ id: string; name: string; configPath: string; manageable: boolean }> daemonUnavailable: boolean }> discover: () => Promise<{ diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index f31f5ea..f3b0e34 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -66,7 +66,7 @@ const api = { /** MCP client discovery and hook management */ mcp: { detectClients: (): Promise<{ - clients: Array<{ id: string; name: string; configPath: string }> + clients: Array<{ id: string; name: string; configPath: string; manageable: boolean }> daemonUnavailable: boolean }> => ipcRenderer.invoke('mcp:detectClients'), diff --git a/packages/desktop/src/renderer/src/__tests__/renderSmoke.test.tsx b/packages/desktop/src/renderer/src/__tests__/renderSmoke.test.tsx index 1b6d04c..020351a 100644 --- a/packages/desktop/src/renderer/src/__tests__/renderSmoke.test.tsx +++ b/packages/desktop/src/renderer/src/__tests__/renderSmoke.test.tsx @@ -16,6 +16,7 @@ vi.mock('../components/onboarding/PersonalKeyCard', () => ({ })) import AppsStep from '../components/onboarding/AppsStep' import MainMenu from '../components/main/MainMenu' +import ClientsView from '../components/main/ClientsView' import EncryptionStep from '../components/onboarding/EncryptionStep' /** @@ -52,7 +53,9 @@ describe('AppsStep', () => { it('renders the clients the daemon reports', async () => { const api = installMockApi() ;(api.mcp as Record).detectClients = async () => ({ - clients: [{ id: 'cursor', name: 'Cursor', configPath: '/home/u/.cursor/mcp.json' }], + clients: [ + { id: 'cursor', name: 'Cursor', configPath: '/home/u/.cursor/mcp.json', manageable: true } + ], daemonUnavailable: false }) @@ -62,6 +65,31 @@ describe('AppsStep', () => { expectNoRenderErrors() }) + it('offers no selection for a client it cannot configure', async () => { + // A checked checkbox whose value is thrown away downstream told the user + // ChatGPT was about to be configured, and inflated the "Configure N Apps" + // count on the next step by one. + const api = installMockApi() + ;(api.mcp as Record).detectClients = async () => ({ + clients: [ + { id: 'cursor', name: 'Cursor', configPath: '/home/u/.cursor/mcp.json', manageable: true }, + { id: 'chatgpt', name: 'ChatGPT', configPath: 'Connectors', manageable: false } + ], + daemonUnavailable: false + }) + + render( {}} />) + + // Shown, and shown as unprotected - not quietly dropped from the list. + expect(await screen.findByText('ChatGPT')).toBeTruthy() + expect(screen.getByText(/not protected/i)).toBeTruthy() + // Only Cursor is selectable, so only Cursor is counted. + await waitFor(() => { + expect(screen.getByText(/Continue with 1 App$/)).toBeTruthy() + }) + expectNoRenderErrors() + }) + it('says the daemon is unreachable instead of "no clients" during an outage', async () => { const api = installMockApi() const mcp = api.mcp as Record @@ -124,6 +152,45 @@ describe('MainMenu', () => { }) }) +describe('ClientsView', () => { + const status = (over: Record) => ({ + installed: true, + hasHook: true, + hookCount: 4, + totalHooks: 4, + mcpConnected: true, + mcpConfigured: true, + mcpApplicable: true, + hooksApplicable: true, + manageable: true, + ...over + }) + + it('explains an unmanageable client it knows, and one it does not', async () => { + // `manageable` is a capability the daemon can set on any client, so the + // copy cannot assume ChatGPT's reason. An unrecognised id has to fall back + // to what the flag alone guarantees rather than borrowing that wording. + // + // `constructor` is the unrecognised id on purpose: the ids are chosen by + // the daemon, and an object lookup answers inherited keys as if they were + // entries, so it would take a function where the fallback belongs. + const api = installMockApi() + ;(api.mcp as Record).getHookStatus = async () => ({ + statuses: [ + status({ client: 'chatgpt', manageable: false, mcpApplicable: false, hooksApplicable: false }), + status({ client: 'constructor', manageable: false, mcpApplicable: false, hooksApplicable: false }) + ], + daemonUnavailable: false + }) + + render() + + expect(await screen.findByText(/Connectors are managed in your account/)).toBeTruthy() + expect(screen.getByText(/^Edison Watch can't configure this app$/)).toBeTruthy() + expectNoRenderErrors() + }) +}) + describe('EncryptionStep', () => { const props = { mcpBaseUrl: 'https://mcp.example', diff --git a/packages/desktop/src/renderer/src/components/main/ClientsView.stories.tsx b/packages/desktop/src/renderer/src/components/main/ClientsView.stories.tsx new file mode 100644 index 0000000..fbafdca --- /dev/null +++ b/packages/desktop/src/renderer/src/components/main/ClientsView.stories.tsx @@ -0,0 +1,90 @@ +import { useLayoutEffect } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import ClientsView from './ClientsView'; + +const meta: Meta = { + title: 'Client2/ClientsView', + component: ClientsView, + parameters: { layout: 'centered' }, +}; + +export default meta; +type Story = StoryObj; + +/** A status row as the daemon reports it, with the manageable defaults. */ +const status = (over: Record) => ({ + installed: true, + hasHook: true, + hookCount: 4, + totalHooks: 4, + mcpConnected: true, + mcpConfigured: true, + mcpApplicable: true, + hooksApplicable: true, + manageable: true, + ...over, +}); + +/** + * The permanent client surface, including a host Edison can only see. + * + * ChatGPT keeps its MCP servers as Connectors in the user's account, so it is + * reported as `manageable: false` and lands in its own "Not Protected" state - + * neither scored against setup conditions it can never meet, nor dropped from + * the list, which would leave an unprotected app invisible after onboarding. + */ +export const WithAnUnmanageableClient: Story = { + decorators: [ + (Story) => { + // Storybook keeps every story in a file on one page, so a stub assigned + // here outlives the story that set it - the next story added to this file + // would silently inherit these four clients. + // + // Swap and restore both happen in the effect, so they stay symmetric: + // mutating a global during render is not safe to repeat, and React does + // repeat renders (StrictMode double-invokes, concurrent renders can be + // thrown away). A second pass would capture this stub as the "previous" + // value and restore it on unmount. `useLayoutEffect` rather than + // `useEffect` because ClientsView fetches in a passive effect, and every + // layout effect runs before any passive one - so the stub is in place + // before the component asks. + useLayoutEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mcp = (window as any).api.mcp; + const previous = mcp.getHookStatus; + mcp.getHookStatus = async () => ({ + statuses: [ + status({ client: 'claude-code' }), + status({ client: 'cursor' }), + status({ + client: 'vscode', + hasHook: false, + hookCount: 2, + mcpConnected: false, + }), + status({ + client: 'chatgpt', + manageable: false, + mcpApplicable: false, + hooksApplicable: false, + hasHook: false, + hookCount: 0, + totalHooks: 0, + mcpConnected: false, + mcpConfigured: false, + }), + ], + daemonUnavailable: false, + }); + return () => { + mcp.getHookStatus = previous; + }; + }, []); + return ( +
+ +
+ ); + }, + ], +}; diff --git a/packages/desktop/src/renderer/src/components/main/ClientsView.tsx b/packages/desktop/src/renderer/src/components/main/ClientsView.tsx index d41f5e5..4d0cefb 100644 --- a/packages/desktop/src/renderer/src/components/main/ClientsView.tsx +++ b/packages/desktop/src/renderer/src/components/main/ClientsView.tsx @@ -16,6 +16,8 @@ interface HookStatus { mcpConfigured: boolean; mcpApplicable: boolean; hooksApplicable: boolean; + /** False when Edison can only see this client, not configure it. */ + manageable: boolean; mcpRuntimeStatus?: ClaudeCodeMcpStatus; } @@ -30,28 +32,83 @@ interface ClientInfo { mcpConfigured: boolean; mcpApplicable: boolean; hooksApplicable: boolean; + /** False when Edison can only see this client, not configure it. */ + manageable: boolean; mcpRuntimeStatus?: ClaudeCodeMcpStatus; } -// Map client IDs (from McpClientId) to display names -const CLIENT_NAMES: Record = { - "claude-code": "Claude Code", - cursor: "Cursor", - windsurf: "Windsurf (early alpha)", - codex: "Codex", - vscode: "VS Code", - zed: "Zed (early alpha)", - intellij: "IntelliJ IDEA (early alpha)", - pycharm: "PyCharm", - webstorm: "WebStorm", +// Map client IDs (from McpClientId) to display names. Every id the daemon can +// report needs an entry - a missing one renders as the raw id. A Map for the +// same reason as UNMANAGEABLE_REASON below: the key comes from the daemon, and +// an object lookup would answer `constructor` with a function to render. +const CLIENT_NAMES = new Map([ + ["claude-code", "Claude Code"], + ["claude-desktop", "Claude Desktop"], + ["claude-cowork", "Claude Cowork"], + ["chatgpt", "ChatGPT"], + ["cursor", "Cursor"], + ["windsurf", "Windsurf (early alpha)"], + ["codex", "Codex"], + ["vscode", "VS Code"], + ["zed", "Zed (early alpha)"], + ["intellij", "IntelliJ IDEA (early alpha)"], + ["pycharm", "PyCharm"], + ["webstorm", "WebStorm"], +]); + +/** + * `unmanaged` is installed-and-outside-our-reach: a client whose MCP servers + * are Connectors in the vendor's account, so there is nothing for Edison to + * configure. It exists because the alternatives both lie - scoring such a + * client against setup conditions reports "gateway not configured" (blaming + * the user for something they cannot fix), and dropping it from the list tells + * them nothing at all about an app that is running unprotected. + */ +type ClientStatus = "connected" | "partial-setup" | "installed" | "unmanaged" | "missing"; + +/** + * Why a given client can't be managed, and what to do about it. + * + * `manageable` is a capability, not an identity: the daemon can mark any client + * unmanageable, and each will be unmanageable for its own reason. Wording that + * assumes ChatGPT's reason would quietly become wrong for the next one, so the + * specific advice is keyed by client and the fallback claims only what the flag + * itself guarantees. + */ +interface UnmanageableReason { + row: string; + tooltip: string; +} + +// A Map, not an object literal: the key is an id the daemon chose, and an +// object lookup answers inherited keys as though they were entries - a client +// named `constructor` would take a function where the fallback belongs. +const UNMANAGEABLE_REASON = new Map([ + [ + "chatgpt", + { + row: "Connectors are managed in your account - Edison Watch can't proxy them", + tooltip: + "This app's MCP servers are Connectors held in your account, not local " + + "config Edison Watch can proxy. Remove them and request equivalents from " + + "your admin.", + }, + ], +]); + +const FALLBACK_REASON: UnmanageableReason = { + row: "Edison Watch can't configure this app", + tooltip: "Edison Watch can see this app but cannot configure or protect it.", }; -type ClientStatus = "connected" | "partial-setup" | "installed" | "missing"; +const unmanageableReason = (id: string): UnmanageableReason => + UNMANAGEABLE_REASON.get(id) ?? FALLBACK_REASON; function StatusDot({ status }: { status: ClientStatus }) { const colors: Record = { connected: "bg-emerald-400", "partial-setup": "bg-amber-400", + unmanaged: "bg-amber-400", installed: "bg-red-400", missing: "bg-gray-500", }; @@ -69,6 +126,9 @@ function StatusDot({ status }: { status: ClientStatus }) { function getClientStatus(client: ClientInfo): ClientStatus { if (!client.installed) return "missing"; + // Before the setup conditions, because none of them apply: there is no + // gateway entry to install and no hook surface to inject. + if (!client.manageable) return "unmanaged"; const needsMcp = client.mcpApplicable; const needsHooks = client.hooksApplicable; const hooksSatisfied = !needsHooks || client.hasHook; @@ -109,6 +169,21 @@ function getIssueDetail(client: ClientInfo): string { /** Tooltip showing connection condition checklist on hover. */ function ConditionTooltip({ client }: { client: ClientInfo }) { + if (!client.manageable) { + return ( +
+
+

+ Not protected +

+

+ {unmanageableReason(client.id).tooltip} +

+
+
+ ); + } + const conditions = [ { label: "Installed", met: client.installed }, ...(client.hooksApplicable @@ -167,7 +242,7 @@ export default function ClientsView(): React.ReactNode { setClients( statuses.map((s) => ({ id: s.client, - name: CLIENT_NAMES[s.client] ?? s.client, + name: CLIENT_NAMES.get(s.client) ?? s.client, installed: s.installed, hasHook: s.hasHook, hookCount: s.hookCount ?? 0, @@ -176,6 +251,7 @@ export default function ClientsView(): React.ReactNode { mcpConfigured: s.mcpConfigured ?? false, mcpApplicable: s.mcpApplicable ?? true, hooksApplicable: s.hooksApplicable ?? true, + manageable: s.manageable ?? true, mcpRuntimeStatus: s.mcpRuntimeStatus, })), ); @@ -233,6 +309,7 @@ export default function ClientsView(): React.ReactNode { const connected = clients.filter((c) => getClientStatus(c) === "connected"); const partialSetup = clients.filter((c) => getClientStatus(c) === "partial-setup"); const noSetup = clients.filter((c) => getClientStatus(c) === "installed"); + const unmanaged = clients.filter((c) => getClientStatus(c) === "unmanaged"); const notInstalled = clients.filter((c) => getClientStatus(c) === "missing"); return ( @@ -270,6 +347,9 @@ export default function ClientsView(): React.ReactNode { { status: "installed" as ClientStatus, items: noSetup, label: "not set up", bg: "bg-red-500/10", text: "text-red-400", activeBorder: "border-red-500/40 ring-1 ring-red-500/20", show: noSetup.length > 0 }, + { status: "unmanaged" as ClientStatus, items: unmanaged, label: "not protected", + bg: "bg-amber-500/10", text: "text-amber-400", + activeBorder: "border-amber-500/40 ring-1 ring-amber-500/20", show: unmanaged.length > 0 }, { status: "missing" as ClientStatus, items: notInstalled, label: "not found", bg: "bg-gray-500/10", text: "text-gray-400", activeBorder: "border-gray-500/40 ring-1 ring-gray-500/20", show: notInstalled.length > 0 }, @@ -298,6 +378,7 @@ export default function ClientsView(): React.ReactNode { connected: "border-emerald-500/20", "partial-setup": "border-amber-500/20", installed: "border-red-500/20", + unmanaged: "border-amber-500/20", missing: "border-gray-500/20", }; return ( @@ -326,6 +407,7 @@ export default function ClientsView(): React.ReactNode { connected: "Connected", "partial-setup": "Incomplete", installed: "Not Set Up", + unmanaged: "Not Protected", missing: "Not Installed", }; @@ -333,6 +415,7 @@ export default function ClientsView(): React.ReactNode { connected: "success", "partial-setup": "warning", installed: "danger", + unmanaged: "warning", missing: "neutral", }; @@ -340,10 +423,16 @@ export default function ClientsView(): React.ReactNode { connected: "border-emerald-500/20 bg-emerald-500/5", "partial-setup": "border-amber-500/15 bg-amber-500/5", installed: "border-red-500/15 bg-red-500/5", + unmanaged: "border-amber-500/15 bg-amber-500/5", missing: "border-[var(--border)] bg-[var(--bg-raised)] opacity-60", }; - const issueDetail = status === "partial-setup" ? getIssueDetail(client) : null; + const issueDetail = + status === "partial-setup" + ? getIssueDetail(client) + : status === "unmanaged" + ? unmanageableReason(client.id).row + : null; return (
{ setClients((prev) => - prev.map((c) => (c.id === id ? { ...c, enabled: !c.enabled } : c)), + prev.map((c) => (c.id === id && c.manageable ? { ...c, enabled: !c.enabled } : c)), ); }; @@ -273,7 +282,13 @@ export default function AppsStep({ const selectedCount = clients.filter((c) => c.enabled).length; - const PARTIALLY_SUPPORTED_IDS = new Set(["claude-desktop", "claude-cowork"]); + // A presentation grouping, NOT a capability: these are the clients whose + // users are likely to be running Connectors, so they get the warning banner + // below. Whether Edison can configure a client is `manageable`, which comes + // from the daemon - the two overlap here but are not the same question. + // Claude Desktop/Cowork are manageable (they have a local config Edison + // writes) and still belong under this warning; ChatGPT is neither. + const PARTIALLY_SUPPORTED_IDS = new Set(["claude-desktop", "claude-cowork", "chatgpt"]); const fullySupportedClients = clients.filter((c) => !PARTIALLY_SUPPORTED_IDS.has(c.id)); const partiallySupportedClients = clients.filter((c) => PARTIALLY_SUPPORTED_IDS.has(c.id)); @@ -287,26 +302,33 @@ export default function AppsStep({ boxShadow: client.enabled ? "0 0 12px 0 rgba(125, 255, 246, 0.08)" : "none", }} > - {/* Clickable row - toggles selection */} + {/* Row. Toggles selection for clients Edison can configure; for the rest + it is inert text, because a checkbox whose value is discarded is worse + than no checkbox. */}

@@ -401,7 +429,7 @@ export default function AppsStep({

We currently only support local MCP servers, not Connectors. You should remove your connectors manually for your safety and request - a equivalent server in Edison Watch from your admin. + an equivalent server in Edison Watch from your admin.
{partiallySupportedClients.map(renderClientCard)} diff --git a/packages/desktop/src/renderer/src/testing/mockApi.ts b/packages/desktop/src/renderer/src/testing/mockApi.ts index 2711738..ba112de 100644 --- a/packages/desktop/src/renderer/src/testing/mockApi.ts +++ b/packages/desktop/src/renderer/src/testing/mockApi.ts @@ -22,6 +22,8 @@ export interface MockClient { id: string name: string configPath: string + /** False for hosts Edison can only detect, never configure (ChatGPT). */ + manageable: boolean } type Api = Window['api']