From 5ad5f8e52fbe126a0675551e21f92d23b2fc94ab Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Wed, 8 Jul 2026 19:32:17 +0300 Subject: [PATCH 01/15] New detectors daemon for detection and quarantine --- .gitignore | 3 + package.json | 1 + scripts/build-detectord.sh | 57 +++++ src/main/detectord/approvalDialog.ts | 252 +++++++++++++++++++++ src/main/detectord/binary.ts | 42 ++++ src/main/detectord/bootstrap.ts | 234 +++++++++++++++++++ src/main/detectord/controller.ts | 77 +++++++ src/main/detectord/discovery.ts | 72 ++++++ src/main/detectord/lifecycle.ts | 70 ++++++ src/main/detectord/mode.ts | 14 ++ src/main/detectord/protocol.ts | 91 ++++++++ src/main/detectord/socket.ts | 179 +++++++++++++++ src/main/detectord/submit.ts | 103 +++++++++ src/main/discovery/mcpDiscovery.ts | 23 +- src/main/index.ts | 33 ++- src/main/ipc/ipcHandlers.ts | 51 ++++- src/main/ipc/ipcHandlersMcpSubmit.ts | 34 +++ src/main/runtime/monitorLog.ts | 1 + src/preload/index.d.ts | 10 + src/preload/index.ts | 17 ++ src/renderer/src/App.tsx | 20 ++ src/renderer/src/components/OrgKeyCard.tsx | 8 + 22 files changed, 1374 insertions(+), 18 deletions(-) create mode 100755 scripts/build-detectord.sh create mode 100644 src/main/detectord/approvalDialog.ts create mode 100644 src/main/detectord/binary.ts create mode 100644 src/main/detectord/bootstrap.ts create mode 100644 src/main/detectord/controller.ts create mode 100644 src/main/detectord/discovery.ts create mode 100644 src/main/detectord/lifecycle.ts create mode 100644 src/main/detectord/mode.ts create mode 100644 src/main/detectord/protocol.ts create mode 100644 src/main/detectord/socket.ts create mode 100644 src/main/detectord/submit.ts diff --git a/.gitignore b/.gitignore index df05cd6..4c56ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ dev-app-update.yml # Claude Code .claude/ + +# Local-only build helper (kept out of git) +/build-linux.yml diff --git a/package.json b/package.json index 9366f50..c499a91 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:demo": "npm run typecheck && electron-vite build --mode demo", "build:release": "npm run typecheck && electron-vite build --mode release", "build:stdiod": "bash scripts/build-stdiod.sh", + "build:detectord": "bash scripts/build-detectord.sh", "build:mac": "npm run build:stdiod && npm run build:demo && electron-builder --mac", "build:mac:release": "npm run build:stdiod && npm run build:release && electron-builder --mac", "stage:python": "pwsh -NoProfile -File scripts/stage-python.ps1", diff --git a/scripts/build-detectord.sh b/scripts/build-detectord.sh new file mode 100755 index 0000000..cc4cf0b --- /dev/null +++ b/scripts/build-detectord.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build the mcp_detector_daemon (edison-detectord) as a universal macOS binary +# and stage it into desktop/bin/ so electron-builder's mac.extraResources rule +# copies it into Contents/Resources/bin/ of the packaged .app. +# +# Mirrors build-stdiod.sh. The daemon source is the sibling `detectord/` clone +# (edison-client/detectord). The cargo binary is `mcp_detector_daemon`; we stage +# it under the friendlier name `edison-detectord` (matching the stdiod naming). +# +# Why universal: electron-builder.yml sets mac.target: universal, so every +# nested binary must also be universal or the merge fails; we build both arches +# and lipo them. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLIENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$CLIENT_DIR/.." && pwd)" +DETECTORD_DIR="$REPO_ROOT/detectord" +BIN_NAME="mcp_detector_daemon" +OUT_DIR="$CLIENT_DIR/bin" +OUT_BIN="$OUT_DIR/edison-detectord" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "build-detectord.sh: only supported on macOS (got $(uname -s))" >&2 + exit 1 +fi + +if [[ ! -d "$DETECTORD_DIR" ]]; then + echo "build-detectord.sh: expected the daemon clone at $DETECTORD_DIR" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +for target in aarch64-apple-darwin x86_64-apple-darwin; do + if ! rustup target list --installed | grep -q "^${target}\$"; then + echo "Installing rustup target $target ..." + rustup target add "$target" + fi +done + +echo "Building $BIN_NAME for aarch64-apple-darwin ..." +( cd "$DETECTORD_DIR" && cargo build --release --bin "$BIN_NAME" --target aarch64-apple-darwin ) + +echo "Building $BIN_NAME for x86_64-apple-darwin ..." +( cd "$DETECTORD_DIR" && cargo build --release --bin "$BIN_NAME" --target x86_64-apple-darwin ) + +echo "Creating universal binary at $OUT_BIN ..." +lipo -create \ + "$DETECTORD_DIR/target/aarch64-apple-darwin/release/$BIN_NAME" \ + "$DETECTORD_DIR/target/x86_64-apple-darwin/release/$BIN_NAME" \ + -output "$OUT_BIN" +chmod +x "$OUT_BIN" + +echo "Verifying architectures ..." +lipo -info "$OUT_BIN" diff --git a/src/main/detectord/approvalDialog.ts b/src/main/detectord/approvalDialog.ts new file mode 100644 index 0000000..a0903a2 --- /dev/null +++ b/src/main/detectord/approvalDialog.ts @@ -0,0 +1,252 @@ +// Daemon-driven quarantine approval window. +// +// In primary mode the daemon auto-quarantines new servers and emits a +// `quarantine-prompt` event for each. The client's job is the human decision — +// send to Edison Watch (register/request) or keep quarantined — plus +// rename-on-conflict. A whole batch of newly-quarantined servers is shown in a +// SINGLE window (one row each, with bulk actions), driving the daemon's +// `disposition` op directly (the daemon owns config, submit, secret-templatizing, +// seen-store and removal), rather than the local-discovery path which can't see +// a server the daemon has already removed. + +import { BrowserWindow, ipcMain } from 'electron' +import { join } from 'path' + +import { getClientDisplayName } from '../runtime/mcpConfigMonitor' +import { + BASE_CSS, + HEADER_CSS, + SERVER_CARD_CSS, + BUTTON_CSS, + REGISTRATION_CSS +} from '../dialogs/dialogStyles' +import { escapeHtml, getClientIcon } from '../dialogs/dialogIcons' + +import type { ServerView } from './protocol' +import type { DetectordClient } from './socket' + +let approvalWindow: BrowserWindow | null = null +let channelSeq = 0 + +// Daemon agent names use underscores (`claude_code`); the client's display / +// icon helpers key off the dashed client ids (`claude-code`). +const toClientId = (agent: string): string => agent.replace(/_/g, '-') + +/** + * Show one window listing every server in `servers` (a batch of newly + * quarantined ones), each with Send-to-EW / Keep-quarantined + inline + * rename-on-conflict. Resolves when the window closes. Only one window at a + * time — the caller batches; a second call while one is open is ignored. + */ +export function showDaemonApprovalDialog( + client: DetectordClient, + servers: ServerView[], + isAdminOrOwner: boolean, + parentWindow?: BrowserWindow +): Promise { + if (approvalWindow && !approvalWindow.isDestroyed()) { + approvalWindow.focus() + return Promise.resolve() + } + if (servers.length === 0) return Promise.resolve() + + const channel = `detectord:disposition:${(channelSeq += 1)}` + + return new Promise((resolve) => { + // Disposition one server (by its index in `servers`). Success => acted; + // a backend 409 => conflict so the row can offer a rename. + const handler = async ( + _e: Electron.IpcMainInvokeEvent, + req: { index: number; skip?: boolean; rename?: string } + ): Promise<{ ok: boolean; conflict?: boolean; message?: string }> => { + const s = servers[req.index] + if (!s) return { ok: false, message: 'unknown server' } + if (req.skip) { + try { + await client.disposition(s.name, 'skip', s.agent) + } catch (err) { + console.error(`[detectord] disposition skip ${s.name} failed: ${String(err)}`) + } + console.log(`[Quarantine] disposition ${s.name} (${s.agent}) -> skip`) + return { ok: true } + } + try { + await client.disposition(s.name, 'send_to_ew', s.agent, req.rename) + console.log(`[Quarantine] disposition ${s.name} (${s.agent}) -> send_to_ew`) + return { ok: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (/conflict/i.test(message)) return { ok: false, conflict: true, message } + return { ok: false, message } + } + } + + try { + ipcMain.handle(channel, handler) + } catch { + ipcMain.removeHandler(channel) + ipcMain.handle(channel, handler) + } + + approvalWindow = new BrowserWindow({ + width: 520, + height: Math.min(720, 150 + servers.length * 132), + show: false, + autoHideMenuBar: true, + resizable: true, + minimizable: false, + maximizable: false, + parent: parentWindow && !parentWindow.isDestroyed() ? parentWindow : undefined, + modal: false, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + nodeIntegration: true, + contextIsolation: false + } + }) + + approvalWindow.on('closed', () => { + ipcMain.removeHandler(channel) + approvalWindow = null + resolve() + }) + + approvalWindow.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(buildHtml(servers, isAdminOrOwner, channel))}` + ) + approvalWindow.once('ready-to-show', () => approvalWindow?.show()) + }) +} + +function serverInfoLine(server: ServerView): string { + return escapeHtml([server.kind, server.path].filter(Boolean).join(' · ')) +} + +function rowHtml(server: ServerView, index: number, primaryLabel: string): string { + const clientId = toClientId(server.agent) + const clientName = getClientDisplayName(clientId as never) + const clientIcon = getClientIcon(clientId as never, server.fingerprint ?? '') + return ` +
+
+
${escapeHtml(server.name)}
+
+ ${clientIcon} + ${escapeHtml(clientName)} +
+
+
${serverInfoLine(server)}
+ + +
+ + +
+
` +} + +function buildHtml(servers: ServerView[], isAdminOrOwner: boolean, channel: string): string { + const primaryLabel = isAdminOrOwner ? 'Add to Edison' : 'Request Approval' + const bulkLabel = isAdminOrOwner ? 'Add all to Edison' : 'Request all' + const rows = servers.map((s, i) => rowHtml(s, i, primaryLabel)).join('') + return ` + + + +MCP Servers Quarantined + + + +
+

MCP Servers Quarantined (${servers.length})

