diff --git a/package-lock.json b/package-lock.json index 64046b2..b54a617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@sentry/electron": "^7.8.0", - "chokidar": "^5.0.0", "electron-updater": "^6.3.9", "electron-window-state": "^5.0.0", "eventsource": "^4.1.0", @@ -4963,21 +4962,6 @@ "node": ">= 16" } }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -8332,19 +8316,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", diff --git a/package.json b/package.json index e89d304..3918eaa 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@sentry/electron": "^7.8.0", - "chokidar": "^5.0.0", "electron-updater": "^6.3.9", "electron-window-state": "^5.0.0", "eventsource": "^4.1.0", diff --git a/src/main/__tests__/hookHealthMonitor.test.ts b/src/main/__tests__/hookHealthMonitor.test.ts deleted file mode 100644 index 91b3fb3..0000000 --- a/src/main/__tests__/hookHealthMonitor.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { tmpdir } from 'os' -import { join } from 'path' -import { promises as fs } from 'fs' - -// Mock electron -vi.mock('electron', () => ({ - app: { - getPath: (name: string) => { - if (name === 'userData') return join(tmpdir(), 'edison-test-userdata') - return join(tmpdir(), 'edison-test-' + name) - }, - getVersion: () => '1.0.0-test' - } -})) - -// Mock sentry -vi.mock('../infra/sentry', () => ({ - captureError: vi.fn() -})) - -// Mock setupConfig (imported by hookHealthMonitor for getMcpUrl / getIsServerOnline) -vi.mock('../infra/setupConfig', () => ({ - getMcpUrl: vi.fn().mockReturnValue(null), - getIsServerOnline: vi.fn().mockReturnValue(false) -})) - -// Mock hookInjection so we can control getHookStatus and getPendingErrorsDir -vi.mock('../runtime/hookInjection', () => ({ - getHookStatus: vi.fn().mockResolvedValue([ - { - client: 'claude-code', - installed: true, - hasHook: true, - hooksApplicable: true, - mcpApplicable: true, - hookCount: 4, - totalHooks: 4, - mcpConnected: false, - mcpConfigured: false - }, - { - client: 'cursor', - installed: true, - hasHook: false, - hooksApplicable: true, - mcpApplicable: true, - hookCount: 0, - totalHooks: 3, - mcpConnected: false, - mcpConfigured: false - }, - { - client: 'windsurf', - installed: false, - hasHook: false, - hooksApplicable: true, - mcpApplicable: true, - hookCount: 0, - totalHooks: 1, - mcpConnected: false, - mcpConfigured: false - } - ]), - getPendingErrorsDir: vi.fn().mockReturnValue(join(tmpdir(), 'edison-test-errors')), - getPendingRegistrationsDir: vi.fn().mockReturnValue(join(tmpdir(), 'edison-test-pending')), - getEdisonWatchDir: vi.fn().mockReturnValue(join(tmpdir(), 'edison-test-watchdir')) -})) - -import { - setOnHooksMissingCallback, - getHookStatusLabel, - startHookHealthMonitor, - stopHookHealthMonitor -} from '../runtime/hookHealthMonitor' - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -let testDir: string - -async function createTestDir(): Promise { - const dir = join(tmpdir(), 'hook-health-test-' + Date.now() + '-' + Math.random().toString(36)) - await fs.mkdir(dir, { recursive: true }) - return dir -} - -async function cleanupDir(dir: string): Promise { - try { - await fs.rm(dir, { recursive: true, force: true }) - } catch { - /* ignore */ - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -const pendingDir = join(tmpdir(), 'edison-test-pending') -const watchDir = join(tmpdir(), 'edison-test-watchdir') - -/** Poll until the file is gone or the timeout passes; returns whether it's gone. */ -async function waitForGone(filePath: string, timeoutMs = 3000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - await fs.access(filePath) - } catch { - return true - } - await new Promise((r) => setTimeout(r, 50)) - } - return false -} - -describe('hookHealthMonitor', () => { - beforeEach(async () => { - testDir = await createTestDir() - // Ensure the mock dirs exist for the watcher and sweeps - const errorsDir = join(tmpdir(), 'edison-test-errors') - await fs.mkdir(errorsDir, { recursive: true }) - await fs.mkdir(pendingDir, { recursive: true }) - await fs.mkdir(watchDir, { recursive: true }) - }) - - afterEach(async () => { - await stopHookHealthMonitor() - await cleanupDir(testDir) - const errorsDir = join(tmpdir(), 'edison-test-errors') - await cleanupDir(errorsDir) - await cleanupDir(pendingDir) - await cleanupDir(watchDir) - vi.restoreAllMocks() - }) - - describe('setOnHooksMissingCallback', () => { - it('accepts a callback function', () => { - const cb = vi.fn() - setOnHooksMissingCallback(cb) - // Should not throw - }) - }) - - describe('getHookStatusLabel', () => { - it('returns a string label', () => { - const label = getHookStatusLabel() - expect(typeof label).toBe('string') - expect(label.length).toBeGreaterThan(0) - }) - }) - - describe('startHookHealthMonitor / stopHookHealthMonitor', () => { - it('starts and stops without error', async () => { - startHookHealthMonitor() - // Give it a moment to initialize - await new Promise((r) => setTimeout(r, 100)) - await stopHookHealthMonitor() - }) - - it('stop is safe to call multiple times', async () => { - await stopHookHealthMonitor() - await stopHookHealthMonitor() - // Should not throw - }) - - it('drains pre-existing registration files from pending/ on startup', async () => { - const regFile = join(pendingDir, '20260101-120000-12345-claude-code.json') - await fs.writeFile( - regFile, - JSON.stringify({ projectPath: '/tmp/x', registeredBy: 'claude-code' }) - ) - - startHookHealthMonitor() - expect(await waitForGone(regFile)).toBe(true) - }) - - it('consumes registration files added while running', async () => { - startHookHealthMonitor() - await new Promise((r) => setTimeout(r, 200)) - - const regFile = join(pendingDir, '20260101-130000-54321-cursor.json') - await fs.writeFile(regFile, JSON.stringify({ projectPath: '/tmp/y', registeredBy: 'cursor' })) - expect(await waitForGone(regFile)).toBe(true) - }) - - it('still consumes session-end files', async () => { - startHookHealthMonitor() - await new Promise((r) => setTimeout(r, 200)) - - const endFile = join(pendingDir, '20260101-140000-11111-session-end.json') - await fs.writeFile( - endFile, - JSON.stringify({ event: 'session_end', conversation_id: 'abc', reason: 'exit' }) - ) - expect(await waitForGone(endFile)).toBe(true) - }) - - it('leaves in-flight dot-temp files alone', async () => { - startHookHealthMonitor() - await new Promise((r) => setTimeout(r, 200)) - - const tmpFile = join(pendingDir, '.20260101-150000-22222-claude-code.json.tmp') - await fs.writeFile(tmpFile, '{partial') - expect(await waitForGone(tmpFile, 1000)).toBe(false) - }) - - it('sweeps active_session files for dead PIDs and keeps live ones', async () => { - const { spawnSync } = await import('child_process') - // A process that has already exited - its PID is guaranteed dead - const deadPid = spawnSync('true').pid as number - const deadFile = join(watchDir, `active_session_${deadPid}.json`) - const aliveFile = join(watchDir, `active_session_${process.pid}.json`) - await fs.writeFile(deadFile, JSON.stringify({ session_id: 'dead' })) - await fs.writeFile(aliveFile, JSON.stringify({ session_id: 'alive' })) - - startHookHealthMonitor() - expect(await waitForGone(deadFile)).toBe(true) - // Live PID's file must survive - await expect(fs.access(aliveFile)).resolves.toBeUndefined() - }) - - it('detects missing hooks and triggers callback', async () => { - const cb = vi.fn() - setOnHooksMissingCallback(cb) - - startHookHealthMonitor() - - // Wait for the first status check to complete - await new Promise((r) => setTimeout(r, 200)) - - await stopHookHealthMonitor() - - // The mock has cursor installed but missing hook - callback should fire - if (cb.mock.calls.length > 0) { - const entries = cb.mock.calls[0]![0] - expect(Array.isArray(entries)).toBe(true) - const cursorEntry = entries.find((e: { client: string }) => e.client === 'cursor') - if (cursorEntry) { - expect(cursorEntry.installed).toBe(true) - expect(cursorEntry.hasHook).toBe(false) - } - } - }) - }) -}) diff --git a/src/main/__tests__/mcpConfigMonitor.test.ts b/src/main/__tests__/mcpConfigMonitor.test.ts deleted file mode 100644 index e5b3aaa..0000000 --- a/src/main/__tests__/mcpConfigMonitor.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { tmpdir } from "os"; -import { join } from "path"; -import { promises as fs } from "fs"; - -// Mock electron -vi.mock("electron", () => ({ - app: { - getPath: (name: string) => { - if (name === "userData") return join(tmpdir(), "edison-test-userdata"); - return join(tmpdir(), "edison-test-" + name); - }, - getVersion: () => "1.0.0-test", - }, -})); - -// Mock sentry -vi.mock("../infra/sentry", () => ({ - captureError: vi.fn(), -})); - -import { - McpConfigMonitor, - isEdisonWatchServer, - filterOutEdisonWatchServers, - getClientDisplayName, -} from "../runtime/mcpConfigMonitor"; -import { SeenServersStore } from "../discovery/seenServersStore"; -import type { DiscoveredMcpServer } from "../discovery/mcpDiscovery"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -let testDir: string; - -async function createTestDir(): Promise { - const dir = join( - tmpdir(), - "config-monitor-test-" + Date.now() + "-" + Math.random().toString(36), - ); - await fs.mkdir(dir, { recursive: true }); - return dir; -} - -async function cleanupDir(dir: string): Promise { - try { - await fs.rm(dir, { recursive: true, force: true }); - } catch { - /* ignore */ - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -describe("mcpConfigMonitor", () => { - beforeEach(async () => { - testDir = await createTestDir(); - }); - - afterEach(async () => { - await cleanupDir(testDir); - }); - - describe("isEdisonWatchServer", () => { - it("detects Edison Watch server by npx mcp-remote with edison args", () => { - const server: DiscoveredMcpServer = { - name: "gateway", - client: "cursor", - source: "user", - path: "/tmp/mcp.json", - config: { - command: "npx", - args: ["-y", "mcp-remote", "https://mcp.edison.watch/sse"], - }, - }; - expect(isEdisonWatchServer(server)).toBe(true); - }); - - it("detects Edison Watch server by URL containing 'edison'", () => { - const server: DiscoveredMcpServer = { - name: "my-proxy", - client: "cursor", - source: "user", - path: "/tmp/mcp.json", - config: { - type: "http", - url: "https://mcp.edison.watch/v1/sse", - } as never, - }; - expect(isEdisonWatchServer(server)).toBe(true); - }); - - it("detects Edison Watch server by localhost URL with /mcp path", () => { - const server: DiscoveredMcpServer = { - name: "local-proxy", - client: "cursor", - source: "user", - path: "/tmp/mcp.json", - config: { - type: "http", - url: "http://localhost:3000/mcp", - } as never, - }; - expect(isEdisonWatchServer(server)).toBe(true); - }); - - it("returns false for non-Edison servers", () => { - const server: DiscoveredMcpServer = { - name: "some-other-server", - client: "cursor", - source: "user", - path: "/tmp/mcp.json", - config: { command: "npx", args: ["-y", "some-server"] }, - }; - expect(isEdisonWatchServer(server)).toBe(false); - }); - }); - - describe("filterOutEdisonWatchServers", () => { - it("filters Edison servers from array", () => { - const servers: DiscoveredMcpServer[] = [ - { - name: "ew-proxy", - client: "cursor", - source: "user", - path: "/a", - config: { - type: "http", - url: "https://mcp.edison.watch/sse", - } as never, - }, - { - name: "other-server", - client: "cursor", - source: "user", - path: "/b", - config: { command: "other" }, - }, - ]; - const filtered = filterOutEdisonWatchServers(servers); - expect(filtered).toHaveLength(1); - expect(filtered[0]!.name).toBe("other-server"); - }); - - it("returns empty array when all servers are Edison", () => { - const servers: DiscoveredMcpServer[] = [ - { - name: "ew", - client: "cursor", - source: "user", - path: "/a", - config: { - command: "npx", - args: ["-y", "mcp-remote", "https://edison.watch/mcp/sse"], - }, - }, - ]; - expect(filterOutEdisonWatchServers(servers)).toHaveLength(0); - }); - }); - - describe("getClientDisplayName", () => { - it("returns human-readable names for known clients", () => { - expect(getClientDisplayName("vscode")).toBe("VS Code"); - expect(getClientDisplayName("cursor")).toBe("Cursor"); - expect(getClientDisplayName("claude-code")).toBe("Claude Code"); - expect(getClientDisplayName("windsurf")).toBe("Windsurf"); - expect(getClientDisplayName("zed")).toBe("Zed"); - }); - }); - - describe("McpConfigMonitor", () => { - it("creates monitor with SeenServersStore", () => { - const storePath = join(testDir, "seen.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store); - - expect(monitor).toBeDefined(); - expect(monitor.getCurrentServers()).toEqual([]); - expect(monitor.getMonitoredPaths()).toEqual([]); - }); - - it("detects new servers via forceRescan", async () => { - const storePath = join(testDir, "seen.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store); - - // Force a rescan - discoverMcpServers() scans system paths - const changes = await monitor.forceRescan(); - expect(Array.isArray(changes)).toBe(true); - }); - - it("detects removed servers after forceRescan", async () => { - const storePath = join(testDir, "seen-removed.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store); - - // First scan to establish baseline - await monitor.forceRescan(); - const before = monitor.getCurrentServers().length; - - // Second scan - no changes expected on empty system - const changes = await monitor.forceRescan(); - const after = monitor.getCurrentServers().length; - - // Verify deterministic behavior - expect(after).toBe(before); - expect(Array.isArray(changes)).toBe(true); - }); - - it("handles corrupt config files gracefully", async () => { - const storePath = join(testDir, "seen-corrupt.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store); - - const corruptPath = join(testDir, "corrupt.json"); - await fs.writeFile(corruptPath, "{{invalid json!!", "utf-8"); - - await monitor.addConfigPaths([corruptPath]); - - // Should not throw even with corrupt file - const changes = await monitor.forceRescan(); - expect(Array.isArray(changes)).toBe(true); - }); - - it("addConfigPaths requires monitor to be running", async () => { - const storePath = join(testDir, "seen-paths.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store); - - const p1 = join(testDir, "a.json"); - await fs.writeFile(p1, "{}", "utf-8"); - - // Monitor not started - addConfigPaths returns empty - const added = await monitor.addConfigPaths([p1]); - expect(added).toEqual([]); - expect(monitor.getMonitoredPaths()).not.toContain(p1); - }); - - it("stop is safe to call multiple times", async () => { - const storePath = join(testDir, "seen-stop.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store); - - await monitor.stop(); - await monitor.stop(); - // Should not throw - }); - - it("accepts custom rescan interval", async () => { - const storePath = join(testDir, "seen-rescan.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store, 500, 30_000); - - expect(monitor).toBeDefined(); - await monitor.stop(); - }); - - it("periodic rescan triggers checkForChanges", async () => { - const storePath = join(testDir, "seen-periodic.json"); - const store = new SeenServersStore(storePath); - // Use a very short rescan interval for testing (50ms) - const monitor = new McpConfigMonitor(store, 500, 50); - - // Spy on the private checkForChanges method directly - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const checkSpy = vi - .spyOn(monitor as any, "checkForChanges") - .mockResolvedValue([]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (monitor as any).isRunning = true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (monitor as any).startRescanTimer(); - - try { - // Wait for at least one interval tick - await new Promise((r) => setTimeout(r, 120)); - - await monitor.stop(); - - expect(checkSpy).toHaveBeenCalled(); - } finally { - checkSpy.mockRestore(); - } - }); - - it("stop clears rescan timer", async () => { - const storePath = join(testDir, "seen-stop-rescan.json"); - const store = new SeenServersStore(storePath); - const monitor = new McpConfigMonitor(store, 500, 100); - - // isRunning defaults to false, so the timer's guard prevents checkForChanges - // from running even if a tick fires before stop() clears the interval. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (monitor as any).startRescanTimer(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((monitor as any).rescanTimer).not.toBeNull(); - - await monitor.stop(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((monitor as any).rescanTimer).toBeNull(); - }); - }); -}); diff --git a/src/main/clients/types.ts b/src/main/clients/types.ts index a602301..7779fdd 100644 --- a/src/main/clients/types.ts +++ b/src/main/clients/types.ts @@ -52,7 +52,7 @@ export interface HookBinding { export interface DirWatchTarget { path: string - /** chokidar depth. Cursor's workspaceStorage uses depth:1; plugin cache depth:3. */ + /** Watch recursion depth. Cursor's workspaceStorage uses depth:1; plugin cache depth:3. */ depth: number /** Optional predicate on the changed path to decide whether to react. */ filter?: (path: string) => boolean @@ -60,7 +60,7 @@ export interface DirWatchTarget { } export interface WatchTargets { - /** Files watched directly (chokidar depth:0). Same entries as configEntries(). */ + /** Files watched directly (depth:0). Same entries as configEntries(). */ files: McpConfigEntry[] /** Directory watchers with depth > 0. */ dirs: DirWatchTarget[] diff --git a/src/main/clients/vscode/hooks.ts b/src/main/clients/vscode/hooks.ts index 36c95d8..e54fa73 100644 --- a/src/main/clients/vscode/hooks.ts +++ b/src/main/clients/vscode/hooks.ts @@ -101,7 +101,10 @@ export async function removeVsCodeCopilotHook(): Promise { return true } -// ── VS Code Workspace Task Hooks ──────────────────────────────────────────── +// ── VS Code Workspace Task (status detection only) ────────────────────────── +// The label + task shape are retained so getStatus() can detect a previously +// injected workspace task. Installing/removing the task is owned by the detector +// daemon; the client no longer writes .vscode/tasks.json. export const VSCODE_TASK_LABEL = 'Edison Watch Registration' @@ -118,73 +121,3 @@ export interface VsCodeTasksFile { version: string tasks: VsCodeTask[] } - -/** - * Inject an Edison Watch registration task into a VS Code workspace's .vscode/tasks.json. - * The task runs the hook script on folder open, discovering the workspace's MCP config - * for quarantine monitoring. - */ -export async function injectVsCodeWorkspaceHook(workspacePath: string): Promise { - const vscodePath = join(workspacePath, '.vscode') - const tasksPath = join(vscodePath, 'tasks.json') - const scriptPath = await ensureHookScript() - - let tasksFile: VsCodeTasksFile = { version: '2.0.0', tasks: [] } - - if (existsSync(tasksPath)) { - try { - const content = await fs.readFile(tasksPath, 'utf-8') - tasksFile = JSON.parse(content) as VsCodeTasksFile - } catch { - tasksFile = { version: '2.0.0', tasks: [] } - } - } - - if (!Array.isArray(tasksFile.tasks)) tasksFile.tasks = [] - - const alreadyExists = tasksFile.tasks.some((t) => t.label === VSCODE_TASK_LABEL) - if (alreadyExists) return false - - if (existsSync(tasksPath)) { - await fs.copyFile(tasksPath, `${tasksPath}.backup.${Date.now()}`) - } - - await fs.mkdir(vscodePath, { recursive: true }) - - tasksFile.tasks.push({ - label: VSCODE_TASK_LABEL, - type: 'shell', - command: `"${scriptPath}"`, - args: ['vscode'], - runOptions: { runOn: 'folderOpen' }, - presentation: { reveal: 'never', panel: 'shared' }, - }) - - await fs.writeFile(tasksPath, JSON.stringify(tasksFile, null, 2), 'utf-8') - console.log(`[HookInjection] Injected VS Code workspace hook into ${tasksPath}`) - return true -} - -/** - * Remove the Edison Watch registration task from a VS Code workspace's .vscode/tasks.json. - */ -export async function removeVsCodeWorkspaceHook(workspacePath: string): Promise { - const tasksPath = join(workspacePath, '.vscode', 'tasks.json') - if (!existsSync(tasksPath)) return false - - let tasksFile: VsCodeTasksFile - try { - const content = await fs.readFile(tasksPath, 'utf-8') - tasksFile = JSON.parse(content) as VsCodeTasksFile - } catch { - return false - } - - const before = tasksFile.tasks?.length ?? 0 - tasksFile.tasks = (tasksFile.tasks ?? []).filter((t) => t.label !== VSCODE_TASK_LABEL) - if (tasksFile.tasks.length === before) return false - - await fs.writeFile(tasksPath, JSON.stringify(tasksFile, null, 2), 'utf-8') - console.log(`[HookInjection] Removed VS Code workspace hook from ${tasksPath}`) - return true -} diff --git a/src/main/clients/vscode/index.ts b/src/main/clients/vscode/index.ts index 1851799..c2a87bc 100644 --- a/src/main/clients/vscode/index.ts +++ b/src/main/clients/vscode/index.ts @@ -9,9 +9,9 @@ * MCP discovery reads both the user `mcp.json` and the VS Code `state.vscdb` * which Extension API installs populate. * - * For this scaffolding PR, `hooks.inject` / `hooks.remove` only cover the - * Copilot hook - workspace task injection is per-workspace and orchestrated - * separately by `injectVsCodeWorkspaceHook`. + * `hooks.inject` / `hooks.remove` only cover the Copilot hook. Per-workspace + * task injection is owned by the detector daemon; the client only detects an + * existing workspace task (via `VSCODE_TASK_LABEL`) for status reporting. */ import { CLIENT_DISPLAY } from '../displayMeta' import type { ClientHookStatus, ClientIntegration, WatchTargets } from '../types' diff --git a/src/main/detectord/bootstrap.ts b/src/main/detectord/bootstrap.ts index 40df484..3b5ad4b 100644 --- a/src/main/detectord/bootstrap.ts +++ b/src/main/detectord/bootstrap.ts @@ -20,7 +20,6 @@ import { 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' @@ -65,7 +64,7 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< ) { return false } - const primary = detectordPrimary() + const primary = true console.log( `[detectord] bootstrap mode=${primary ? 'primary (enforce)' : 'shadow (detect-only)'} ` + `binary=${getDetectordBinaryPath()} available=${detectordBinaryExists()}` @@ -113,7 +112,7 @@ export async function bootstrapDetectord(creds?: DetectordEnrollInput): Promise< export async function setDetectordSecret( key: string ): Promise<{ ok: boolean; outcome?: SecretOutcome; reason?: string }> { - const ensured = await ensureDetectord((m) => console.log(m), detectordPrimary()) + const ensured = await ensureDetectord((m) => console.log(m), true) if (!ensured.ok) { console.warn(`[detectord] set secret skipped: ${ensured.reason}`) return { ok: false, reason: ensured.reason } diff --git a/src/main/detectord/mode.ts b/src/main/detectord/mode.ts deleted file mode 100644 index 232c2d6..0000000 --- a/src/main/detectord/mode.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - // The daemon ships for macOS (launchd), Windows (Task Scheduler), and Linux - // (systemd user service). Other platforms have no supervisor integration, so - // the TS pipeline stays primary there. - if ( - process.platform !== 'darwin' && - process.platform !== 'win32' && - process.platform !== 'linux' - ) { - return false - } - 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 index 56c5bd1..deb3ea9 100644 --- a/src/main/detectord/protocol.ts +++ b/src/main/detectord/protocol.ts @@ -20,7 +20,16 @@ export type Request = | { op: 'status'; refresh?: boolean } | { op: 'list_agents' } | { op: 'list_servers' } - | { op: 'disposition'; name: string; agent?: string; choice: Choice; rename?: string } + | { + op: 'disposition' + name: string + agent?: string + choice: Choice + rename?: string + /** For SendToEw: submit this (manually redacted) config instead of the + * daemon's discovered one, honoring the credential-review overrides. */ + submit_config?: ServerConfig + } | { op: 'refresh_policy' } | { op: 'verify_secret'; key: string } | { op: 'reset_secret'; key: string; confirm: boolean } diff --git a/src/main/detectord/socket.ts b/src/main/detectord/socket.ts index 0e8d990..dd74378 100644 --- a/src/main/detectord/socket.ts +++ b/src/main/detectord/socket.ts @@ -19,6 +19,7 @@ import { type Reply, type Request, type SecretOutcome, + type ServerConfig, type ServerView, type Status } from './protocol' @@ -165,8 +166,14 @@ export class DetectordClient extends EventEmitter { return r } - async disposition(name: string, choice: Choice, agent?: string, rename?: string): Promise { - await this.expect({ op: 'disposition', name, agent, choice, rename }) + async disposition( + name: string, + choice: Choice, + agent?: string, + rename?: string, + submitConfig?: ServerConfig + ): Promise { + await this.expect({ op: 'disposition', name, agent, choice, rename, submit_config: submitConfig }) } async verifySecret(key: string): Promise { diff --git a/src/main/detectord/submit.ts b/src/main/detectord/submit.ts index f9ce6a3..5aac871 100644 --- a/src/main/detectord/submit.ts +++ b/src/main/detectord/submit.ts @@ -5,13 +5,60 @@ // 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 type { DiscoveredMcpServer, McpServerConfig } from '../discovery/types' +import type { TemplateOverride } from '../discovery/mcpServerSubmit' import { getDetectordClient } from './lifecycle' +import type { ServerConfig } from './protocol' // Client ids use dashes (`claude-code`); daemon agent names use underscores. const toAgent = (client: string): string => client.replace(/-/g, '_') +/** + * Apply the credential-review overrides to a config, replacing each user-selected + * span with a `{varName}` placeholder. Mirrors submitServerWithOverrides so the + * daemon submit honors the same manual redactions the http path used to. + */ +function applyTemplateOverrides( + config: McpServerConfig, + overrides: TemplateOverride[] +): McpServerConfig { + const cloned = JSON.parse(JSON.stringify(config)) as Record + for (const ov of overrides) { + const [context, key] = ov.entryId.split(':', 2) + if (context === undefined || key === undefined) continue + const replaceInValue = (raw: string): string => + raw.slice(0, ov.start) + `{${ov.varName}}` + raw.slice(ov.end) + if (context === 'args') { + const idx = parseInt(key.match(/\d+/)?.[0] ?? '0', 10) + const args = cloned.args as string[] | undefined + if (args && args[idx] !== undefined) args[idx] = replaceInValue(args[idx]) + } else if (context === 'env') { + const env = cloned.env as Record | undefined + if (env && env[key] !== undefined) env[key] = replaceInValue(env[key]) + } else if (context === 'url') { + cloned.url = replaceInValue(String(cloned.url)) + } else if (context === 'headers') { + const headers = cloned.headers as Record | undefined + if (headers && headers[key] !== undefined) headers[key] = replaceInValue(headers[key]) + } + } + return cloned as McpServerConfig +} + +/** Map a client config into the daemon's wire ServerConfig, or null if unmappable (opaque). */ +function toDaemonSubmitConfig(config: McpServerConfig): ServerConfig | null { + if ('command' in config && config.command) { + return { Stdio: { command: config.command, args: config.args ?? [], env: config.env ?? {} } } + } + if ('url' in config && config.url) { + return { + Http: { url: config.url, headers: config.headers ?? {}, kind: config.type === 'sse' ? 'Sse' : 'Http' } + } + } + return null +} + export interface DetectordSubmitFailure { name: string client: string @@ -39,7 +86,8 @@ export interface DetectordSubmitSummary { * carrying the config so onboarding can offer the rename-resubmit flow. */ export async function submitServersViaDetectord( - servers: DiscoveredMcpServer[] + servers: DiscoveredMcpServer[], + overrides?: Record ): Promise { const client = getDetectordClient() const serverList = servers.map((s) => ({ @@ -77,8 +125,23 @@ export async function submitServersViaDetectord( let autoApproved = 0 const failures: DetectordSubmitFailure[] = [] for (const s of servers) { + // Client-side dedup renames name-conflicting servers (e.g. name "sqlite_cursor", + // originalName "sqlite"). The daemon only knows the discovered (original) name, + // so submit under that and pass the deduped name as the disposition rename - + // mirroring resubmitServerViaDetectord. Non-conflicting servers have no + // originalName, so daemonName === s.name and rename stays undefined. + const daemonName = s.originalName ?? s.name + const rename = s.originalName ? s.name : undefined + // An explicitly provided overrides array is the user's complete, authoritative + // redaction set from credential review (even an empty array means "nothing + // here is a secret" - the daemon must submit it verbatim, not auto-templatize). + // Absent (undefined) => no review for this server => let the daemon + // auto-templatize its discovered config. + const ov = overrides?.[s.name] + const submitConfig = + ov !== undefined ? (toDaemonSubmitConfig(applyTemplateOverrides(s.config, ov)) ?? undefined) : undefined try { - await client.disposition(s.name, 'send_to_ew', toAgent(s.client)) + await client.disposition(daemonName, 'send_to_ew', toAgent(s.client), rename, submitConfig) submitted++ if (isAdminOrOwner) autoApproved++ } catch (err) { diff --git a/src/main/dialogs/debugWindow.ts b/src/main/dialogs/debugWindow.ts index 615ab28..3063fe6 100644 --- a/src/main/dialogs/debugWindow.ts +++ b/src/main/dialogs/debugWindow.ts @@ -170,10 +170,6 @@ function buildDebugHtml(servers: DiscoveredMcpServer[], projectPathsHtml: string

Debug Actions

-