This repository was archived by the owner on Jul 24, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
New detectors daemon for detection and quarantine #29
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
5ad5f8e
New detectors daemon for detection and quarantine
dimitriosGX f651675
Remove x86-64 mac build for detectord and set mac.target to arm for e…
dimitriosGX 0ffdb22
Add fallback to check detectord enrorlment locally in case of backend…
dimitriosGX 7c2d04c
move resubmitServerViaDetectord and submitServersViaDetectord inside …
dimitriosGX 0a743df
Clearing this.buf when the socket closes keeps reconnects from inheri…
dimitriosGX 0ef581d
Fix template failure reason never reported
dimitriosGX d6eb5fe
fix detectord bootstrap race condition
dimitriosGX ef84cbb
Fix failed Keep Quarantined disposition is shown as completed
dimitriosGX e289c5c
contract-truthfulness fix
dimitriosGX ae267fa
fix latent issue: preserve optional-agent
dimitriosGX 09d05b4
Fix doesn't set
dimitriosGX 2956b26
Fix — add outcome to preload declarations
dimitriosGX c1fa682
Bundle detectord
dimitriosGX 7b9b1a9
fix em dashes
dimitriosGX ef813f7
fix large fsize
dimitriosGX File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,3 +44,6 @@ dev-app-update.yml | |
|
|
||
| # Claude Code | ||
| .claude/ | ||
|
|
||
| # Local-only build helper (kept out of git) | ||
| /build-linux.yml | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| #!/usr/bin/env bash | ||
| # 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). | ||
| # | ||
| # 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 | ||
|
|
||
| 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" | ||
| TARGET="aarch64-apple-darwin" | ||
| 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" | ||
|
|
||
| if ! rustup target list --installed | grep -q "^${TARGET}\$"; then | ||
| echo "Installing rustup target $TARGET ..." | ||
| rustup target add "$TARGET" | ||
| fi | ||
|
|
||
| echo "Building $BIN_NAME for $TARGET ..." | ||
| ( cd "$DETECTORD_DIR" && cargo build --release --bin "$BIN_NAME" --target "$TARGET" ) | ||
|
|
||
| echo "Staging binary at $OUT_BIN ..." | ||
| cp "$DETECTORD_DIR/target/$TARGET/release/$BIN_NAME" "$OUT_BIN" | ||
| chmod +x "$OUT_BIN" | ||
|
|
||
| echo "Verifying architecture ..." | ||
| lipo -info "$OUT_BIN" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| // 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<void> { | ||
| 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<void>((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) | ||
| console.log(`[Quarantine] disposition ${s.name} (${s.agent}) -> skip`) | ||
| return { ok: true } | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err) | ||
| console.error(`[detectord] disposition skip ${s.name} failed: ${message}`) | ||
| return { ok: false, message } | ||
| } | ||
| } | ||
| 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 ` | ||
| <div class="server-item" data-index="${index}"> | ||
| <div class="server-header"> | ||
| <div class="server-name"><strong>${escapeHtml(server.name)}</strong></div> | ||
| <div class="server-source"> | ||
| <span class="client-icon">${clientIcon}</span> | ||
| <span class="client-name">${escapeHtml(clientName)}</span> | ||
| </div> | ||
| </div> | ||
| <div class="server-info">${serverInfoLine(server)}</div> | ||
| <div class="msg" style="display:none"></div> | ||
| <div class="rename-row" style="display:none"> | ||
| <input type="text" class="rename-input" maxlength="32" placeholder="New name (a-z, 0-9, _)" /> | ||
| <button class="button button-request resubmit">Resubmit</button> | ||
| </div> | ||
| <div class="server-actions"> | ||
| <button class="button button-request send">${escapeHtml(primaryLabel)}</button> | ||
| <button class="button button-dismiss skip">Keep Quarantined</button> | ||
| </div> | ||
| </div>` | ||
| } | ||
|
|
||
| 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 `<!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <title>MCP Servers Quarantined</title> | ||
| <style> | ||
| ${BASE_CSS} | ||
| ${HEADER_CSS} | ||
| ${SERVER_CARD_CSS} | ||
| ${BUTTON_CSS} | ||
| ${REGISTRATION_CSS} | ||
| .rename-row { display:flex; gap:6px; margin-top:8px; } | ||
| .rename-input { flex:1; min-width:0; padding:4px 8px; border-radius:4px; border:1px solid var(--border,#333); background:var(--bg-input,#1a1a1a); color:var(--text-primary,#eee); font-size:12px; outline:none; } | ||
| .msg { font-size:11px; margin-top:8px; } | ||
| .msg.error { color: var(--danger,#e53e3e); } | ||
| .msg.done { color: var(--success,#38a169); } | ||
| .server-item.resolved { opacity:0.55; } | ||
| .header-actions { display:flex; gap:8px; margin-top:8px; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="header"> | ||
| <h1>MCP Servers Quarantined <span class="count">(${servers.length})</span></h1> | ||
| <div class="header-actions"> | ||
| <button class="button button-bulk" id="send-all">${escapeHtml(bulkLabel)}</button> | ||
| <button class="button button-bulk" id="skip-all">Keep all quarantined</button> | ||
| </div> | ||
| </div> | ||
| ${rows} | ||
| <script> | ||
| const { ipcRenderer } = require('electron') | ||
| const CHANNEL = ${JSON.stringify(channel)} | ||
| let remaining = ${servers.length} | ||
|
|
||
| function rowOf(el) { return el.closest('.server-item') } | ||
| function setMsg(item, text, cls) { | ||
| const m = item.querySelector('.msg') | ||
| m.textContent = text | ||
| m.className = 'msg ' + (cls || '') | ||
| m.style.display = text ? 'block' : 'none' | ||
| } | ||
| function markResolved(item, text) { | ||
| item.classList.add('resolved') | ||
| item.querySelector('.server-actions').style.display = 'none' | ||
| item.querySelector('.rename-row').style.display = 'none' | ||
| setMsg(item, text, 'done') | ||
| remaining -= 1 | ||
| if (remaining <= 0) setTimeout(() => window.close(), 600) | ||
| } | ||
|
|
||
| async function send(item, rename) { | ||
| const idx = Number(item.dataset.index) | ||
| item.querySelectorAll('button').forEach(b => b.disabled = true) | ||
| let res | ||
| try { res = await ipcRenderer.invoke(CHANNEL, rename ? { index: idx, rename } : { index: idx }) } | ||
| catch (e) { setMsg(item, String(e), 'error'); item.querySelectorAll('button').forEach(b => b.disabled = false); return } | ||
| 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') | ||
| item.querySelectorAll('button').forEach(b => b.disabled = false) | ||
| item.querySelector('.rename-input').focus() | ||
| return | ||
| } | ||
| setMsg(item, res.message || 'Failed to send to Edison Watch.', 'error') | ||
| item.querySelectorAll('button').forEach(b => b.disabled = false) | ||
| } | ||
|
|
||
| async function skip(item) { | ||
| const idx = Number(item.dataset.index) | ||
| item.querySelectorAll('button').forEach(b => b.disabled = true) | ||
| 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 => { | ||
| item.querySelector('.send').addEventListener('click', () => send(item)) | ||
| item.querySelector('.skip').addEventListener('click', () => skip(item)) | ||
| item.querySelector('.resubmit').addEventListener('click', () => { | ||
| const v = (item.querySelector('.rename-input').value || '').trim() | ||
| if (!/^[a-z0-9_]{1,32}$/.test(v)) { setMsg(item, 'Use 1-32 chars: a-z, 0-9, underscore.', 'error'); return } | ||
| send(item, v) | ||
| }) | ||
| }) | ||
|
|
||
| document.getElementById('send-all').addEventListener('click', function () { | ||
| this.disabled = true | ||
| document.querySelectorAll('.server-item:not(.resolved) .send').forEach(b => b.click()) | ||
| }) | ||
| document.getElementById('skip-all').addEventListener('click', function () { | ||
| this.disabled = true | ||
| document.querySelectorAll('.server-item:not(.resolved) .skip').forEach(b => b.click()) | ||
| }) | ||
| </script> | ||
| </body> | ||
| </html>` | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.