+
+ + +
+
+ ${rows} + + +` +} diff --git a/src/main/detectord/binary.ts b/src/main/detectord/binary.ts new file mode 100644 index 0000000..327a8c5 --- /dev/null +++ b/src/main/detectord/binary.ts @@ -0,0 +1,42 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' + +import { app } from 'electron' + +// Resolve the absolute path to the mcp_detector_daemon (edison-detectord) binary. +// +// Packaged: Contents/Resources/bin/edison-detectord (staged by +// scripts/build-detectord.sh, copied via mac.extraResources). Dev: the cargo +// target dir in the sibling detectord/ clone, where the binary keeps its cargo +// name `mcp_detector_daemon` — run `cargo build --release` (or build-detectord.sh) +// there once. +export function getDetectordBinaryPath(): string { + const win = process.platform === 'win32' + if (app.isPackaged) { + return path.join(process.resourcesPath, 'bin', win ? 'edison-detectord.exe' : 'edison-detectord') + } + // __dirname in dev is /desktop/out/main. Accept either the cargo dev + // build (detectord/target/release, plain `cargo build --release`) or the + // staged universal binary (desktop/bin, `npm run build:detectord`). + const repoRoot = path.resolve(__dirname, '..', '..', '..') + const candidates = [ + path.join(repoRoot, 'detectord', 'target', 'release', win ? 'mcp_detector_daemon.exe' : 'mcp_detector_daemon'), + path.join(__dirname, '..', '..', 'bin', win ? 'edison-detectord.exe' : 'edison-detectord') + ] + return candidates.find(existsSync) ?? candidates[0]! +} + +export function detectordBinaryExists(): boolean { + return existsSync(getDetectordBinaryPath()) +} + +// The Unix socket the launchd-managed daemon serves. Matches the daemon's +// ipc::default_socket_path() (base_dir/daemon.sock, base_dir = +// ~/Library/Application Support/edison-watch-detectord in user mode). +export function detectordSocketPath(): string { + return path.join( + app.getPath('appData'), + 'edison-watch-detectord', + 'daemon.sock' + ) +} diff --git a/src/main/detectord/bootstrap.ts b/src/main/detectord/bootstrap.ts new file mode 100644 index 0000000..0525f3f --- /dev/null +++ b/src/main/detectord/bootstrap.ts @@ -0,0 +1,234 @@ +// Bootstrap the detector daemon: install + launch it (on every client run), +// enroll it with the app's credentials, and mirror everything it does into the +// client's log streams. In primary mode (the default) the daemon owns detection, +// quarantine, install, and hooks — the TS pipeline stands down. +// +// Logging: we emit console.log with the SAME prefixes the TS pipeline uses +// (`[Monitor]`, `[Quarantine]`, `[SeenStore]`) so monitorLog's tee captures them +// into /tmp/ew-monitor.log — the client "still produces the detection and +// quarantine logs" even though the daemon is the one doing the work. Install / +// enroll use a `[detectord]` prefix. + +import { + getApiBaseUrl, + getCredentialsForEnv, + getMcpBaseUrl, + getSetupData, + isSetupComplete +} from '../infra/setupConfig' + +import { showDaemonApprovalDialog } from './approvalDialog' +import { detectordBinaryExists, getDetectordBinaryPath } from './binary' +import { ensureDetectord } from './lifecycle' +import { detectordPrimary } from './mode' +import type { DetectordEvent, SecretOutcome, ServerView } from './protocol' +import type { DetectordClient } from './socket' + +// App ids use dashes (`claude-code`); daemon agent names use underscores. +const toAgent = (appId: string): string => appId.replace(/-/g, '_') + +let eventsSubscribed = false + +/** + * Credentials pushed from the renderer right after sign-in. A returning login + * keeps its API key in the renderer's auth state and never persists it to the + * main-process setup file, so `getCredentialsForEnv()` is empty at app-ready. + * The renderer therefore pushes them here (mirroring `stdiod.login`) so the + * daemon can enroll on login instead of only after onboarding. + */ +export interface DetectordEnrollInput { + apiUrl?: string + mcpUrl?: string + apiKey?: string + edisonSecretKey?: string +} + +/** + * Install + enroll + start logging the daemon. Idempotent — safe to call + * unconditionally on app-ready, on setup:complete, after account switches, and + * from the renderer's post-login push (install is once-per-session; enroll is + * additive). Without `creds` it reads persisted setup; enroll is skipped (with + * a log) until credentials are available from either source. + */ +export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise { + const primary = detectordPrimary() + console.log( + `[detectord] bootstrap mode=${primary ? 'primary (enforce)' : 'shadow (detect-only)'} ` + + `binary=${getDetectordBinaryPath()} available=${detectordBinaryExists()}` + ) + const ensured = await ensureDetectord((m) => console.log(m), primary) + if (!ensured.ok) { + console.error(`[detectord] bootstrap skipped: ${ensured.reason}`) + return + } + const client = ensured.client + + // Enroll is safe to run on every login: it's additive (agents union with the + // existing set — never removed) and non-destructive (a missing secret keeps + // the existing one). So we always (re-)enroll whenever credentials are + // available rather than guarding on prior state; agent/key *additions* still + // come through here (union) and removals go through unenroll. + if (!(await enrollDaemon(client, primary, creds))) return + + if (!eventsSubscribed) { + eventsSubscribed = true + client.onEvent((ev) => void handleDaemonEvent(client, ev)) + } + + await logInitialDetection(client) +} + +/** + * Register/adopt the org secret key with the daemon when the user enters or + * changes it (OrgKeyCard). `verify_secret` validates against the backend and + * adopts it into the enrollment — the explicit "enroll key" state change. The + * daemon must already be enrolled; if it isn't, this is a non-fatal no-op. + */ +export async function setDetectordSecret( + key: string +): Promise<{ ok: boolean; outcome?: SecretOutcome; reason?: string }> { + const ensured = await ensureDetectord((m) => console.log(m), detectordPrimary()) + if (!ensured.ok) { + console.warn(`[detectord] set secret skipped: ${ensured.reason}`) + return { ok: false, reason: ensured.reason } + } + try { + const outcome = await ensured.client.verifySecret(key) + console.log(`[detectord] secret adopted valid=${outcome.valid ?? '?'}`) + return { ok: true, outcome } + } catch (err) { + console.error(`[detectord] set secret failed: ${String(err)}`) + return { ok: false, reason: String(err) } + } +} + +async function enrollDaemon( + client: DetectordClient, + primary: boolean, + override?: DetectordEnrollInput +): Promise { + const stored = getCredentialsForEnv() + const apiUrl = override?.apiUrl ?? getApiBaseUrl() + const mcpUrl = override?.mcpUrl ?? getMcpBaseUrl() + const apiKey = override?.apiKey ?? stored?.apiKey + const edisonSecretKey = override?.edisonSecretKey ?? stored?.edisonSecretKey + const setup = getSetupData() + if (!apiUrl || !apiKey) { + console.warn('[detectord] not enrolling — no api url / key yet') + return false + } + // Only the apps the user has actually configured. Empty (e.g. a new user who + // hasn't reached the app-selection step yet) => a base enroll with no agents: + // the daemon installs edison-watch on nothing until onboarding adds them. + // Agents are additive daemon-side, so an empty set never removes any. + const appIds = setup.configuredApps ?? [] + const agents = appIds.map(toAgent) + // Arm auto-quarantine only once onboarding is complete. While a new user is + // still in onboarding the daemon stays detect-only (lists/reports, quarantines + // nothing) so onboarding can review + send-to-EW first. setup:complete runs + // markSetupComplete before this, so it re-enrolls armed. + const armed = isSetupComplete() + try { + const status = await client.enroll({ + url: apiUrl, + key: apiKey, + mcpUrl: mcpUrl ?? undefined, + agents, + secret: edisonSecretKey, + // primary => the daemon installs edison-watch + hooks; shadow => detect-only. + install: primary, + armed + }) + console.log( + `[detectord] enrolled org=${status.org_name ?? '?'} role=${status.role ?? '?'} ` + + `policy.quarantine=${status.quarantine} armed=${armed} agents=${agents.join(',')}` + ) + return true + } catch (err) { + console.error(`[detectord] enroll failed: ${String(err)}`) + return false + } +} + +async function logInitialDetection(client: DetectordClient): Promise { + try { + const servers = await client.listServers() + const actionable = servers.filter((s) => s.state !== 'edison') + console.log(`[Monitor] daemon discovered ${actionable.length} MCP server(s)`) + for (const s of actionable) logServer('[Monitor]', s) + } catch (err) { + console.error(`[detectord] list_servers failed: ${String(err)}`) + } +} + +function logServer(prefix: string, s: ServerView): void { + const fp = s.fingerprint ? ` fp=${s.fingerprint}` : '' + console.log(`${prefix} ${s.name} (${s.agent}) ${s.kind} state=${s.state}${fp}`) +} + +// Batch quarantine prompts into ONE window: the daemon quarantines a whole +// batch of new servers in a single reconcile pass and fires a quarantine-prompt +// event for each, (near-)simultaneously. We debounce the burst and show all of +// them in one dialog (a row each), so the user reviews the whole set at once. +// Anything that arrives while a window is open is collected into the next batch. +const pendingBatch: ServerView[] = [] +let batchTimer: ReturnType | null = null +let dialogOpen = false +const BATCH_DEBOUNCE_MS = 400 + +function enqueuePrompt(client: DetectordClient, s: ServerView): void { + // Dedup by name+agent: repeated fs events can re-emit before the user acts. + const key = `${s.name}:${s.agent}` + if (pendingBatch.some((q) => `${q.name}:${q.agent}` === key)) return + pendingBatch.push(s) + scheduleBatch(client) +} + +function scheduleBatch(client: DetectordClient): void { + if (dialogOpen) return // shown when the current window closes + if (batchTimer) clearTimeout(batchTimer) + batchTimer = setTimeout(() => void flushBatch(client), BATCH_DEBOUNCE_MS) +} + +async function flushBatch(client: DetectordClient): Promise { + batchTimer = null + if (dialogOpen || pendingBatch.length === 0) return + const batch = pendingBatch.splice(0, pendingBatch.length) + // Owners/admins register directly ("Add to Edison"); everyone else files a + // request. The daemon enforces this by role regardless — this is the label. + let isAdminOrOwner = false + try { + const status = await client.status() + isAdminOrOwner = status.role === 'owner' || status.role === 'admin' + } catch { + /* label defaults to "Request Approval" */ + } + dialogOpen = true + try { + await showDaemonApprovalDialog(client, batch, isAdminOrOwner) + } finally { + dialogOpen = false + if (pendingBatch.length > 0) scheduleBatch(client) + } +} + +async function handleDaemonEvent(client: DetectordClient, ev: DetectordEvent): Promise { + switch (ev.event) { + case 'quarantined': + console.log( + `[Quarantine] daemon quarantined ${ev.name} (${ev.agent}) state=${ev.state}` + + (ev.fingerprint ? ` fp=${ev.fingerprint}` : '') + ) + // New (unknown) servers need the user's call: send to EW or keep quarantined. + if (ev.state === 'quarantine-prompt') { + enqueuePrompt(client, ev) + } + break + case 'discovered': + logServer('[Monitor] daemon discovered', ev) + break + case 'policy_changed': + console.log(`[Quarantine] daemon policy.quarantine=${ev.quarantine}`) + break + } +} diff --git a/src/main/detectord/controller.ts b/src/main/detectord/controller.ts new file mode 100644 index 0000000..06e7796 --- /dev/null +++ b/src/main/detectord/controller.ts @@ -0,0 +1,77 @@ +// Main-process controller for the bundled detector daemon. +// +// Like stdiod/controller.ts, this only orchestrates one-shot CLI subcommands +// (`service install|uninstall|status`). The LaunchAgent registered by +// `edison-detectord service install` is what actually keeps the daemon running; +// live operations go through the socket (see socket.ts), not this controller. + +import { spawn } from 'node:child_process' + +import { detectordBinaryExists, getDetectordBinaryPath } from './binary' + +export interface SpawnResult { + code: number + stdout: string + stderr: string +} + +export interface ServiceStatus { + installed: boolean + running: boolean + socket: string +} + +// EDISON_DRY_RUN (Playwright/Storybook) short-circuits subprocess calls so test +// runs don't touch launchctl on the host — matching stdiod's controller. +function dryRun(): boolean { + return process.env.EDISON_DRY_RUN === '1' +} + +function runDetectord(args: string[]): Promise { + const binary = getDetectordBinaryPath() + return new Promise((resolve, reject) => { + const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (c: Buffer) => (stdout += c.toString('utf8'))) + child.stderr.on('data', (c: Buffer) => (stderr += c.toString('utf8'))) + child.on('error', reject) + child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr })) + }) +} + +/** True once `scripts/build-detectord.sh` (or a dev `cargo build`) has run. */ +export function detectordAvailable(): boolean { + return detectordBinaryExists() +} + +/** + * Install + start the LaunchAgent. `enforce=false` (default) runs the daemon + * report-only — safe for first install; flip to true once wired up and trusted. + */ +export async function installService(enforce = false): Promise { + if (dryRun()) return { code: 0, stdout: '', stderr: '' } + const args = ['service', 'install'] + if (enforce) args.push('--enforce') + return runDetectord(args) +} + +export async function uninstallService(opts?: { purge?: boolean }): Promise { + if (dryRun()) return { code: 0, stdout: '', stderr: '' } + const args = ['service', 'uninstall'] + if (opts?.purge) args.push('--purge') + return runDetectord(args) +} + +export async function serviceStatus(): Promise { + if (dryRun() || !detectordBinaryExists()) { + return { installed: false, running: false, socket: '' } + } + const { stdout } = await runDetectord(['service', 'status']) + const field = (k: string): string => stdout.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? '' + return { + installed: field('installed') === 'true', + running: field('running') === 'true', + socket: field('socket') + } +} diff --git a/src/main/detectord/discovery.ts b/src/main/detectord/discovery.ts new file mode 100644 index 0000000..b3af20f --- /dev/null +++ b/src/main/detectord/discovery.ts @@ -0,0 +1,72 @@ +// Source MCP-server discovery from the daemon instead of re-scanning locally. +// +// In primary mode the daemon is the single source of truth for what's on the +// machine — and, unlike the client's own scan, it sees stdio servers. This maps +// the daemon's `list_servers` into the client's DiscoveredMcpServer shape so the +// existing onboarding/dedup/submit pipeline runs unchanged on the daemon's view. + +import type { + DiscoveredMcpServer, + McpClientId, + McpServerConfig, + McpServerTransport +} from '../discovery/types' + +import { getDetectordClient } from './lifecycle' +import type { ServerConfig, ServerView } from './protocol' + +// Daemon agent ids use underscores (`claude_code`); client ids use dashes. +const toClientId = (agent: string): McpClientId => agent.replace(/_/g, '-') as McpClientId + +function mapConfig(cfg: ServerConfig): McpServerConfig | null { + if ('Stdio' in cfg) { + const { command, args, env } = cfg.Stdio + return { command, args, env } + } + if ('Http' in cfg) { + const { url, headers, kind } = cfg.Http + // The client transport union has no "streamable-http"; fold it into http. + const type: McpServerTransport = kind === 'Sse' ? 'sse' : 'http' + return { type, url, headers } + } + if ('Opaque' in cfg) { + return { type: 'opaque' } + } + return null +} + +function toDiscovered(v: ServerView): DiscoveredMcpServer | null { + if (v.state === 'edison') return null // never surface our own entries + if (!v.config) return null + const config = mapConfig(v.config) + if (!config) return null + return { + name: v.name, + client: toClientId(v.agent), + // The daemon doesn't classify source; default to 'user'. It only affects + // display grouping/metadata, not the supported/unsupported split (which is + // by config shape) or submission. + source: 'user', + path: v.path, + config + } +} + +/** + * The daemon's discovered servers as DiscoveredMcpServer[], or `null` to signal + * "fall back to local discovery" — when the daemon is unreachable or not yet + * enrolled (e.g. before login, when it has nothing to report anyway). + */ +export async function discoverViaDetectord(): Promise { + const client = getDetectordClient() + try { + await client.connect() + const status = await client.status() + if (!status.enrolled) return null + const servers = await client.listServers() + return servers.map(toDiscovered).filter((s): s is DiscoveredMcpServer => s !== null) + } catch (err) { + console.warn(`[detectord] discovery via daemon failed; using local scan: ${String(err)}`) + return null + } +} diff --git a/src/main/detectord/lifecycle.ts b/src/main/detectord/lifecycle.ts new file mode 100644 index 0000000..7425054 --- /dev/null +++ b/src/main/detectord/lifecycle.ts @@ -0,0 +1,70 @@ +// Detector-daemon lifecycle from the app's side: ensure the LaunchAgent is +// installed (report-only) and hold a shared socket client. Mirrors how the app +// treats stdiod — the daemon is launchd-managed; we only orchestrate install + +// connect. + +import { DetectordClient } from './socket' +import { getDetectordBinaryPath } from './binary' +import { detectordAvailable, installService } from './controller' + +let client: DetectordClient | null = null +let installedThisSession = false + +/** The shared client (lazily created; connects on first request). */ +export function getDetectordClient(): DetectordClient { + if (!client) client = new DetectordClient() + return client +} + +export type EnsureResult = + | { ok: true; client: DetectordClient } + | { ok: false; reason: string } + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +/** + * Ensure the daemon is installed (report-only by default) and the socket + * connects. Returns a discriminated result so the caller can surface the actual + * failure. The connect is retried — after `service install`, launchd needs a + * moment to start the daemon and bind the socket. + */ +export async function ensureDetectord( + slog: (m: string) => void = () => {}, + enforce = false +): Promise { + const binary = getDetectordBinaryPath() + if (!detectordAvailable()) { + return { + ok: false, + reason: `daemon binary not found at ${binary} — run \`npm run build:detectord\` (or \`cargo build --release\` in detectord/).` + } + } + // Install once per app session (the LaunchAgent bootstrap is a restart, so we + // don't want to bounce it on every call — but we DO want it installed on every + // client run, which the unconditional caller guarantees). + if (!installedThisSession) { + try { + const r = await installService(enforce) + slog(`[detectord] service install (enforce=${enforce}) exit=${r.code} ${r.stdout.trim()} ${r.stderr.trim()}`) + if (r.code !== 0) { + return { ok: false, reason: `service install failed (exit ${r.code}): ${r.stderr.trim() || r.stdout.trim()}` } + } + installedThisSession = true + } catch (err) { + return { ok: false, reason: `service install error: ${String(err)}` } + } + } + + const c = getDetectordClient() + let lastErr = '' + for (let attempt = 0; attempt < 10; attempt++) { + try { + await c.connect() + return { ok: true, client: c } + } catch (err) { + lastErr = String(err) + await sleep(300) + } + } + return { ok: false, reason: `socket connect failed after retries: ${lastErr}` } +} diff --git a/src/main/detectord/mode.ts b/src/main/detectord/mode.ts new file mode 100644 index 0000000..ef07cc8 --- /dev/null +++ b/src/main/detectord/mode.ts @@ -0,0 +1,14 @@ +// The single cutover switch. +// +// primary — the detector daemon owns install + hooks + quarantine (runs +// --enforce, enrolls install:true); the TS pipeline stands down. +// shadow — the daemon runs detect-only + logs; the TS pipeline stays primary. +// +// Reversible at any time: set EW_DETECTORD_PRIMARY=0 (or false/off) to fall back +// to the TS pipeline, then restart the app. Default is primary (cutover active). + +export function detectordPrimary(): boolean { + const v = (process.env.EW_DETECTORD_PRIMARY ?? '').toLowerCase() + if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false + return true +} diff --git a/src/main/detectord/protocol.ts b/src/main/detectord/protocol.ts new file mode 100644 index 0000000..56c5bd1 --- /dev/null +++ b/src/main/detectord/protocol.ts @@ -0,0 +1,91 @@ +// TypeScript mirror of the daemon's wire protocol (detectord/crates/ +// mcp_detector_daemon/src/protocol.rs). Newline-delimited JSON over the Unix +// socket: one Reply per Request (FIFO), plus unsolicited Event pushes. + +export type Choice = 'send_to_ew' | 'skip' + +export type Request = + | { + op: 'enroll' + url: string + key: string + mcp_url?: string + agents?: string[] + secret?: string + /** false = detect-only (no edison-watch install / hooks). Defaults true. */ + install?: boolean + /** Arm auto-quarantine. Set true only once onboarding completes. */ + armed?: boolean + } + | { op: 'status'; refresh?: boolean } + | { op: 'list_agents' } + | { op: 'list_servers' } + | { op: 'disposition'; name: string; agent?: string; choice: Choice; rename?: string } + | { op: 'refresh_policy' } + | { op: 'verify_secret'; key: string } + | { op: 'reset_secret'; key: string; confirm: boolean } + | { op: 'unenroll' } + +export interface Status { + user: string + enrolled: boolean + org_id?: string | null + org_name?: string | null + email?: string | null + role?: string | null + quarantine: boolean + quarantined_count: number + armed?: boolean +} + +export interface AgentInfo { + name: string + installed: boolean +} + +/** One discovered server instance. `state`: edison | known | new | opaque | report. */ +// Mirrors the daemon's externally-tagged mcp_detector_lib::ServerConfig. +export type HttpKind = 'Http' | 'Sse' | 'StreamableHttp' +export type OpaqueReason = 'ExtensionProvider' | 'ExtensionServer' | 'CursorPlugin' +export type ServerConfig = + | { Stdio: { command: string; args: string[]; env: Record } } + | { Http: { url: string; headers: Record; kind: HttpKind } } + | { Opaque: { removable: boolean; reason: OpaqueReason } } + +export interface ServerView { + name: string + agent: string + kind: string // stdio | http | opaque + state: string + fingerprint?: string | null + path: string + config?: ServerConfig | null +} + +export interface SecretOutcome { + valid?: boolean | null + expired?: boolean | null + deleted?: number | null +} + +export type Reply = + | ({ reply: 'status' } & Status) + | { reply: 'agents'; agents: AgentInfo[] } + | { reply: 'servers'; servers: ServerView[] } + | ({ reply: 'secret' } & SecretOutcome) + | { reply: 'ack' } + | { reply: 'error'; message: string } + +export type DetectordEvent = + | ({ event: 'quarantined' } & ServerView) + | ({ event: 'discovered' } & ServerView) + | { event: 'policy_changed'; quarantine: boolean } + +/** A line from the daemon is either a Reply or an Event. */ +export function isEvent(msg: unknown): msg is DetectordEvent { + return typeof msg === 'object' && msg !== null && 'event' in msg +} + +export function isReply(msg: unknown): msg is Reply { + return typeof msg === 'object' && msg !== null && 'reply' in msg +} diff --git a/src/main/detectord/socket.ts b/src/main/detectord/socket.ts new file mode 100644 index 0000000..e066711 --- /dev/null +++ b/src/main/detectord/socket.ts @@ -0,0 +1,179 @@ +// Main-process client for the detector daemon's Unix socket. +// +// The daemon is launchd-managed (see `edison-detectord service install`); this +// connects to its socket and speaks newline-delimited JSON: one Reply per +// Request (FIFO, since the daemon serialises requests per connection), plus +// unsolicited Event pushes. Events are re-emitted via the 'event' EventEmitter +// channel for the quarantine/discovery UI to subscribe to. + +import { EventEmitter } from 'node:events' +import { createConnection, type Socket } from 'node:net' + +import { detectordSocketPath } from './binary' +import { + isEvent, + isReply, + type AgentInfo, + type Choice, + type DetectordEvent, + type Reply, + type Request, + type SecretOutcome, + type ServerView, + type Status +} from './protocol' + +/** A daemon Reply that carried an { reply: 'error' } becomes a thrown Error. */ +export class DetectordError extends Error {} + +type Pending = { resolve: (r: Reply) => void; reject: (e: Error) => void } + +export class DetectordClient extends EventEmitter { + private socket: Socket | null = null + private buf = '' + private readonly pending: Pending[] = [] + private connecting: Promise | null = null + + constructor(private readonly socketPath: string = detectordSocketPath()) { + super() + } + + /** Connect (idempotent). Rejects if the daemon socket isn't there. */ + connect(): Promise { + if (this.socket && !this.socket.destroyed) return Promise.resolve() + if (this.connecting) return this.connecting + + this.connecting = new Promise((resolve, reject) => { + const sock = createConnection(this.socketPath) + sock.setEncoding('utf8') + sock.once('connect', () => { + this.socket = sock + this.connecting = null + resolve() + }) + sock.once('error', (err) => { + this.connecting = null + reject(err) + }) + sock.on('data', (chunk: string) => this.onData(chunk)) + sock.on('close', () => this.onClose()) + }) + return this.connecting + } + + disconnect(): void { + this.socket?.destroy() + this.socket = null + } + + /** Send a request and await its reply. */ + async request(req: Request): Promise { + await this.connect() + return new Promise((resolve, reject) => { + this.pending.push({ resolve, reject }) + this.socket!.write(JSON.stringify(req) + '\n') + }) + } + + private onData(chunk: string): void { + this.buf += chunk + let nl: number + while ((nl = this.buf.indexOf('\n')) >= 0) { + const line = this.buf.slice(0, nl).trim() + this.buf = this.buf.slice(nl + 1) + if (!line) continue + let msg: unknown + try { + msg = JSON.parse(line) + } catch { + continue // ignore malformed line + } + if (isEvent(msg)) { + this.emit('event', msg as DetectordEvent) + } else if (isReply(msg)) { + this.pending.shift()?.resolve(msg as Reply) + } + } + } + + private onClose(): void { + this.socket = null + const err = new DetectordError('daemon socket closed') + while (this.pending.length) this.pending.shift()!.reject(err) + } + + // ── typed convenience wrappers ──────────────────────────────────────────── + + private async expect(req: Request): Promise { + const reply = await this.request(req) + if (reply.reply === 'error') throw new DetectordError(reply.message) + return reply + } + + async status(refresh = false): Promise { + const r = await this.expect({ op: 'status', refresh }) + if (r.reply !== 'status') throw new DetectordError(`unexpected reply ${r.reply}`) + return r + } + + async listAgents(): Promise { + const r = await this.expect({ op: 'list_agents' }) + if (r.reply !== 'agents') throw new DetectordError(`unexpected reply ${r.reply}`) + return r.agents + } + + async listServers(): Promise { + const r = await this.expect({ op: 'list_servers' }) + if (r.reply !== 'servers') throw new DetectordError(`unexpected reply ${r.reply}`) + return r.servers + } + + async enroll(input: { + url: string + key: string + mcpUrl?: string + agents?: string[] + secret?: string + /** false = detect-only (no edison-watch install / hooks). */ + install?: boolean + /** Arm auto-quarantine. Set true only once onboarding is complete. */ + armed?: boolean + }): Promise { + const r = await this.expect({ + op: 'enroll', + url: input.url, + key: input.key, + mcp_url: input.mcpUrl, + agents: input.agents, + secret: input.secret, + install: input.install, + armed: input.armed + }) + if (r.reply !== 'status') throw new DetectordError(`unexpected reply ${r.reply}`) + return r + } + + async disposition(name: string, choice: Choice, agent?: string, rename?: string): Promise { + await this.expect({ op: 'disposition', name, agent, choice, rename }) + } + + async verifySecret(key: string): Promise { + const r = await this.expect({ op: 'verify_secret', key }) + if (r.reply !== 'secret') throw new DetectordError(`unexpected reply ${r.reply}`) + return r + } + + async resetSecret(key: string): Promise { + const r = await this.expect({ op: 'reset_secret', key, confirm: true }) + if (r.reply !== 'secret') throw new DetectordError(`unexpected reply ${r.reply}`) + return r + } + + async unenroll(): Promise { + await this.expect({ op: 'unenroll' }) + } + + onEvent(cb: (ev: DetectordEvent) => void): this { + return this.on('event', cb) + } +} diff --git a/src/main/detectord/submit.ts b/src/main/detectord/submit.ts new file mode 100644 index 0000000..ff60d8d --- /dev/null +++ b/src/main/detectord/submit.ts @@ -0,0 +1,103 @@ +// Route onboarding's "register these servers" actions through the daemon. +// +// In primary mode the daemon owns submit (templatize secrets, send to EW, mark +// seen, remove locally) and handles stdio servers the client's own http-only +// submit path can't. Onboarding's bulk submit + rename-resubmit map cleanly onto +// per-server `disposition(send_to_ew[, rename])` calls. + +import type { DiscoveredMcpServer } from '../discovery/types' + +import { getDetectordClient } from './lifecycle' + +// Client ids use dashes (`claude-code`); daemon agent names use underscores. +const toAgent = (client: string): string => client.replace(/-/g, '_') + +export interface DetectordSubmitFailure { + name: string + client: string + reason: 'conflict' | 'error' | 'already-on-backend' + message: string + config?: Record + configPath?: string + backendStatus?: 'registered' | 'requested' +} + +export interface DetectordSubmitSummary { + submitted: number + autoApproved: number + skipped: number + alreadyOnBackend: number + total: number + servers: Array<{ name: string; client: string; clients?: string[]; source: string }> + failures: DetectordSubmitFailure[] +} + +/** + * Submit each server via the daemon. Success => submitted (autoApproved when the + * user is admin/owner, since the daemon registers directly for those roles). A + * backend 409 comes back as a `conflict:` error, surfaced as a conflict failure + * carrying the config so onboarding can offer the rename-resubmit flow. + */ +export async function submitServersViaDetectord( + servers: DiscoveredMcpServer[] +): Promise { + const client = getDetectordClient() + await client.connect() + const status = await client.status().catch(() => null) + const isAdminOrOwner = status?.role === 'admin' || status?.role === 'owner' + + let submitted = 0 + let autoApproved = 0 + const failures: DetectordSubmitFailure[] = [] + for (const s of servers) { + try { + await client.disposition(s.name, 'send_to_ew', toAgent(s.client)) + submitted++ + if (isAdminOrOwner) autoApproved++ + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (/conflict/i.test(message)) { + failures.push({ + name: s.name, + client: s.client, + reason: 'conflict', + message, + config: s.config as unknown as Record, + configPath: s.path + }) + } else { + failures.push({ name: s.name, client: s.client, reason: 'error', message }) + } + } + } + return { + submitted, + autoApproved, + skipped: 0, + alreadyOnBackend: 0, + total: servers.length, + servers: servers.map((s) => ({ + name: s.name, + client: s.client, + clients: s.clients, + source: s.source + })), + failures + } +} + +/** Resubmit a name-conflicting server under a new name via the daemon. */ +export async function resubmitServerViaDetectord( + name: string, + newName: string, + client: string +): Promise<{ success: boolean; error?: string }> { + const c = getDetectordClient() + await c.connect() + try { + await c.disposition(name, 'send_to_ew', toAgent(client), newName) + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } +} diff --git a/src/main/discovery/mcpDiscovery.ts b/src/main/discovery/mcpDiscovery.ts index 62668da..07cd82b 100644 --- a/src/main/discovery/mcpDiscovery.ts +++ b/src/main/discovery/mcpDiscovery.ts @@ -12,6 +12,8 @@ import type { DiscoveredMcpServer, DiscoveryResult } from './types' import { isOpaqueConfig, hasMalformedHeaders } from './types' import { clientAlias } from './serverDeduplication' import { MAC_APP_NAMES, macAppExists } from './macAppNames' +import { detectordPrimary } from '../detectord/mode' +import { discoverViaDetectord } from '../detectord/discovery' // ── Re-exports (backward compatibility) ──────────────────────────────────── @@ -76,8 +78,13 @@ export { MAC_APP_NAMES, macAppExists } export async function discoverMcpServers(): Promise export async function discoverMcpServers(opts: { includeRaw: true }): Promise export async function discoverMcpServers(opts?: { includeRaw?: boolean }): Promise { - const perClient = await Promise.all(CLIENT_LIST.map((c) => c.discoverServers())) - const results: DiscoveredMcpServer[] = perClient.flat() + // Primary mode: the daemon is the source of truth (and sees stdio servers the + // client can't). Fall back to a local scan only when it returns null (daemon + // unreachable / not enrolled). The rest of the pipeline — shim-unwrap, + // supported/unsupported split, dedup, installed-app filter — runs unchanged. + const daemonServers = detectordPrimary() ? await discoverViaDetectord() : null + const results: DiscoveredMcpServer[] = + daemonServers ?? (await Promise.all(CLIENT_LIST.map((c) => c.discoverServers()))).flat() // Normalize stdio shims (e.g. `npx -y mcp-remote https://…`) to their // URL-shaped equivalents so downstream code (submit, dedup, credential @@ -90,8 +97,13 @@ export async function discoverMcpServers(opts?: { includeRaw?: boolean }): Promi // Split supported / unsupported. Unsupported = opaque (Cursor marketplace // and VS Code state-DB shapes) OR local stdio that we can't unwrap into a // URL OR an HTTP server whose `headers` field is the wrong shape (must be - // a JSON object). Local stdio is no longer executable post-rip-out, so we - // list it for visibility but don't quarantine or submit it. + // a JSON object). + // + // Local stdio is "not yet supported" only for the *client's* http-proxy path. + // When the list comes from the daemon (primary mode), the daemon can act on + // stdio servers, so they're supported (registerable via the daemon) — don't + // bucket them as unsupported or onboarding would show 0 registerable servers. + const daemonSourced = daemonServers !== null const supported: DiscoveredMcpServer[] = [] const unsupportedRaw: DiscoveredMcpServer[] = [] for (const s of results) { @@ -100,7 +112,8 @@ export async function discoverMcpServers(opts?: { includeRaw?: boolean }): Promi } else if (hasMalformedHeaders(s.config)) { unsupportedRaw.push(s) } else if ('command' in s.config && s.config.command) { - unsupportedRaw.push(s) + if (daemonSourced) supported.push(s) + else unsupportedRaw.push(s) } else { supported.push(s) } diff --git a/src/main/index.ts b/src/main/index.ts index 372b143..022cf7e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -90,12 +90,15 @@ import { setSseStatusCallback } from './ipc/approvalsHandler' import { registerIpcHandlers } from './ipc/ipcHandlers' +import { bootstrapDetectord } from './detectord/bootstrap' +import { detectordPrimary } from './detectord/mode' import { buildAppMenu as buildAppMenuFromDeps } from './menus/appMenu' import { buildTrayMenuItems as buildTrayMenuItemsFromDeps } from './menus/trayMenu' import { integrateDesktopEntry } from './runtime/desktopIntegration' import { stageStdiodBinary } from './runtime/stdiodBinary' import { refreshStdiodStatusCache, startStdiodStatusCacheRefresh } from './stdiod/trayCache' import { uninstall as uninstallStdiod } from './stdiod/controller' +import { uninstallService as uninstallDetectord } from './detectord/controller' import { maybeRefreshStdiodInstall } from './stdiod/installRefresh' // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -292,6 +295,10 @@ async function handleClearDataAndRestart(): Promise { // wipe on it. Awaited so it finishes before app.exit(). await uninstallStdiod({ purge: true }).catch(() => {}) slog('[clear-data] Tore down stdiod daemon') + // Same for the detector daemon: purge removes the LaunchAgent plus all data + // (enrollment, seen-store, quarantine records, logs, socket). + await uninstallDetectord({ purge: true }).catch(() => {}) + slog('[clear-data] Tore down detector daemon') for (const file of CLEAR_DATA_FILES) { try { unlinkSync(join(userDataPath, file)) @@ -518,23 +525,35 @@ app.whenReady().then(async () => { // restart it onto the freshly shipped binary when the bundle changed. maybeRefreshStdiodInstall().catch((err) => console.error('[Stdiod] install refresh failed:', err)) + // Install + launch the detector daemon on EVERY client run (not gated on + // setup): the daemon owns detection/quarantine/install/hooks. Enrolls if + // credentials exist yet; otherwise the setup:complete handler enrolls on login. + bootstrapDetectord().catch((err) => console.error('[detectord] bootstrap failed:', err)) + if (isSetupComplete()) { slog('setup complete, creating tray') createTray() startEventSubscription() - startHookHealthMonitor() - // Await hook injection before quarantine monitor to avoid config file races - await injectAllHooks().catch((err) => console.error('[HookInjection] Failed:', err)) + // The TS hooks/quarantine pipeline stands down when the daemon is primary + // (it owns those). See detectord/mode.ts. + if (!detectordPrimary()) { + startHookHealthMonitor() + // Await hook injection before quarantine monitor to avoid config file races + await injectAllHooks().catch((err) => console.error('[HookInjection] Failed:', err)) + } await warmOrgIdCacheOnStartup() - startQuarantineMonitorIfEnabled().catch((err) => console.error('[Quarantine] Failed:', err)) - startQuarantinePolling() + if (!detectordPrimary()) { + startQuarantineMonitorIfEnabled().catch((err) => console.error('[Quarantine] Failed:', err)) + startQuarantinePolling() + } - // Self-heal: re-register edison-watch for any apps where the config was cleared externally + // Self-heal: re-register edison-watch for any apps where the config was + // cleared externally. Skipped when the daemon is primary — it owns install. const setup = getSetupData() const mcpBaseUrl = getMcpBaseUrl() const creds = getCredentialsForEnv() - if (mcpBaseUrl && creds?.apiKey) { + if (!detectordPrimary() && mcpBaseUrl && creds?.apiKey) { const rawApps = setup.configuredApps?.length ? setup.configuredApps : ALL_SUPPORTED_APPS let configuredApps = rawApps.filter((app) => ALL_SUPPORTED_APPS.includes(app)) diff --git a/src/main/ipc/ipcHandlers.ts b/src/main/ipc/ipcHandlers.ts index 9d13cb8..595e26d 100644 --- a/src/main/ipc/ipcHandlers.ts +++ b/src/main/ipc/ipcHandlers.ts @@ -28,6 +28,9 @@ import { removeVsCodeWorkspaceHook, getCodexConfigPath } from '../runtime/hookInjection' +import { bootstrapDetectord, setDetectordSecret } from '../detectord/bootstrap' +import { uninstallService as uninstallDetectord } from '../detectord/controller' +import { detectordPrimary } from '../detectord/mode' import { startHookHealthMonitor } from '../runtime/hookHealthMonitor' import { getUpdateState, @@ -165,12 +168,18 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { // Start background services startEventSubscription() - startHookHealthMonitor() - injectAllHooks().catch((err) => console.error('[HookInjection] Failed to inject hooks:', err)) - startQuarantineMonitorIfEnabled().catch((err) => - console.error('[Quarantine] Failed to start monitor after setup:', err) - ) - startQuarantinePolling() + // The TS install/hooks/quarantine pipeline stands down when the daemon is + // primary (it owns those). See detectord/mode.ts. + if (!detectordPrimary()) { + startHookHealthMonitor() + injectAllHooks().catch((err) => console.error('[HookInjection] Failed to inject hooks:', err)) + startQuarantineMonitorIfEnabled().catch((err) => + console.error('[Quarantine] Failed to start monitor after setup:', err) + ) + startQuarantinePolling() + } + // Install + enroll the detector daemon and mirror its work into the client logs. + bootstrapDetectord().catch((err) => console.error('[detectord] bootstrap failed:', err)) const win = getMainWindow() if (win) { @@ -196,6 +205,36 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { return { ok: true } }) + // Renderer pushes credentials right after sign-in so the daemon can enroll on + // login. A returning login keeps its API key only in the renderer's auth + // state (never persisted to the setup file), so app-ready's bootstrap can't + // read it — mirror stdiod.login and let the renderer hand them over. + ipcMain.handle( + 'detectord:enroll', + async ( + _event, + input: { apiUrl?: string; mcpUrl?: string; apiKey?: string; edisonSecretKey?: string } + ) => { + await bootstrapDetectord(input).catch((err) => + console.error('[detectord] enroll (push) failed:', err) + ) + return { ok: true } + } + ) + + // Register/adopt the org secret key when the user enters or changes it + // (OrgKeyCard). Explicit "enroll key" state change — separate from login. + ipcMain.handle('detectord:setSecret', async (_event, key: string) => + setDetectordSecret(key) + ) + + // Stop + remove the detector daemon. purge=true also deletes all its data + // (enrollment, seen-store, quarantine records, logs, socket). + ipcMain.handle('detectord:uninstall', async (_event, opts?: { purge?: boolean }) => { + const r = await uninstallDetectord(opts ?? {}) + return { ok: r.code === 0, stdout: r.stdout, stderr: r.stderr } + }) + // Verify a composite secret key against the ACTIVE-environment backend. // Runs in main so it always uses getCredentialsForEnv()/getApiBaseUrl() - // a renderer doing this could authenticate to the active env with a stale diff --git a/src/main/ipc/ipcHandlersMcpSubmit.ts b/src/main/ipc/ipcHandlersMcpSubmit.ts index f6c6132..fa48a20 100644 --- a/src/main/ipc/ipcHandlersMcpSubmit.ts +++ b/src/main/ipc/ipcHandlersMcpSubmit.ts @@ -15,6 +15,8 @@ const execFileAsync = promisify(execFile); import { discoverMcpServers, describeUnsupportedReason } from "../discovery/mcpDiscovery"; import type { DiscoveredMcpServer, McpClientId, McpServerConfig } from "../discovery/mcpDiscovery"; +import { detectordPrimary } from "../detectord/mode"; +import { submitServersViaDetectord, resubmitServerViaDetectord } from "../detectord/submit"; import { removeServerFromConfig } from "../runtime/mcpConfigActions"; import { quarantineCursorPlugin } from "../clients/cursor/quarantinePlugins"; import { @@ -172,6 +174,11 @@ export function registerMcpSubmitHandlers(): void { return { success: false, error: "Not signed in or server URL not configured." }; } + // Primary mode: resubmit-under-new-name is a daemon disposition with rename. + if (detectordPrimary()) { + return resubmitServerViaDetectord(params.originalName, params.newName, params.client ?? ""); + } + // Use cache first, fall back to passed config const { servers: cached, raw: cachedRaw } = getCachedDiscovery(); let server: DiscoveredMcpServer | undefined = cached.find((s) => s.name === params.originalName); @@ -241,6 +248,15 @@ export function registerMcpSubmitHandlers(): void { removed: string[]; errors: string[]; }> => { + // Primary mode: the daemon owns removal. Servers the user didn't send to EW + // are auto-quarantined once enforcement arms at setup:complete, so there's + // nothing for the client to remove here. + if (detectordPrimary()) { + const names = targets.map((t) => (typeof t === "string" ? t : t.name)); + console.log(`[detectord] removeServers no-op in primary (daemon quarantines when armed): ${names.join(", ")}`); + return { removed: [], errors: [] }; + } + // Use cached raw (pre-dedup) list so we find ALL per-agent instances const { servers: deduped, raw: filtered } = getCachedDiscovery(); const removed: string[] = []; @@ -428,6 +444,15 @@ export function registerMcpSubmitHandlers(): void { const allServers = deduplicateServers(cached); const skipSet = new Set(params.skipServers ?? []); const servers = skipSet.size > 0 ? allServers.filter((s) => !skipSet.has(s.name)) : allServers; + + // Primary mode: the daemon owns submit and auto-templatizes detected secrets, + // so the manual template overrides don't apply — route through the daemon. + if (detectordPrimary()) { + const summary = await submitServersViaDetectord(servers); + console.log(`[detectord] onboarding submit (templates ignored; daemon auto-templatizes): ${summary.submitted} submitted, ${summary.failures.length} failed`); + return summary; + } + const removalMap = buildRemovalMap(cachedRaw, servers); const serverList = servers.map((s) => ({ name: s.name, client: s.client, clients: s.clients, source: s.source })); @@ -563,6 +588,15 @@ export function registerMcpSubmitHandlers(): void { const allServers = deduplicateServers(cached); const skipSet = new Set(params?.skipServers ?? []); const servers = skipSet.size > 0 ? allServers.filter((s) => !skipSet.has(s.name)) : allServers; + + // Primary mode: the daemon owns submit (and handles stdio). Route each + // registration through it instead of the client's http-only submit path. + if (detectordPrimary()) { + const summary = await submitServersViaDetectord(servers); + console.log(`[detectord] onboarding submit: ${summary.submitted} submitted, ${summary.failures.length} failed`); + return summary; + } + const removalMap = buildRemovalMap(cachedRaw, servers); const serverList = servers.map((s) => ({ name: s.name, client: s.client, clients: s.clients, source: s.source })); diff --git a/src/main/runtime/monitorLog.ts b/src/main/runtime/monitorLog.ts index ecf20f8..a2e0de9 100644 --- a/src/main/runtime/monitorLog.ts +++ b/src/main/runtime/monitorLog.ts @@ -10,6 +10,7 @@ const RELEVANT_PREFIXES = [ '[SeenStore]', '[getCursorPluginMcpPaths]', '[claude-cli]', + '[detectord]', ] function shouldCapture(msg: string): boolean { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 703d1cb..8d33967 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -181,6 +181,16 @@ interface EdisonAPI { app: { clearDataAndRestart: () => Promise } + detectord: { + enroll: (input: { + apiUrl?: string + mcpUrl?: string + apiKey?: string + edisonSecretKey?: string + }) => Promise<{ ok: boolean }> + setSecret: (key: string) => Promise<{ ok: boolean }> + uninstall: (opts?: { purge?: boolean }) => Promise<{ ok: boolean; stdout: string; stderr: string }> + } stdiod: { status: () => Promise install: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a8d9e2..b1e48d1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -232,6 +232,23 @@ const api = { clearDataAndRestart: (): Promise => ipcRenderer.invoke('app:clearDataAndRestart') }, + /** Bundled edison-detectord daemon (MCP discovery/quarantine). */ + detectord: { + /** Push credentials after sign-in so the daemon enrolls on login. */ + enroll: (input: { + apiUrl?: string + mcpUrl?: string + apiKey?: string + edisonSecretKey?: string + }): Promise<{ ok: boolean }> => ipcRenderer.invoke('detectord:enroll', input), + /** Register/adopt the org secret key when the user enters or changes it. */ + setSecret: (key: string): Promise<{ ok: boolean }> => + ipcRenderer.invoke('detectord:setSecret', key), + /** Uninstall the daemon; purge=true also deletes all its data + logs. */ + uninstall: (opts?: { purge?: boolean }): Promise<{ ok: boolean; stdout: string; stderr: string }> => + ipcRenderer.invoke('detectord:uninstall', opts) + }, + /** Bundled edison-stdiod daemon (stdio MCP tunnel) */ stdiod: { status: (): Promise => ipcRenderer.invoke('stdiod:status'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index af008b6..4a194c3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,6 +39,26 @@ export default function App(): React.ReactNode { })(); }, []); + // Hand the daemon the credentials as soon as the user is signed in (Continue + // past the login window) so it enrolls right away. A returning login keeps + // its API key only in this renderer's auth state — main's setup file has no + // key for the active env, so app-ready's bootstrap can't do this on its own. + // Enroll is additive (agents union) and non-destructive (a missing secret + // keeps the existing one), so it's safe to (re-)send on every sign-in. Before + // app selection there are no configured agents yet, so this is a base enroll + // (url + key); onboarding's setup:complete adds the chosen agents. + useEffect(() => { + if (!auth.signedIn || !auth.apiKey || !auth.apiBaseUrl) return; + window.api.detectord + .enroll({ + apiUrl: auth.apiBaseUrl, + mcpUrl: auth.mcpBaseUrl, + apiKey: auth.apiKey, + edisonSecretKey: edisonSecretKey || undefined, + }) + .catch((err) => console.error("[App] detectord enroll push failed:", err)); + }, [auth.signedIn, auth.apiKey, auth.apiBaseUrl, auth.mcpBaseUrl, edisonSecretKey]); + // Windows: right-click in the app body opens the app menu (skip editable // fields/selections); the title bar keeps the OS system menu. useEffect(() => { diff --git a/src/renderer/src/components/OrgKeyCard.tsx b/src/renderer/src/components/OrgKeyCard.tsx index 7564ec2..00de96f 100644 --- a/src/renderer/src/components/OrgKeyCard.tsx +++ b/src/renderer/src/components/OrgKeyCard.tsx @@ -153,6 +153,14 @@ export default function OrgKeyCard({ // side effects) and mark the key active. cacheSecretKey(composite); await window.api.setup.update({ edisonSecretKey: composite }); + // Adopt the key into the detector daemon's enrollment (explicit "enroll + // key" state change). Non-fatal: the org key is already applied to the + // client configs above; the daemon will re-verify on its next enroll. + try { + await window.api.detectord.setSecret(composite); + } catch (err) { + console.error("[OrgKeyCard] detectord setSecret failed:", err); + } setSaved(true); setEditing(false); From f651675d99376302ba6559e546df29bf655c7d6a Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Fri, 10 Jul 2026 16:54:04 +0300 Subject: [PATCH 02/15] Remove x86-64 mac build for detectord and set mac.target to arm for electrion builder --- electron-builder.yml | 11 +++++------ scripts/build-detectord.sh | 39 ++++++++++++++++---------------------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index a99af66..d26ff91 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -62,10 +62,9 @@ nsis: mac: icon: resources/icon.icns category: public.app-category.developer-tools - # Bundle the edison-stdiod daemon (universal Mach-O staged by - # scripts/build-stdiod.sh) directly into Contents/Resources/bin/. - # electron-builder's mac.target=universal requires this binary to also - # be universal - see scripts/build-stdiod.sh for the lipo step. + # Bundle the edison-stdiod daemon directly into Contents/Resources/bin/. + # The .app targets arm64 (Apple Silicon) only; a universal bundled binary + # still works (its arm64 slice is used), but arm64-only staging is enough. # @electron/osx-sign signs nested executables in Contents/Resources # automatically when hardenedRuntime: true, inheriting entitlements # from entitlementsInherit, so no afterPack hook is needed here. @@ -75,10 +74,10 @@ mac: target: - target: dmg arch: - - universal + - arm64 - target: zip arch: - - universal + - arm64 entitlements: resources/entitlements.mac.plist entitlementsInherit: resources/entitlements.mac.plist hardenedRuntime: true diff --git a/scripts/build-detectord.sh b/scripts/build-detectord.sh index cc4cf0b..39e760b 100755 --- a/scripts/build-detectord.sh +++ b/scripts/build-detectord.sh @@ -1,15 +1,15 @@ #!/usr/bin/env bash -# Build the mcp_detector_daemon (edison-detectord) as a universal macOS binary -# and stage it into desktop/bin/ so electron-builder's mac.extraResources rule -# copies it into Contents/Resources/bin/ of the packaged .app. +# Build the mcp_detector_daemon (edison-detectord) for Apple Silicon (arm64) and +# stage it into desktop/bin/ so electron-builder's mac.extraResources rule copies +# it into Contents/Resources/bin/ of the packaged .app. # # Mirrors build-stdiod.sh. The daemon source is the sibling `detectord/` clone # (edison-client/detectord). The cargo binary is `mcp_detector_daemon`; we stage # it under the friendlier name `edison-detectord` (matching the stdiod naming). # -# Why universal: electron-builder.yml sets mac.target: universal, so every -# nested binary must also be universal or the merge fails; we build both arches -# and lipo them. +# arm64-only: we no longer build the x86_64 slice or lipo a universal binary. +# NOTE: if electron-builder.yml still sets mac.target: universal, the bundled +# binaries must match — target arm64 there too, or the universal merge fails. set -euo pipefail @@ -18,6 +18,7 @@ CLIENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$CLIENT_DIR/.." && pwd)" DETECTORD_DIR="$REPO_ROOT/detectord" BIN_NAME="mcp_detector_daemon" +TARGET="aarch64-apple-darwin" OUT_DIR="$CLIENT_DIR/bin" OUT_BIN="$OUT_DIR/edison-detectord" @@ -33,25 +34,17 @@ fi mkdir -p "$OUT_DIR" -for target in aarch64-apple-darwin x86_64-apple-darwin; do - if ! rustup target list --installed | grep -q "^${target}\$"; then - echo "Installing rustup target $target ..." - rustup target add "$target" - fi -done - -echo "Building $BIN_NAME for aarch64-apple-darwin ..." -( cd "$DETECTORD_DIR" && cargo build --release --bin "$BIN_NAME" --target aarch64-apple-darwin ) +if ! rustup target list --installed | grep -q "^${TARGET}\$"; then + echo "Installing rustup target $TARGET ..." + rustup target add "$TARGET" +fi -echo "Building $BIN_NAME for x86_64-apple-darwin ..." -( cd "$DETECTORD_DIR" && cargo build --release --bin "$BIN_NAME" --target x86_64-apple-darwin ) +echo "Building $BIN_NAME for $TARGET ..." +( cd "$DETECTORD_DIR" && cargo build --release --bin "$BIN_NAME" --target "$TARGET" ) -echo "Creating universal binary at $OUT_BIN ..." -lipo -create \ - "$DETECTORD_DIR/target/aarch64-apple-darwin/release/$BIN_NAME" \ - "$DETECTORD_DIR/target/x86_64-apple-darwin/release/$BIN_NAME" \ - -output "$OUT_BIN" +echo "Staging binary at $OUT_BIN ..." +cp "$DETECTORD_DIR/target/$TARGET/release/$BIN_NAME" "$OUT_BIN" chmod +x "$OUT_BIN" -echo "Verifying architectures ..." +echo "Verifying architecture ..." lipo -info "$OUT_BIN" From 0ffdb2228e513610aa6a6b438c20bc0fad0eca8a Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 09:07:38 +0300 Subject: [PATCH 03/15] Add fallback to check detectord enrorlment locally in case of backend fail --- src/main/detectord/bootstrap.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/detectord/bootstrap.ts b/src/main/detectord/bootstrap.ts index 0525f3f..51b5a46 100644 --- a/src/main/detectord/bootstrap.ts +++ b/src/main/detectord/bootstrap.ts @@ -68,7 +68,18 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< // the existing one). So we always (re-)enroll whenever credentials are // available rather than guarding on prior state; agent/key *additions* still // come through here (union) and removals go through unenroll. - if (!(await enrollDaemon(client, primary, creds))) return + if (!(await enrollDaemon(client, primary, creds))) { + // enroll didn't run or failed — no credentials yet, or a transient backend + // error (enroll hits the backend). The daemon may still be enrolled and + // running/enforcing from a prior session, so don't go blind: if it reports + // it's enrolled, fall through to subscribe + list so its quarantines still + // reach the UI. status() is a LOCAL IPC read (it reads the on-disk + // enrollment, no backend call), so it's reliable even during a backend + // outage. Only bail when there's genuinely no enrollment to observe. + const status = await client.status().catch(() => null) + if (!status?.enrolled) return + console.warn('[detectord] enroll did not run; observing already-enrolled daemon') + } if (!eventsSubscribed) { eventsSubscribed = true From 7c2d04cbba1e4e1e4679694d08c733499d4cd48d Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 09:26:33 +0300 Subject: [PATCH 04/15] move resubmitServerViaDetectord and submitServersViaDetectord inside try-catch --- src/main/detectord/submit.ts | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/main/detectord/submit.ts b/src/main/detectord/submit.ts index ff60d8d..213a2e9 100644 --- a/src/main/detectord/submit.ts +++ b/src/main/detectord/submit.ts @@ -42,7 +42,34 @@ export async function submitServersViaDetectord( servers: DiscoveredMcpServer[] ): Promise { const client = getDetectordClient() - await client.connect() + const serverList = servers.map((s) => ({ + name: s.name, + client: s.client, + clients: s.clients, + source: s.source + })) + try { + // connect() before status/disposition. On an unreachable daemon, return a + // summary with every server marked failed rather than throwing to the IPC + // handler — the caller renders per-server failures. + await client.connect() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + submitted: 0, + autoApproved: 0, + skipped: 0, + alreadyOnBackend: 0, + total: servers.length, + servers: serverList, + failures: servers.map((s) => ({ + name: s.name, + client: s.client, + reason: 'error' as const, + message + })) + } + } const status = await client.status().catch(() => null) const isAdminOrOwner = status?.role === 'admin' || status?.role === 'owner' @@ -76,12 +103,7 @@ export async function submitServersViaDetectord( skipped: 0, alreadyOnBackend: 0, total: servers.length, - servers: servers.map((s) => ({ - name: s.name, - client: s.client, - clients: s.clients, - source: s.source - })), + servers: serverList, failures } } @@ -93,8 +115,10 @@ export async function resubmitServerViaDetectord( client: string ): Promise<{ success: boolean; error?: string }> { const c = getDetectordClient() - await c.connect() try { + // connect() inside the try so an unreachable daemon fulfills the + // {success:false, error} contract instead of throwing to the IPC handler. + await c.connect() await c.disposition(name, 'send_to_ew', toAgent(client), newName) return { success: true } } catch (err) { From 0a743df0621262d431bc8f24967388a5ec3a4bf8 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 09:35:01 +0300 Subject: [PATCH 05/15] Clearing this.buf when the socket closes keeps reconnects from inheriting partial frames from the old stream. --- src/main/detectord/socket.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/detectord/socket.ts b/src/main/detectord/socket.ts index e066711..c611c6a 100644 --- a/src/main/detectord/socket.ts +++ b/src/main/detectord/socket.ts @@ -44,6 +44,9 @@ export class DetectordClient extends EventEmitter { if (this.connecting) return this.connecting this.connecting = new Promise((resolve, reject) => { + // A new byte stream must start clean — never inherit a partial frame left + // in the buffer by a previous (abruptly closed) connection. + this.buf = '' const sock = createConnection(this.socketPath) sock.setEncoding('utf8') sock.once('connect', () => { @@ -98,6 +101,10 @@ export class DetectordClient extends EventEmitter { private onClose(): void { this.socket = null + // Drop any partial frame from the dead stream so a reconnect doesn't prepend + // it to the next reply (which would corrupt + drop that reply, hanging its + // request until the following disconnect). + this.buf = '' const err = new DetectordError('daemon socket closed') while (this.pending.length) this.pending.shift()!.reject(err) } From 0ef581dba7150f44e8c4c4030825e753d5006caf Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 09:45:14 +0300 Subject: [PATCH 06/15] Fix template failure reason never reported --- src/preload/index.d.ts | 2 +- src/preload/index.ts | 2 +- src/renderer/src/components/OrgKeyCard.tsx | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 8d33967..e8884d4 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -188,7 +188,7 @@ interface EdisonAPI { apiKey?: string edisonSecretKey?: string }) => Promise<{ ok: boolean }> - setSecret: (key: string) => Promise<{ ok: boolean }> + setSecret: (key: string) => Promise<{ ok: boolean; reason?: string }> uninstall: (opts?: { purge?: boolean }) => Promise<{ ok: boolean; stdout: string; stderr: string }> } stdiod: { diff --git a/src/preload/index.ts b/src/preload/index.ts index b1e48d1..a43da7f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -242,7 +242,7 @@ const api = { edisonSecretKey?: string }): Promise<{ ok: boolean }> => ipcRenderer.invoke('detectord:enroll', input), /** Register/adopt the org secret key when the user enters or changes it. */ - setSecret: (key: string): Promise<{ ok: boolean }> => + setSecret: (key: string): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('detectord:setSecret', key), /** Uninstall the daemon; purge=true also deletes all its data + logs. */ uninstall: (opts?: { purge?: boolean }): Promise<{ ok: boolean; stdout: string; stderr: string }> => diff --git a/src/renderer/src/components/OrgKeyCard.tsx b/src/renderer/src/components/OrgKeyCard.tsx index 00de96f..5eff446 100644 --- a/src/renderer/src/components/OrgKeyCard.tsx +++ b/src/renderer/src/components/OrgKeyCard.tsx @@ -157,7 +157,13 @@ export default function OrgKeyCard({ // key" state change). Non-fatal: the org key is already applied to the // client configs above; the daemon will re-verify on its next enroll. try { - await window.api.detectord.setSecret(composite); + const res = await window.api.detectord.setSecret(composite); + if (!res?.ok) { + console.warn( + `[OrgKeyCard] detector daemon did not adopt the org key` + + `${res?.reason ? `: ${res.reason}` : ""} (will re-verify on next enroll)`, + ); + } } catch (err) { console.error("[OrgKeyCard] detectord setSecret failed:", err); } From d6eb5fec025c661bd2d65f88985e75d126de184b Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 09:56:40 +0300 Subject: [PATCH 07/15] fix detectord bootstrap race condition --- src/main/detectord/lifecycle.ts | 36 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/main/detectord/lifecycle.ts b/src/main/detectord/lifecycle.ts index 7425054..ecfdf30 100644 --- a/src/main/detectord/lifecycle.ts +++ b/src/main/detectord/lifecycle.ts @@ -9,6 +9,7 @@ import { detectordAvailable, installService } from './controller' let client: DetectordClient | null = null let installedThisSession = false +let installInFlight: Promise<{ ok: true } | { ok: false; reason: string }> | null = null /** The shared client (lazily created; connects on first request). */ export function getDetectordClient(): DetectordClient { @@ -41,18 +42,33 @@ export async function ensureDetectord( } // Install once per app session (the LaunchAgent bootstrap is a restart, so we // don't want to bounce it on every call — but we DO want it installed on every - // client run, which the unconditional caller guarantees). + // client run, which the unconditional caller guarantees). Serialized via a + // shared in-flight promise: concurrent bootstrap/enroll paths (app-ready + + // the login push, setup:complete, setSecret) must not both run `service + // install`, which would bounce the LaunchAgent under another caller. First + // caller runs it; the rest await the same promise. On failure it's cleared so + // a later call can retry. if (!installedThisSession) { - try { - const r = await installService(enforce) - slog(`[detectord] service install (enforce=${enforce}) exit=${r.code} ${r.stdout.trim()} ${r.stderr.trim()}`) - if (r.code !== 0) { - return { ok: false, reason: `service install failed (exit ${r.code}): ${r.stderr.trim() || r.stdout.trim()}` } + installInFlight ??= (async () => { + try { + const r = await installService(enforce) + slog(`[detectord] service install (enforce=${enforce}) exit=${r.code} ${r.stdout.trim()} ${r.stderr.trim()}`) + if (r.code !== 0) { + return { + ok: false as const, + reason: `service install failed (exit ${r.code}): ${r.stderr.trim() || r.stdout.trim()}` + } + } + installedThisSession = true + return { ok: true as const } + } catch (err) { + return { ok: false as const, reason: `service install error: ${String(err)}` } + } finally { + installInFlight = null } - installedThisSession = true - } catch (err) { - return { ok: false, reason: `service install error: ${String(err)}` } - } + })() + const res = await installInFlight + if (!res.ok) return { ok: false, reason: res.reason } } const c = getDetectordClient() From ef84cbbb0d18546bd02f3999feae2343f0856d3b Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 10:05:03 +0300 Subject: [PATCH 08/15] Fix failed Keep Quarantined disposition is shown as completed --- src/main/detectord/approvalDialog.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/detectord/approvalDialog.ts b/src/main/detectord/approvalDialog.ts index a0903a2..b916445 100644 --- a/src/main/detectord/approvalDialog.ts +++ b/src/main/detectord/approvalDialog.ts @@ -64,11 +64,13 @@ export function showDaemonApprovalDialog( if (req.skip) { try { await client.disposition(s.name, 'skip', s.agent) + console.log(`[Quarantine] disposition ${s.name} (${s.agent}) -> skip`) + return { ok: true } } catch (err) { - console.error(`[detectord] disposition skip ${s.name} failed: ${String(err)}`) + const message = err instanceof Error ? err.message : String(err) + console.error(`[detectord] disposition skip ${s.name} failed: ${message}`) + return { ok: false, message } } - console.log(`[Quarantine] disposition ${s.name} (${s.agent}) -> skip`) - return { ok: true } } try { await client.disposition(s.name, 'send_to_ew', s.agent, req.rename) @@ -224,8 +226,12 @@ function buildHtml(servers: ServerView[], isAdminOrOwner: boolean, channel: stri async function skip(item) { const idx = Number(item.dataset.index) item.querySelectorAll('button').forEach(b => b.disabled = true) - try { await ipcRenderer.invoke(CHANNEL, { index: idx, skip: true }) } catch (e) { /* keep quarantined */ } - markResolved(item, 'Kept quarantined') + let res + try { res = await ipcRenderer.invoke(CHANNEL, { index: idx, skip: true }) } + catch (e) { setMsg(item, String(e), 'error'); item.querySelectorAll('button').forEach(b => b.disabled = false); return } + if (res.ok) { markResolved(item, 'Kept quarantined'); return } + setMsg(item, res.message || 'Failed to keep quarantined.', 'error') + item.querySelectorAll('button').forEach(b => b.disabled = false) } document.querySelectorAll('.server-item').forEach(item => { From e289c5c64f5a0c9ad8849f4d48912843b8bd0ec2 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 10:09:59 +0300 Subject: [PATCH 09/15] contract-truthfulness fix --- src/main/detectord/bootstrap.ts | 13 ++++++++++--- src/main/ipc/ipcHandlers.ts | 7 ++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/detectord/bootstrap.ts b/src/main/detectord/bootstrap.ts index 51b5a46..d6b9fce 100644 --- a/src/main/detectord/bootstrap.ts +++ b/src/main/detectord/bootstrap.ts @@ -50,7 +50,13 @@ export interface DetectordEnrollInput { * additive). Without `creds` it reads persisted setup; enroll is skipped (with * a log) until credentials are available from either source. */ -export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise { +/** + * Returns whether the daemon ended up enrolled and being observed (subscribed + + * listed). `false` means we couldn't reach the daemon, or it isn't enrolled and + * this call didn't enroll it — so a caller (e.g. the `detectord:enroll` IPC) can + * surface a retry/error instead of assuming success. + */ +export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise { const primary = detectordPrimary() console.log( `[detectord] bootstrap mode=${primary ? 'primary (enforce)' : 'shadow (detect-only)'} ` + @@ -59,7 +65,7 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< const ensured = await ensureDetectord((m) => console.log(m), primary) if (!ensured.ok) { console.error(`[detectord] bootstrap skipped: ${ensured.reason}`) - return + return false } const client = ensured.client @@ -77,7 +83,7 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< // enrollment, no backend call), so it's reliable even during a backend // outage. Only bail when there's genuinely no enrollment to observe. const status = await client.status().catch(() => null) - if (!status?.enrolled) return + if (!status?.enrolled) return false console.warn('[detectord] enroll did not run; observing already-enrolled daemon') } @@ -87,6 +93,7 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< } await logInitialDetection(client) + return true } /** diff --git a/src/main/ipc/ipcHandlers.ts b/src/main/ipc/ipcHandlers.ts index 595e26d..193d6fd 100644 --- a/src/main/ipc/ipcHandlers.ts +++ b/src/main/ipc/ipcHandlers.ts @@ -215,10 +215,11 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { _event, input: { apiUrl?: string; mcpUrl?: string; apiKey?: string; edisonSecretKey?: string } ) => { - await bootstrapDetectord(input).catch((err) => + const ok = await bootstrapDetectord(input).catch((err) => { console.error('[detectord] enroll (push) failed:', err) - ) - return { ok: true } + return false + }) + return { ok } } ) From ae267fa12065b9445a459d912d4a0346df53f5c0 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 10:34:16 +0300 Subject: [PATCH 10/15] fix latent issue: preserve optional-agent --- src/main/detectord/submit.ts | 6 ++++-- src/main/ipc/ipcHandlersMcpSubmit.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/detectord/submit.ts b/src/main/detectord/submit.ts index 213a2e9..346291c 100644 --- a/src/main/detectord/submit.ts +++ b/src/main/detectord/submit.ts @@ -112,14 +112,16 @@ export async function submitServersViaDetectord( export async function resubmitServerViaDetectord( name: string, newName: string, - client: string + client?: string ): Promise<{ success: boolean; error?: string }> { const c = getDetectordClient() try { // connect() inside the try so an unreachable daemon fulfills the // {success:false, error} contract instead of throwing to the IPC handler. await c.connect() - await c.disposition(name, 'send_to_ew', toAgent(client), newName) + // No client → leave agent unspecified so the daemon matches by name alone + // (matches the pre-primary cache path). An empty string would match nothing. + await c.disposition(name, 'send_to_ew', client ? toAgent(client) : undefined, newName) return { success: true } } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err) } diff --git a/src/main/ipc/ipcHandlersMcpSubmit.ts b/src/main/ipc/ipcHandlersMcpSubmit.ts index fa48a20..c77bf64 100644 --- a/src/main/ipc/ipcHandlersMcpSubmit.ts +++ b/src/main/ipc/ipcHandlersMcpSubmit.ts @@ -175,8 +175,10 @@ export function registerMcpSubmitHandlers(): void { } // Primary mode: resubmit-under-new-name is a daemon disposition with rename. + // Pass client through as-is (may be undefined) so the daemon matches by name + // alone when it's omitted, preserving the optional-client contract. if (detectordPrimary()) { - return resubmitServerViaDetectord(params.originalName, params.newName, params.client ?? ""); + return resubmitServerViaDetectord(params.originalName, params.newName, params.client); } // Use cache first, fall back to passed config From 09d05b4a3684b10deb66cadf51a3e504086d46d7 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 10:37:28 +0300 Subject: [PATCH 11/15] Fix doesn't set --- src/main/detectord/socket.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/detectord/socket.ts b/src/main/detectord/socket.ts index c611c6a..6f7df20 100644 --- a/src/main/detectord/socket.ts +++ b/src/main/detectord/socket.ts @@ -24,7 +24,12 @@ import { } from './protocol' /** A daemon Reply that carried an { reply: 'error' } becomes a thrown Error. */ -export class DetectordError extends Error {} +export class DetectordError extends Error { + constructor(message?: string) { + super(message) + this.name = 'DetectordError' + } +} type Pending = { resolve: (r: Reply) => void; reject: (e: Error) => void } From 2956b26ede03f03e2addaa346065e92a339f6b15 Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 10:44:39 +0300 Subject: [PATCH 12/15] =?UTF-8?q?Fix=20=E2=80=94=20add=20outcome=20to=20pr?= =?UTF-8?q?eload=20declarations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/preload/index.d.ts | 3 ++- src/preload/index.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e8884d4..0e31f46 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,5 +1,6 @@ import type { ElectronAPI } from '@electron-toolkit/preload' +import type { SecretOutcome } from '../main/detectord/protocol' import type { StdiodLoginInput, StdiodResult, StdiodStatus } from '../main/stdiod/types' import type { UpdateState } from '../main/infra/updateManager' import type { UpdateSettings } from '../main/infra/updateSettings' @@ -188,7 +189,7 @@ interface EdisonAPI { apiKey?: string edisonSecretKey?: string }) => Promise<{ ok: boolean }> - setSecret: (key: string) => Promise<{ ok: boolean; reason?: string }> + setSecret: (key: string) => Promise<{ ok: boolean; outcome?: SecretOutcome; reason?: string }> uninstall: (opts?: { purge?: boolean }) => Promise<{ ok: boolean; stdout: string; stderr: string }> } stdiod: { diff --git a/src/preload/index.ts b/src/preload/index.ts index a43da7f..9a378ca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' +import type { SecretOutcome } from '../main/detectord/protocol' import type { StdiodLoginInput, StdiodResult, StdiodStatus } from '../main/stdiod/types' import type { UpdateState } from '../main/infra/updateManager' import type { UpdateSettings } from '../main/infra/updateSettings' @@ -242,7 +243,9 @@ const api = { edisonSecretKey?: string }): Promise<{ ok: boolean }> => ipcRenderer.invoke('detectord:enroll', input), /** Register/adopt the org secret key when the user enters or changes it. */ - setSecret: (key: string): Promise<{ ok: boolean; reason?: string }> => + setSecret: ( + key: string + ): Promise<{ ok: boolean; outcome?: SecretOutcome; reason?: string }> => ipcRenderer.invoke('detectord:setSecret', key), /** Uninstall the daemon; purge=true also deletes all its data + logs. */ uninstall: (opts?: { purge?: boolean }): Promise<{ ok: boolean; stdout: string; stderr: string }> => From c1fa6826d97eb057d58c03e22e85b1036b991d5c Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 10:52:53 +0300 Subject: [PATCH 13/15] Bundle detectord --- electron-builder.yml | 14 ++++++++------ package.json | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index d26ff91..a05222d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -62,15 +62,17 @@ nsis: mac: icon: resources/icon.icns category: public.app-category.developer-tools - # Bundle the edison-stdiod daemon directly into Contents/Resources/bin/. - # The .app targets arm64 (Apple Silicon) only; a universal bundled binary - # still works (its arm64 slice is used), but arm64-only staging is enough. - # @electron/osx-sign signs nested executables in Contents/Resources - # automatically when hardenedRuntime: true, inheriting entitlements - # from entitlementsInherit, so no afterPack hook is needed here. + # Bundle the edison-stdiod + edison-detectord daemons directly into + # Contents/Resources/bin/. The .app targets arm64 (Apple Silicon) only; a + # universal bundled binary still works (its arm64 slice is used), but arm64-only + # staging is enough. @electron/osx-sign signs nested executables in + # Contents/Resources automatically when hardenedRuntime: true, inheriting + # entitlements from entitlementsInherit, so no afterPack hook is needed here. extraResources: - from: bin/edison-stdiod to: bin/edison-stdiod + - from: bin/edison-detectord + to: bin/edison-detectord target: - target: dmg arch: diff --git a/package.json b/package.json index c499a91..70a27cb 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "build:release": "npm run typecheck && electron-vite build --mode release", "build:stdiod": "bash scripts/build-stdiod.sh", "build:detectord": "bash scripts/build-detectord.sh", - "build:mac": "npm run build:stdiod && npm run build:demo && electron-builder --mac", - "build:mac:release": "npm run build:stdiod && npm run build:release && electron-builder --mac", + "build:mac": "npm run build:stdiod && npm run build:detectord && npm run build:demo && electron-builder --mac", + "build:mac:release": "npm run build:stdiod && npm run build:detectord && npm run build:release && electron-builder --mac", "stage:python": "pwsh -NoProfile -File scripts/stage-python.ps1", "stage:runtimes": "pwsh -NoProfile -File scripts/stage-runtimes.ps1", "build:stdiod:win": "bash scripts/build-stdiod-win.sh", From 7b9b1a97316e90bdc2c7780c558782c16923c33e Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 11:01:54 +0300 Subject: [PATCH 14/15] fix em dashes --- scripts/build-detectord.sh | 2 +- scripts/check-ai-writing.ts | 2 +- src/main/detectord/approvalDialog.ts | 8 ++++---- src/main/detectord/binary.ts | 2 +- src/main/detectord/bootstrap.ts | 18 +++++++++--------- src/main/detectord/controller.ts | 4 ++-- src/main/detectord/discovery.ts | 4 ++-- src/main/detectord/lifecycle.ts | 8 ++++---- src/main/detectord/mode.ts | 4 ++-- src/main/detectord/socket.ts | 2 +- src/main/detectord/submit.ts | 2 +- src/main/discovery/mcpDiscovery.ts | 6 +++--- src/main/index.ts | 2 +- src/main/ipc/ipcHandlers.ts | 4 ++-- src/main/ipc/ipcHandlersMcpSubmit.ts | 2 +- src/renderer/src/App.tsx | 2 +- 16 files changed, 36 insertions(+), 36 deletions(-) diff --git a/scripts/build-detectord.sh b/scripts/build-detectord.sh index 39e760b..4ff0b01 100755 --- a/scripts/build-detectord.sh +++ b/scripts/build-detectord.sh @@ -9,7 +9,7 @@ # # arm64-only: we no longer build the x86_64 slice or lipo a universal binary. # NOTE: if electron-builder.yml still sets mac.target: universal, the bundled -# binaries must match — target arm64 there too, or the universal merge fails. +# binaries must match: target arm64 there too, or the universal merge fails. set -euo pipefail diff --git a/scripts/check-ai-writing.ts b/scripts/check-ai-writing.ts index 8fe7c99..4d650cc 100755 --- a/scripts/check-ai-writing.ts +++ b/scripts/check-ai-writing.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../.."); const SELF = resolve(fileURLToPath(import.meta.url)); const EM_DASH = "\u2014"; -const ROOT_SKIP = new Set([".git", ".venv", "node_modules", "dist", "build", "target", ".next", "coverage", ".cache"]); +const ROOT_SKIP = new Set([".git", ".venv", "node_modules", "dist", "build", "target", "out", "bin", ".next", "coverage", ".cache"]); const REC_SKIP = new Set(["__pycache__", "node_modules", "dist", "target", ".next"]); const SKIP_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".mp4", ".mov", ".mp3", ".woff", ".woff2", ".ttf", ".otf", ".pdf", ".zip", ".gz", ".bin", ".lock"]); diff --git a/src/main/detectord/approvalDialog.ts b/src/main/detectord/approvalDialog.ts index b916445..501426b 100644 --- a/src/main/detectord/approvalDialog.ts +++ b/src/main/detectord/approvalDialog.ts @@ -1,8 +1,8 @@ // Daemon-driven quarantine approval window. // // In primary mode the daemon auto-quarantines new servers and emits a -// `quarantine-prompt` event for each. The client's job is the human decision — -// send to Edison Watch (register/request) or keep quarantined — plus +// `quarantine-prompt` event for each. The client's job is the human decision: +// send to Edison Watch (register/request) or keep quarantined, plus // rename-on-conflict. A whole batch of newly-quarantined servers is shown in a // SINGLE window (one row each, with bulk actions), driving the daemon's // `disposition` op directly (the daemon owns config, submit, secret-templatizing, @@ -36,7 +36,7 @@ const toClientId = (agent: string): string => agent.replace(/_/g, '-') * Show one window listing every server in `servers` (a batch of newly * quarantined ones), each with Send-to-EW / Keep-quarantined + inline * rename-on-conflict. Resolves when the window closes. Only one window at a - * time — the caller batches; a second call while one is open is ignored. + * time. The caller batches; a second call while one is open is ignored. */ export function showDaemonApprovalDialog( client: DetectordClient, @@ -214,7 +214,7 @@ function buildHtml(servers: ServerView[], isAdminOrOwner: boolean, channel: stri if (res.ok) { markResolved(item, 'Sent to Edison Watch'); return } if (res.conflict) { item.querySelector('.rename-row').style.display = 'flex' - setMsg(item, (res.message || 'Name already taken') + ' — choose a different name.', 'error') + setMsg(item, (res.message || 'Name already taken') + '. Choose a different name.', 'error') item.querySelectorAll('button').forEach(b => b.disabled = false) item.querySelector('.rename-input').focus() return diff --git a/src/main/detectord/binary.ts b/src/main/detectord/binary.ts index 327a8c5..09ec030 100644 --- a/src/main/detectord/binary.ts +++ b/src/main/detectord/binary.ts @@ -8,7 +8,7 @@ import { app } from 'electron' // Packaged: Contents/Resources/bin/edison-detectord (staged by // scripts/build-detectord.sh, copied via mac.extraResources). Dev: the cargo // target dir in the sibling detectord/ clone, where the binary keeps its cargo -// name `mcp_detector_daemon` — run `cargo build --release` (or build-detectord.sh) +// name `mcp_detector_daemon`; run `cargo build --release` (or build-detectord.sh) // there once. export function getDetectordBinaryPath(): string { const win = process.platform === 'win32' diff --git a/src/main/detectord/bootstrap.ts b/src/main/detectord/bootstrap.ts index d6b9fce..eb39e9e 100644 --- a/src/main/detectord/bootstrap.ts +++ b/src/main/detectord/bootstrap.ts @@ -1,11 +1,11 @@ // Bootstrap the detector daemon: install + launch it (on every client run), // enroll it with the app's credentials, and mirror everything it does into the // client's log streams. In primary mode (the default) the daemon owns detection, -// quarantine, install, and hooks — the TS pipeline stands down. +// quarantine, install, and hooks; the TS pipeline stands down. // // Logging: we emit console.log with the SAME prefixes the TS pipeline uses // (`[Monitor]`, `[Quarantine]`, `[SeenStore]`) so monitorLog's tee captures them -// into /tmp/ew-monitor.log — the client "still produces the detection and +// into /tmp/ew-monitor.log, so the client "still produces the detection and // quarantine logs" even though the daemon is the one doing the work. Install / // enroll use a `[detectord]` prefix. @@ -44,7 +44,7 @@ export interface DetectordEnrollInput { } /** - * Install + enroll + start logging the daemon. Idempotent — safe to call + * Install + enroll + start logging the daemon. Idempotent, so safe to call * unconditionally on app-ready, on setup:complete, after account switches, and * from the renderer's post-login push (install is once-per-session; enroll is * additive). Without `creds` it reads persisted setup; enroll is skipped (with @@ -53,7 +53,7 @@ export interface DetectordEnrollInput { /** * Returns whether the daemon ended up enrolled and being observed (subscribed + * listed). `false` means we couldn't reach the daemon, or it isn't enrolled and - * this call didn't enroll it — so a caller (e.g. the `detectord:enroll` IPC) can + * this call didn't enroll it, so a caller (e.g. the `detectord:enroll` IPC) can * surface a retry/error instead of assuming success. */ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise { @@ -70,12 +70,12 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< const client = ensured.client // Enroll is safe to run on every login: it's additive (agents union with the - // existing set — never removed) and non-destructive (a missing secret keeps + // existing set, never removed) and non-destructive (a missing secret keeps // the existing one). So we always (re-)enroll whenever credentials are // available rather than guarding on prior state; agent/key *additions* still // come through here (union) and removals go through unenroll. if (!(await enrollDaemon(client, primary, creds))) { - // enroll didn't run or failed — no credentials yet, or a transient backend + // enroll didn't run or failed: no credentials yet, or a transient backend // error (enroll hits the backend). The daemon may still be enrolled and // running/enforcing from a prior session, so don't go blind: if it reports // it's enrolled, fall through to subscribe + list so its quarantines still @@ -99,7 +99,7 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< /** * Register/adopt the org secret key with the daemon when the user enters or * changes it (OrgKeyCard). `verify_secret` validates against the backend and - * adopts it into the enrollment — the explicit "enroll key" state change. The + * adopts it into the enrollment: the explicit "enroll key" state change. The * daemon must already be enrolled; if it isn't, this is a non-fatal no-op. */ export async function setDetectordSecret( @@ -132,7 +132,7 @@ async function enrollDaemon( const edisonSecretKey = override?.edisonSecretKey ?? stored?.edisonSecretKey const setup = getSetupData() if (!apiUrl || !apiKey) { - console.warn('[detectord] not enrolling — no api url / key yet') + console.warn('[detectord] not enrolling: no api url / key yet') return false } // Only the apps the user has actually configured. Empty (e.g. a new user who @@ -213,7 +213,7 @@ async function flushBatch(client: DetectordClient): Promise { if (dialogOpen || pendingBatch.length === 0) return const batch = pendingBatch.splice(0, pendingBatch.length) // Owners/admins register directly ("Add to Edison"); everyone else files a - // request. The daemon enforces this by role regardless — this is the label. + // request. The daemon enforces this by role regardless; this is the label. let isAdminOrOwner = false try { const status = await client.status() diff --git a/src/main/detectord/controller.ts b/src/main/detectord/controller.ts index 06e7796..b763d5a 100644 --- a/src/main/detectord/controller.ts +++ b/src/main/detectord/controller.ts @@ -22,7 +22,7 @@ export interface ServiceStatus { } // EDISON_DRY_RUN (Playwright/Storybook) short-circuits subprocess calls so test -// runs don't touch launchctl on the host — matching stdiod's controller. +// runs don't touch launchctl on the host, matching stdiod's controller. function dryRun(): boolean { return process.env.EDISON_DRY_RUN === '1' } @@ -47,7 +47,7 @@ export function detectordAvailable(): boolean { /** * Install + start the LaunchAgent. `enforce=false` (default) runs the daemon - * report-only — safe for first install; flip to true once wired up and trusted. + * report-only: safe for first install; flip to true once wired up and trusted. */ export async function installService(enforce = false): Promise { if (dryRun()) return { code: 0, stdout: '', stderr: '' } diff --git a/src/main/detectord/discovery.ts b/src/main/detectord/discovery.ts index b3af20f..74c5fd0 100644 --- a/src/main/detectord/discovery.ts +++ b/src/main/detectord/discovery.ts @@ -1,7 +1,7 @@ // Source MCP-server discovery from the daemon instead of re-scanning locally. // // In primary mode the daemon is the single source of truth for what's on the -// machine — and, unlike the client's own scan, it sees stdio servers. This maps +// machine, and unlike the client's own scan, it sees stdio servers. This maps // the daemon's `list_servers` into the client's DiscoveredMcpServer shape so the // existing onboarding/dedup/submit pipeline runs unchanged on the daemon's view. @@ -54,7 +54,7 @@ function toDiscovered(v: ServerView): DiscoveredMcpServer | null { /** * The daemon's discovered servers as DiscoveredMcpServer[], or `null` to signal - * "fall back to local discovery" — when the daemon is unreachable or not yet + * "fall back to local discovery" when the daemon is unreachable or not yet * enrolled (e.g. before login, when it has nothing to report anyway). */ export async function discoverViaDetectord(): Promise { diff --git a/src/main/detectord/lifecycle.ts b/src/main/detectord/lifecycle.ts index ecfdf30..1dcde49 100644 --- a/src/main/detectord/lifecycle.ts +++ b/src/main/detectord/lifecycle.ts @@ -1,6 +1,6 @@ // Detector-daemon lifecycle from the app's side: ensure the LaunchAgent is // installed (report-only) and hold a shared socket client. Mirrors how the app -// treats stdiod — the daemon is launchd-managed; we only orchestrate install + +// treats stdiod: the daemon is launchd-managed; we only orchestrate install + // connect. import { DetectordClient } from './socket' @@ -26,7 +26,7 @@ const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms /** * Ensure the daemon is installed (report-only by default) and the socket * connects. Returns a discriminated result so the caller can surface the actual - * failure. The connect is retried — after `service install`, launchd needs a + * failure. The connect is retried, since after `service install`, launchd needs a * moment to start the daemon and bind the socket. */ export async function ensureDetectord( @@ -37,11 +37,11 @@ export async function ensureDetectord( if (!detectordAvailable()) { return { ok: false, - reason: `daemon binary not found at ${binary} — run \`npm run build:detectord\` (or \`cargo build --release\` in detectord/).` + reason: `daemon binary not found at ${binary}; run \`npm run build:detectord\` (or \`cargo build --release\` in detectord/).` } } // Install once per app session (the LaunchAgent bootstrap is a restart, so we - // don't want to bounce it on every call — but we DO want it installed on every + // don't want to bounce it on every call, but we DO want it installed on every // client run, which the unconditional caller guarantees). Serialized via a // shared in-flight promise: concurrent bootstrap/enroll paths (app-ready + // the login push, setup:complete, setSecret) must not both run `service diff --git a/src/main/detectord/mode.ts b/src/main/detectord/mode.ts index ef07cc8..d26d9eb 100644 --- a/src/main/detectord/mode.ts +++ b/src/main/detectord/mode.ts @@ -1,8 +1,8 @@ // The single cutover switch. // -// primary — the detector daemon owns install + hooks + quarantine (runs +// primary: the detector daemon owns install + hooks + quarantine (runs // --enforce, enrolls install:true); the TS pipeline stands down. -// shadow — the daemon runs detect-only + logs; the TS pipeline stays primary. +// shadow: the daemon runs detect-only + logs; the TS pipeline stays primary. // // Reversible at any time: set EW_DETECTORD_PRIMARY=0 (or false/off) to fall back // to the TS pipeline, then restart the app. Default is primary (cutover active). diff --git a/src/main/detectord/socket.ts b/src/main/detectord/socket.ts index 6f7df20..0e8d990 100644 --- a/src/main/detectord/socket.ts +++ b/src/main/detectord/socket.ts @@ -49,7 +49,7 @@ export class DetectordClient extends EventEmitter { if (this.connecting) return this.connecting this.connecting = new Promise((resolve, reject) => { - // A new byte stream must start clean — never inherit a partial frame left + // A new byte stream must start clean; never inherit a partial frame left // in the buffer by a previous (abruptly closed) connection. this.buf = '' const sock = createConnection(this.socketPath) diff --git a/src/main/detectord/submit.ts b/src/main/detectord/submit.ts index 346291c..f9ce6a3 100644 --- a/src/main/detectord/submit.ts +++ b/src/main/detectord/submit.ts @@ -51,7 +51,7 @@ export async function submitServersViaDetectord( try { // connect() before status/disposition. On an unreachable daemon, return a // summary with every server marked failed rather than throwing to the IPC - // handler — the caller renders per-server failures. + // handler; the caller renders per-server failures. await client.connect() } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/src/main/discovery/mcpDiscovery.ts b/src/main/discovery/mcpDiscovery.ts index 07cd82b..c70284c 100644 --- a/src/main/discovery/mcpDiscovery.ts +++ b/src/main/discovery/mcpDiscovery.ts @@ -80,8 +80,8 @@ export async function discoverMcpServers(opts: { includeRaw: true }): Promise { // Primary mode: the daemon is the source of truth (and sees stdio servers the // client can't). Fall back to a local scan only when it returns null (daemon - // unreachable / not enrolled). The rest of the pipeline — shim-unwrap, - // supported/unsupported split, dedup, installed-app filter — runs unchanged. + // unreachable / not enrolled). The rest of the pipeline (shim-unwrap, + // supported/unsupported split, dedup, installed-app filter) runs unchanged. const daemonServers = detectordPrimary() ? await discoverViaDetectord() : null const results: DiscoveredMcpServer[] = daemonServers ?? (await Promise.all(CLIENT_LIST.map((c) => c.discoverServers()))).flat() @@ -101,7 +101,7 @@ export async function discoverMcpServers(opts?: { includeRaw?: boolean }): Promi // // Local stdio is "not yet supported" only for the *client's* http-proxy path. // When the list comes from the daemon (primary mode), the daemon can act on - // stdio servers, so they're supported (registerable via the daemon) — don't + // stdio servers, so they're supported (registerable via the daemon); don't // bucket them as unsupported or onboarding would show 0 registerable servers. const daemonSourced = daemonServers !== null const supported: DiscoveredMcpServer[] = [] diff --git a/src/main/index.ts b/src/main/index.ts index 022cf7e..b87973c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -549,7 +549,7 @@ app.whenReady().then(async () => { } // Self-heal: re-register edison-watch for any apps where the config was - // cleared externally. Skipped when the daemon is primary — it owns install. + // cleared externally. Skipped when the daemon is primary; it owns install. const setup = getSetupData() const mcpBaseUrl = getMcpBaseUrl() const creds = getCredentialsForEnv() diff --git a/src/main/ipc/ipcHandlers.ts b/src/main/ipc/ipcHandlers.ts index 193d6fd..3e7e57e 100644 --- a/src/main/ipc/ipcHandlers.ts +++ b/src/main/ipc/ipcHandlers.ts @@ -208,7 +208,7 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { // Renderer pushes credentials right after sign-in so the daemon can enroll on // login. A returning login keeps its API key only in the renderer's auth // state (never persisted to the setup file), so app-ready's bootstrap can't - // read it — mirror stdiod.login and let the renderer hand them over. + // read it, so mirror stdiod.login and let the renderer hand them over. ipcMain.handle( 'detectord:enroll', async ( @@ -224,7 +224,7 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { ) // Register/adopt the org secret key when the user enters or changes it - // (OrgKeyCard). Explicit "enroll key" state change — separate from login. + // (OrgKeyCard). Explicit "enroll key" state change, separate from login. ipcMain.handle('detectord:setSecret', async (_event, key: string) => setDetectordSecret(key) ) diff --git a/src/main/ipc/ipcHandlersMcpSubmit.ts b/src/main/ipc/ipcHandlersMcpSubmit.ts index c77bf64..61c12df 100644 --- a/src/main/ipc/ipcHandlersMcpSubmit.ts +++ b/src/main/ipc/ipcHandlersMcpSubmit.ts @@ -448,7 +448,7 @@ export function registerMcpSubmitHandlers(): void { const servers = skipSet.size > 0 ? allServers.filter((s) => !skipSet.has(s.name)) : allServers; // Primary mode: the daemon owns submit and auto-templatizes detected secrets, - // so the manual template overrides don't apply — route through the daemon. + // so the manual template overrides don't apply; route through the daemon. if (detectordPrimary()) { const summary = await submitServersViaDetectord(servers); console.log(`[detectord] onboarding submit (templates ignored; daemon auto-templatizes): ${summary.submitted} submitted, ${summary.failures.length} failed`); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4a194c3..09ec989 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -41,7 +41,7 @@ export default function App(): React.ReactNode { // Hand the daemon the credentials as soon as the user is signed in (Continue // past the login window) so it enrolls right away. A returning login keeps - // its API key only in this renderer's auth state — main's setup file has no + // its API key only in this renderer's auth state; main's setup file has no // key for the active env, so app-ready's bootstrap can't do this on its own. // Enroll is additive (agents union) and non-destructive (a missing secret // keeps the existing one), so it's safe to (re-)send on every sign-in. Before From ef813f7a8f8e81387bd84bfac98b4f8427a474df Mon Sep 17 00:00:00 2001 From: Dimitrios Karkoulis Date: Mon, 13 Jul 2026 11:07:24 +0300 Subject: [PATCH 15/15] fix large fsize --- src/main/ipc/ipcHandlersMcpSubmit.ts | 33 ++++++++++------------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/main/ipc/ipcHandlersMcpSubmit.ts b/src/main/ipc/ipcHandlersMcpSubmit.ts index 61c12df..06b40f9 100644 --- a/src/main/ipc/ipcHandlersMcpSubmit.ts +++ b/src/main/ipc/ipcHandlersMcpSubmit.ts @@ -39,13 +39,9 @@ import { getCachedOrgId, refreshOrgIdFromBackend } from "../infra/orgIdCache"; import { logClaudeCmd } from "../runtime/monitorLog"; /** - * Get the caller's org_id for seen-store writes. Uses the cache if populated; - * otherwise forces an inline refresh from the backend. - * - * Accepts explicit apiBaseUrl/apiKey so the ONBOARDING flow works - during - * onboarding getCredentialsForEnv() still returns null (keychain not written - * yet) but the caller already has the apiKey from IPC params or setup data. - * After onboarding both cached-creds and explicit-creds paths converge. + * Caller's org_id for seen-store writes: cached if warm, else an inline backend + * refresh. Explicit apiBaseUrl/apiKey lets onboarding work before + * getCredentialsForEnv() is populated (keychain not written yet). */ async function getOrRefreshOrgId( apiBaseUrl: string | null | undefined, @@ -105,13 +101,10 @@ function getCachedDiscovery() { } /** - * Hook for when a discovered server's fingerprint already exists on the backend. - * - * The submit step is skipped (the server is already there), but we still - * (a) update the seen-store so quarantine recognises it on the next rescan, - * and (b) remove the local agent-config entry so traffic flows through - * Edison Watch instead of the bypass path. `removalMap` is omitted in the - * single-server path; we then fall back to removing the discovered server itself. + * Fingerprint already on the backend: skip the submit, but still update the + * seen-store (so quarantine recognises it) and remove the local config entry so + * traffic flows through Edison Watch. `removalMap` omitted (single-server path) + * falls back to removing the discovered server itself. */ async function handleAlreadyOnBackend( server: DiscoveredMcpServer, @@ -464,10 +457,8 @@ export function registerMcpSubmitHandlers(): void { const errors: string[] = []; const failures: Array<{ name: string; client: string; reason: "conflict" | "error" | "already-on-backend"; message: string; config?: Record; configPath?: string; backendStatus?: "registered" | "requested" }> = []; - // Preflight: pull org's existing fingerprints so we can short-circuit - // servers that are already on the backend (registered or pending). The - // 409 path on the server only catches name collisions; we want - // "already exists" surfaced as a non-error state, not as a rename prompt. + // Preflight: pull org fingerprints to short-circuit servers already on the + // backend (registered/pending) as a non-error state, not a rename prompt. const backendIndex = await fetchBackendFingerprints(apiBaseUrl, apiKey); const role = await fetchUserRole(apiBaseUrl, apiKey); @@ -741,10 +732,8 @@ export function registerMcpSubmitHandlers(): void { config: params.config as McpServerConfig, }; - // Preflight: if the fingerprint is already on the backend, skip the - // submit (it's the same server) and just sync local state. This guards - // against the user double-acknowledging a quarantine dialog after the - // request was already filed in a previous session. + // Preflight: if the fingerprint is already on the backend, skip the submit + // (same server) and just sync local state, guarding a double-acknowledge. const backendIndex = await fetchBackendFingerprints(apiBaseUrl, apiKey); const backendMatch = findBackendFingerprintMatch(server, backendIndex); if (backendMatch) {