Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ dev-app-update.yml

# Claude Code
.claude/

# Local-only build helper (kept out of git)
/build-linux.yml
19 changes: 10 additions & 9 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,24 @@ 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.
# @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:
- universal
- arm64
- target: zip
arch:
- universal
- arm64
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
hardenedRuntime: true
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
"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: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:detectord": "bash scripts/build-detectord.sh",
Comment thread
dimitriosGX marked this conversation as resolved.
"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",
Expand Down
50 changes: 50 additions & 0 deletions scripts/build-detectord.sh
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"
2 changes: 1 addition & 1 deletion scripts/check-ai-writing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
258 changes: 258 additions & 0 deletions src/main/detectord/approvalDialog.ts
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>`
}
Loading
Loading