From 6640d424490121fb6ff1941e95b833eaa338ed19 Mon Sep 17 00:00:00 2001 From: Variet Worker Date: Tue, 17 Mar 2026 18:48:46 +0900 Subject: [PATCH] refactor(extension): split extension.ts into 3 modules - http-bridge, html-patcher, command-handler (#398) --- .agents/references/architecture.md | 48 +- docs/devlog/2026-03-17.md | 1 + extension/src/command-handler.ts | 189 ++++++++ extension/src/extension.ts | 712 ++--------------------------- extension/src/html-patcher.ts | 291 ++++++++++++ extension/src/http-bridge.ts | 367 +++++++++++++++ extension/src/step-probe.ts | 8 +- 7 files changed, 928 insertions(+), 688 deletions(-) create mode 100644 extension/src/command-handler.ts create mode 100644 extension/src/html-patcher.ts create mode 100644 extension/src/http-bridge.ts diff --git a/.agents/references/architecture.md b/.agents/references/architecture.md index 05f535f..3433055 100644 --- a/.agents/references/architecture.md +++ b/.agents/references/architecture.md @@ -49,10 +49,16 @@ gravity_control/ │ ├── ── Extension 측 (TypeScript) ── ├── extension/src/ -│ ├── extension.ts # 메인: SDK init, activate, HTTP bridge (1,290줄) -│ ├── step-probe.ts # 폴링 + 응답 처리 + 승인 전략 (1,435줄) +│ ├── extension.ts # 메인: SDK init, activate, 오케스트레이션 (650줄) +│ ├── step-probe.ts # 폴링 + 응답 처리 + 승인 전략 (1,479줄) │ │ # setupMonitor(), processResponseFile(), │ │ # writePendingApproval(), tryApprovalStrategies() +│ ├── http-bridge.ts # HTTP 서버 (Renderer↔Extension Host 통신) (280줄) +│ │ # startHttpBridge(), getDeterministicPort() +│ ├── html-patcher.ts # AG HTML 패치 + product.json 체크섬 (280줄) +│ │ # setupApprovalObserver(), updateProductChecksums() +│ ├── command-handler.ts # Discord→AG 명령어 처리 (175줄) +│ │ # watchCommandsDir(), handleWSCommand() │ ├── observer-script.ts # DOM Observer 스크립트 생성 (698줄) │ │ # generateApprovalObserverScript() │ ├── ws-client.ts # WSBridgeClient: Hub 연결, 재연결, 큐 (505줄) @@ -122,17 +128,49 @@ gravity_control/ | Hub 연동 | on_hub_pending, on_hub_chat, on_hub_register 핸들러 | | IDLE 알림 | AI step 종료 시 Discord 알림 | -### 3.4 Extension (extension.ts) — VS Code 확장 +### 3.4 Extension (extension.ts) — VS Code 확장 (오케스트레이터) | 기능 | 설명 | |------|------| | AG SDK 연동 | getCascadeStatus, getAllTrajectories RPC | | 세션 감지 | activeSessionId 자동 추적 | | 프로젝트 자동 감지 | git remote URL 기반 | -| HTTP bridge | 파일 기반 pending/response 읽기쓰기 (레거시) | +| 모듈 초기화 | HTTP bridge, observer, command handler 시작 | | WS bridge | WSBridgeClient 통한 Hub 연결 (우선) | | Status bar | SDK 상태 + 연결 상태 표시 | +### 3.4a HTTP Bridge (http-bridge.ts) + +`HttpBridgeContext` 인터페이스로 extension.ts의 공유 상태 참조: + +| 기능 | 설명 | +|------|------| +| POST /pending | Renderer가 발견한 승인 버튼 보고 | +| GET /response/:rid | Renderer가 Discord 응답 폴링 | +| GET /trigger-click | Extension→Renderer 클릭 트리거 | +| GET/POST /deep-inspect* | DOM 심층 검사 | +| getDeterministicPort | 프로젝트명 기반 결정적 포트 | + +### 3.4b HTML Patcher (html-patcher.ts) + +| 기능 | 설명 | +|------|------| +| setupApprovalObserver | AG Workbench HTML 파일에 observer 스크립트 인라인 삽입 | +| updateProductChecksums | product.json SHA256 체크섬 업데이트 (vscode-file:// 프로토콜용) | +| CSP 패치 | script-src에 'unsafe-inline' 추가 | +| .orig 백업 | 최초 패치 전 원본 백업, 손상 시 자동 복구 | + +### 3.4c Command Handler (command-handler.ts) + +`CommandHandlerContext` 인터페이스로 extension.ts 상태 참조: + +| 기능 | 설명 | +|------|------| +| watchCommandsDir | commands/ 디렉토리 fs.watch + 3s 폴링 | +| handleWSCommand | WS Hub 경유 명령어 처리 | +| !stop, !auto | AG 에이전트 제어 명령어 | +| 텍스트 전달 | Discord → AG `sendPromptToAgentPanel` | + ### 3.5 Step Probe (step-probe.ts) — 상태 폴링 `BridgeContext` 인터페이스로 extension.ts와 상태 공유: @@ -146,7 +184,7 @@ gravity_control/ | setupResponseWatcher | response/ 디렉토리 파일 감시 | **BridgeContext 필드** (14개): -`bridgePath`, `projectName`, `sdk`, `wsBridge`, `logToFile`, `autoApproveEnabled`, `activeSessionId`, `sentPendingIds`, `deterministicPort`, `recentDiscordSentTexts`, `writeChatSnapshot`, `writeChatSnapshotWithFiles`, `workspaceUri`, `diffReviewMetadata` +`bridgePath`, `projectName`, `sdk`, `wsBridge`, `logToFile`, `autoApproveEnabled`, `activeSessionId`, `setClickTrigger`, `recentDiscordSentTexts`, `writeChatSnapshot`, `writeChatSnapshotWithFiles`, `workspaceUri`, `diffReviewMetadata`, `sessionStalled`, `lastPendingStepIndex`, `stallProbed`, `sawRunningAfterPending` ### 3.6 WS Client (ws-client.ts) — Hub 클라이언트 diff --git a/docs/devlog/2026-03-17.md b/docs/devlog/2026-03-17.md index bf1d92c..b6f393b 100644 --- a/docs/devlog/2026-03-17.md +++ b/docs/devlog/2026-03-17.md @@ -6,6 +6,7 @@ | 010 | 06:50~07:39 | 문서 전면 재작성 + 서버 배포 + WS 호환 수정 | `6ea3211` | ✅ | | 011 | 07:44~08:18 | VSIX v0.4.0 E2E 사전 검증 + WS 프록시 수정 | — | 🔧 | | 012 | 09:00~17:44 | VSIX E2E: workspaceUri, 이중발송, ApprovalRequest, ApprovalView WS, 응답 라우팅 | `2eea5fa` | ✅ | +| 013 | 18:05~18:45 | Extension 모듈 분리 #398: http-bridge, html-patcher, command-handler 추출 (1296→650줄) | `2d8266e` | ✅ | ### #010 상세 - **문서**: architecture.md(250줄), tech-stack.md(100줄), conventions.md(100줄) 전면 재작성 + Wiki 동기화 diff --git a/extension/src/command-handler.ts b/extension/src/command-handler.ts new file mode 100644 index 0000000..420a5fb --- /dev/null +++ b/extension/src/command-handler.ts @@ -0,0 +1,189 @@ +/** + * Command Handler — Discord → AG command processing. + * + * Extracted from extension.ts to reduce file size. + * Handles commands from both: + * - File-based bridge (legacy/fallback): fs.watch + polling + * - WebSocket Hub (primary): direct callbacks + */ + +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +// ─── Context interface (shared state from extension.ts) ─── + +export interface CommandHandlerContext { + bridgePath: string; + projectName: string; + sdk: any; + autoApproveEnabled: boolean; + logToFile: (msg: string) => void; + /** Called when auto-approve is toggled; extension.ts updates its own state */ + onAutoApproveChanged: (enabled: boolean) => void; + /** Track recently sent Discord→AG texts to avoid echo relay */ + recentDiscordSentTexts: Map; +} + +// ─── File-based command watcher ─── + +let commandsWatcher: fs.FSWatcher | null = null; + +/** + * Watch the commands directory for Discord→AG command files. + * Uses fs.watch + polling fallback (fs.watch unreliable on Windows). + */ +export function watchCommandsDir(ctx: CommandHandlerContext) { + const cmdDir = path.join(ctx.bridgePath, 'commands'); + + // Process existing files + const processAllCommands = () => { + try { + for (const f of fs.readdirSync(cmdDir)) { + if (f.endsWith('.json')) { + _processCommandFile(path.join(cmdDir, f), ctx); + } + } + } catch { } + }; + + processAllCommands(); + + // Watch for new files (may not fire reliably on Windows) + try { + commandsWatcher = fs.watch(cmdDir, (event, filename) => { + if (filename && filename.endsWith('.json') && event === 'rename') { + const fp = path.join(cmdDir, filename); + if (fs.existsSync(fp)) { + setTimeout(() => _processCommandFile(fp, ctx), 200); + } + } + }); + } catch { } + + // Polling fallback: fs.watch on Windows can silently fail + setInterval(() => { + processAllCommands(); + }, 3000); +} + +/** Dispose the file watcher on deactivate */ +export function disposeCommandsWatcher() { + if (commandsWatcher) { + commandsWatcher.close(); + commandsWatcher = null; + } +} + +// ─── WebSocket command handler ─── + +/** + * Handle a command received via WebSocket Hub. + * Same logic as file-based processing but without file I/O. + */ +export function handleWSCommand(ctx: CommandHandlerContext, data: { text?: string; action?: string; project_name?: string }) { + const text = data.text || ''; + if (!text) return; + + // Project filtering (WS already routes by project, but double-check) + if (data.project_name && data.project_name !== ctx.projectName) { + ctx.logToFile(`[WS-CMD] Ignoring command for ${data.project_name} (we are ${ctx.projectName})`); + return; + } + + if (text === '!stop') { + ctx.logToFile('[WS-CMD] !stop — cancelling AG task'); + if (ctx.sdk) { + try { ctx.sdk.cascade.cancelCurrentTask(); } catch { } + } + return; + } + + if (text.startsWith('!auto')) { + const parts = text.split(' '); + const enabled = parts[1] !== 'off'; + ctx.onAutoApproveChanged(enabled); + ctx.logToFile(`[WS-CMD] auto_approve=${enabled}`); + return; + } + + // General text → send as user message to AG + ctx.logToFile(`[WS-CMD] Sending text to AG: ${text.substring(0, 80)}`); + if (ctx.sdk) { + try { + ctx.sdk.cascade.sendPrompt(text); + } catch (e: any) { + ctx.logToFile(`[WS-CMD] SDK sendPrompt error: ${e.message}`); + } + } +} + +// ─── Private ─── + +function _processCommandFile(filePath: string, ctx: CommandHandlerContext) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const cmd = JSON.parse(content); + + // Skip already consumed commands + if (cmd.consumed) { + try { fs.unlinkSync(filePath); } catch { } + return; + } + + // Ignore commands for other projects + if (cmd.project_name && cmd.project_name !== ctx.projectName) { + console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${ctx.projectName}")`); + return; + } + + // Bot writes 'text' field, not 'message' + const text = cmd.text || cmd.message || ''; + const action = cmd.action || ''; + + console.log(`Gravity Bridge: command — text="${text}" action="${action}"`); + + if (action === 'approve' && ctx.sdk) { + ctx.sdk.cascade.acceptStep().catch((e: any) => + console.log(`Gravity Bridge: approve error: ${e.message}`) + ); + } else if (action === 'reject' && ctx.sdk) { + ctx.sdk.cascade.rejectStep().catch((e: any) => + console.log(`Gravity Bridge: reject error: ${e.message}`) + ); + } else if (action === 'approve_terminal' && ctx.sdk) { + ctx.sdk.cascade.acceptTerminalCommand().catch((e: any) => + console.log(`Gravity Bridge: approve_terminal error: ${e.message}`) + ); + } else if (text === '!stop') { + // Cancel current operation + vscode.commands.executeCommand('antigravity.agent.rejectAgentStep') + .then(() => console.log('Gravity Bridge: ✅ stop sent'), + () => { }); + } else if (text.startsWith('!auto')) { + // Auto-approve mode toggle + let enabled: boolean; + if (text === '!auto on') { + enabled = true; + } else if (text === '!auto off') { + enabled = false; + } else { + // Toggle if no explicit on/off + enabled = !ctx.autoApproveEnabled; + } + ctx.onAutoApproveChanged(enabled); + ctx.logToFile(`[AUTO] auto-approve toggled → ${enabled}`); + } else if (text) { + // Send message to Antigravity — use VS Code command (most reliable) + ctx.recentDiscordSentTexts.set(text.trim(), Date.now()); + vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text) + .then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`), + (e: any) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`)); + } + + // Remove processed command file + try { fs.unlinkSync(filePath); } catch { } + } catch (e: any) { + console.log(`Gravity Bridge: command processing error: ${e.message}`); + } +} diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 3138972..cc61b96 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -15,10 +15,11 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as cp from 'child_process'; -import * as crypto from 'crypto'; import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client'; -import { generateApprovalObserverScript } from './observer-script'; import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState } from './step-probe'; +import { startHttpBridge, getDeterministicPort, HttpBridgeContext } from './http-bridge'; +import { setupApprovalObserver } from './html-patcher'; +import { watchCommandsDir, handleWSCommand, disposeCommandsWatcher, CommandHandlerContext } from './command-handler'; // ─── File-based logging (AI can read directly) ─── function logToFile(msg: string) { @@ -46,9 +47,7 @@ let projectName: string; let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering) let isActive = false; let autoApproveEnabled = false; // toggled via !auto from Discord -let deterministicPort = 0; // derived from projectName, consistent across restarts let watcher: fs.FSWatcher | null = null; -let commandsWatcher: fs.FSWatcher | null = null; let wsBridge: WSBridgeClient | null = null; // WebSocket Hub connection const sentPendingIds = new Set(); @@ -171,107 +170,7 @@ function writeChatSnapshotWithFiles(text: string, files: Array<{name: string, co } -// ─── Command File Watcher (Discord → Antigravity) ─── - -function processCommandFile(filePath: string) { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const cmd = JSON.parse(content); - - // Skip already consumed commands - if (cmd.consumed) { - try { fs.unlinkSync(filePath); } catch { } - return; - } - - // Ignore commands for other projects - if (cmd.project_name && cmd.project_name !== projectName) { - console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${projectName}")`); - return; - } - - // Bot writes 'text' field, not 'message' - const text = cmd.text || cmd.message || ''; - const action = cmd.action || ''; - - console.log(`Gravity Bridge: command — text="${text}" action="${action}"`); - - if (action === 'approve' && sdk) { - sdk.cascade.acceptStep().catch((e: any) => - console.log(`Gravity Bridge: approve error: ${e.message}`) - ); - } else if (action === 'reject' && sdk) { - sdk.cascade.rejectStep().catch((e: any) => - console.log(`Gravity Bridge: reject error: ${e.message}`) - ); - } else if (action === 'approve_terminal' && sdk) { - sdk.cascade.acceptTerminalCommand().catch((e: any) => - console.log(`Gravity Bridge: approve_terminal error: ${e.message}`) - ); - } else if (text === '!stop') { - // Cancel current operation - vscode.commands.executeCommand('antigravity.agent.rejectAgentStep') - .then(() => console.log('Gravity Bridge: ✅ stop sent'), - () => { }); - } else if (text.startsWith('!auto')) { - // Auto-approve mode toggle - if (text === '!auto on') { - autoApproveEnabled = true; - } else if (text === '!auto off') { - autoApproveEnabled = false; - } else { - // Toggle if no explicit on/off - autoApproveEnabled = !autoApproveEnabled; - } - logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`); - } else if (text) { - // Send message to Antigravity — use VS Code command (most reliable) - recentDiscordSentTexts.set(text.trim(), Date.now()); - vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text) - .then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`), - (e: any) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`)); - } - - // Remove processed command file - try { fs.unlinkSync(filePath); } catch { } - } catch (e: any) { - console.log(`Gravity Bridge: command processing error: ${e.message}`); - } -} - -function watchCommandsDir() { - const cmdDir = path.join(bridgePath, 'commands'); - - // Process existing files - const processAllCommands = () => { - try { - for (const f of fs.readdirSync(cmdDir)) { - if (f.endsWith('.json')) { - processCommandFile(path.join(cmdDir, f)); - } - } - } catch { } - }; - - processAllCommands(); - - // Watch for new files (may not fire reliably on Windows) - try { - commandsWatcher = fs.watch(cmdDir, (event, filename) => { - if (filename && filename.endsWith('.json') && event === 'rename') { - const fp = path.join(cmdDir, filename); - if (fs.existsSync(fp)) { - setTimeout(() => processCommandFile(fp), 200); - } - } - }); - } catch { } - - // Polling fallback: fs.watch on Windows can silently fail - setInterval(() => { - processAllCommands(); - }, 3000); -} +// ─── Command handling extracted to ./command-handler.ts ─── // ─── SDK Integration ─── @@ -454,579 +353,17 @@ async function fixLSConnection(): Promise { } } -// ─── Approval Observer via SDK IntegrationManager ─── +// ─── Approval Observer + Product.json Checksums extracted to ./html-patcher.ts ─── -async function setupApprovalObserver() { - if (!sdk) { logToFile('[OBSERVER] no SDK'); return; } - try { - const integration = sdk.integration; - if (!integration) { logToFile('[OBSERVER] sdk.integration unavailable'); return; } +// ─── HTTP Bridge Server extracted to ./http-bridge.ts ─── - // 1. Start HTTP bridge server in Extension Host - const bridgePort = await startObserverHttpBridge(); - if (!bridgePort) { logToFile('[OBSERVER] HTTP bridge failed'); return; } +// Shared state for HTTP bridge context (module-level, referenced by BridgeContext too) +let sessionStalled = false; +let lastPendingStepIndex = -1; +let stallProbed = false; +let sawRunningAfterPending = true; - // 2. Register a TOP_BAR button so build() works - try { - integration.register({ - id: 'gravity_bridge_status', - point: 'topBar', - icon: '🌉', - tooltip: 'Gravity Bridge Active', - }); - } catch { /* already registered */ } - - // 3. Write renderer script with HTTP fetch() approach - const observerJS = generateApprovalObserverScript(bridgePort); - const patcher = (integration as any)._patcher; - if (patcher && typeof patcher.getScriptPath === 'function') { - let baseScript = ''; - try { baseScript = integration.build(); } catch { baseScript = ''; } - const combinedScript = baseScript + '\n' + observerJS; - const scriptPath = patcher.getScriptPath(); - fs.writeFileSync(scriptPath, combinedScript, 'utf8'); - logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`); - if (!integration.isInstalled()) { - patcher.install(combinedScript); - logToFile('[OBSERVER] patcher.install() called (needs reload)'); - } - - // Patch BOTH HTML files with inline script injection. - // CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404), - // so we MUST inline the script directly into BOTH HTML files. - // workbench.html — loaded by DevTools/standard mode - // workbench-jetski-agent.html — loaded by AG agent mode - const scriptDir = path.dirname(scriptPath); - // Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable: - // workbench.html → workbench.desktop.main.css + workbench.js - // workbench-jetski-agent.html → tw-base.tailwind.css + jetskiMain.tailwind.css + jetskiAgent.js - // Cross-restoring between them causes CSS to not load → layout broken (elements visible but all shifted left). - const htmlFileSpecs = [ - { - name: 'workbench.html', - requiredMarker: 'workbench.desktop.main.css', // CSS unique to this file - requiredScript: 'workbench.js', // JS entry point - }, - { - name: 'workbench-jetski-agent.html', - requiredMarker: 'jetskiMain.tailwind.css', // CSS unique to this file - requiredScript: 'jetskiAgent.js', // JS entry point - }, - ]; - // ── FIX #1: File lock to prevent multi-instance HTML patching race ── - const lockFile = path.join(scriptDir, '.patch-lock'); - let lockAcquired = false; - try { - if (fs.existsSync(lockFile)) { - const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs; - if (lockAge < 30_000) { - logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge/1000)}s) — skipping`); - return; // Exit setupApprovalObserver entirely - } - logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge/1000)}s) — force-acquiring`); - } - fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8'); - lockAcquired = true; - } catch (lockErr: any) { - logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`); - } - - for (const spec of htmlFileSpecs) { - const htmlPath = path.join(scriptDir, spec.name); - const backupPath = htmlPath + '.orig'; - try { - if (!fs.existsSync(htmlPath)) { - logToFile(`[OBSERVER] ${spec.name} not found — skipping`); - continue; - } - let html = fs.readFileSync(htmlPath, 'utf8'); - - // ── BACKUP: Save original before first-ever patch ── - // Only backup if the file looks valid AND hasn't been backed up yet. - if (!fs.existsSync(backupPath) - && html.length >= 500 - && html.includes('') - && html.includes(spec.requiredMarker)) { - fs.writeFileSync(backupPath, html, 'utf8'); - logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`); - } - - // ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ── - // Race condition: another extension instance may be mid-write (0-byte). - // Wrong type: restored from the other HTML file (different CSS/JS refs). - const isCorrupt = html.length < 500 || !html.includes(''); - const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker); - - if (isCorrupt || isWrongType) { - const reason = isCorrupt - ? `corrupt/empty (${html.length} bytes)` - : `wrong type (missing ${spec.requiredMarker})`; - logToFile(`[OBSERVER] ${spec.name} detected ${reason}`); - - // Try to restore from backup - if (fs.existsSync(backupPath)) { - const backup = fs.readFileSync(backupPath, 'utf8'); - if (backup.length >= 500 - && backup.includes('') - && backup.includes(spec.requiredMarker)) { - fs.writeFileSync(htmlPath, backup, 'utf8'); - html = backup; - logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`); - } else { - logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`); - continue; - } - } else { - logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`); - continue; - } - } - - // CRITICAL: Patch CSP to allow inline scripts. - // Default CSP has script-src 'self' 'unsafe-eval' blob: — NO 'unsafe-inline'. - // Without 'unsafe-inline', all inline \n${inlineMarkerEnd}`); - logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`); - } else { - html = html.replace('', - `\n${inlineMarkerStart}\n\n${inlineMarkerEnd}\n`); - logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`); - } - // SAFETY: Final validation before write - if (html.length < 500 || !html.includes('') || !html.includes(spec.requiredMarker)) { - logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`); - continue; - } - fs.writeFileSync(htmlPath, html, 'utf8'); - } catch (e: any) { - logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`); - } - } - - // Release patch lock - if (lockAcquired) { - try { fs.unlinkSync(lockFile); } catch { } - logToFile('[OBSERVER] patch lock released'); - } - } - - // 4. Update product.json checksums so vscode-file:// serves our patched files - updateProductChecksums(); - - try { integration.enableAutoRepair(); } catch { } - setInterval(() => { try { integration.signalActive(); } catch { } }, 30_000); - - logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`); - console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`); - } catch (err: any) { - logToFile(`[OBSERVER] setup error: ${err.message}`); - } -} - -// ─── Product.json Checksum Auto-Update ─── -// vscode-file:// protocol validates SHA256 checksums in product.json. -// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version. -// This function recalculates checksums for files we modify (HTML files with \n${inlineMarkerEnd}`); + logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`); + } else { + html = html.replace('', + `\n${inlineMarkerStart}\n\n${inlineMarkerEnd}\n`); + logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`); + } + // SAFETY: Final validation before write + if (html.length < 500 || !html.includes('') || !html.includes(spec.requiredMarker)) { + logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`); + continue; + } + fs.writeFileSync(htmlPath, html, 'utf8'); + } catch (e: any) { + logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`); + } + } + + // Release patch lock + if (lockAcquired) { + try { fs.unlinkSync(lockFile); } catch { } + logToFile('[OBSERVER] patch lock released'); + } +} diff --git a/extension/src/http-bridge.ts b/extension/src/http-bridge.ts new file mode 100644 index 0000000..a530238 --- /dev/null +++ b/extension/src/http-bridge.ts @@ -0,0 +1,367 @@ +/** + * HTTP Bridge Server — Extension Host ↔ Renderer communication. + * + * Extracted from extension.ts to reduce file size. + * Provides an HTTP server that the AG renderer (DOM observer script) uses to: + * - Report detected approval buttons (POST /pending) + * - Poll for Discord responses (GET /response/:rid) + * - Receive click triggers (GET /trigger-click) + * - Deep DOM inspection (GET/POST /deep-inspect*) + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { WSBridgeClient } from './ws-client'; + +// ─── Context interface (shared state from extension.ts) ─── + +export interface HttpBridgeContext { + bridgePath: string; + projectName: string; + activeSessionId: string; + wsBridge: WSBridgeClient | null; + sessionStalled: boolean; + lastPendingStepIndex: number; + logToFile: (msg: string) => void; +} + +// ─── Module-level state ─── + +let observerHttpServer: any = null; +const pendingResponses = new Map(); + +// Click trigger: extension sets this, renderer polls and clicks button +let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = null; + +// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back +let deepInspectRequested = false; +let deepInspectResult: any = null; +let deepInspectWaiters: Array<(data: any) => void> = []; + +// ─── Public API ─── + +/** Set click trigger (called from step-probe when approval needed) */ +export function setClickTrigger(action: 'approve' | 'reject') { + clickTrigger = { action, timestamp: Date.now() }; +} + +/** Get the HTTP bridge server instance */ +export function getHttpServer(): any { + return observerHttpServer; +} + +/** Derive a deterministic port from project name (range 10000-60000) */ +export function getDeterministicPort(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return 10000 + (Math.abs(hash) % 50000); +} + +/** + * Start the HTTP bridge server. + * Returns the port number (0 if failed). + */ +export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise { + return new Promise((resolve) => { + try { + const http = require('http'); + const server = http.createServer((req: any, res: any) => { + // CORS headers for renderer fetch() + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } + + const url = new URL(req.url, `http://127.0.0.1`); + + // POST /pending — renderer reports a detected approval button + if (req.method === 'POST' && url.pathname === '/pending') { + _handlePending(req, res, ctx); + return; + } + + // GET /response/:rid — renderer polls for Discord approval + if (req.method === 'GET' && url.pathname.startsWith('/response/')) { + _handleGetResponse(req, res, url, ctx); + return; + } + + // GET /trigger-click — renderer polls to check if extension wants a click + if (req.method === 'GET' && url.pathname === '/trigger-click') { + _handleTriggerClick(res, ctx); + return; + } + + // GET /deep-inspect — trigger deep DOM inspection from renderer + if (req.method === 'GET' && url.pathname === '/deep-inspect') { + _handleDeepInspect(res, ctx); + return; + } + + // GET /deep-inspect-trigger — renderer polls this + if (req.method === 'GET' && url.pathname === '/deep-inspect-trigger') { + _handleDeepInspectTrigger(res); + return; + } + + // POST /deep-inspect-result — renderer posts inspection results here + if (req.method === 'POST' && url.pathname === '/deep-inspect-result') { + _handleDeepInspectResult(req, res, ctx); + return; + } + + // GET /ping — health check + if (url.pathname === '/ping') { + res.writeHead(200); res.end('pong'); + return; + } + + res.writeHead(404); res.end('not found'); + }); + + // Listen on deterministic port (derived from projectName), fallback to random + let detPort = getDeterministicPort(ctx.projectName); + const tryListen = (targetPort: number) => { + server.listen(targetPort, '127.0.0.1', () => { + const port = server.address().port; + observerHttpServer = server; + ctx.logToFile(`[HTTP] bridge server started on port ${port}`); + + // Write port to shared ports JSON (multi-bridge support) + const patcher = (sdk.integration as any)?._patcher; + if (patcher && typeof patcher.getWorkbenchDir === 'function') { + const workbenchDir = patcher.getWorkbenchDir(); + const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json'); + let portsData: Record = {}; + try { + if (fs.existsSync(portsFile)) { + portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8')); + } + } catch { } + portsData[ctx.projectName] = port; + fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8'); + ctx.logToFile(`[HTTP] ports JSON updated → ${portsFile} (${ctx.projectName}=${port})`); + } + + resolve(port); + }); + }; + + server.on('error', (e: any) => { + if (e.code === 'EADDRINUSE' && detPort > 0) { + ctx.logToFile(`[HTTP] deterministic port ${detPort} in use, trying random...`); + detPort = 0; + const server2 = require('http').createServer(server._events.request); + observerHttpServer = server2; + server2.on('error', (e2: any) => { + ctx.logToFile(`[HTTP] random port also failed: ${e2.message}`); + resolve(0); + }); + server2.listen(0, '127.0.0.1', () => { + const port = server2.address().port; + ctx.logToFile(`[HTTP] bridge server started on RANDOM port ${port}`); + resolve(port); + }); + return; + } + ctx.logToFile(`[HTTP] server error: ${e.message}`); + resolve(0); + }); + + tryListen(detPort); + } catch (e: any) { + ctx.logToFile(`[HTTP] server failed: ${e.message}`); + resolve(0); + } + }); +} + +// ─── Route Handlers (private) ─── + +function _handlePending(req: any, res: any, ctx: HttpBridgeContext) { + let body = ''; + req.on('data', (c: string) => body += c); + req.on('end', () => { + try { + const data = JSON.parse(body); + + // ── Server-side false positive filter ── + const cmd = (data.command || '').trim(); + const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i; + if (FALSE_POSITIVE_RE.test(cmd)) { + ctx.logToFile(`[HTTP] filtered false positive: "${cmd}"`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, filtered: true })); + return; + } + // "Run" button → step_probe handles these with full command detail + // Only let through if session is stalled AND step_probe hasn't created a pending yet + if (/^Run$/i.test(cmd)) { + if (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0) { + ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, filtered: true })); + return; + } + } + + const rid = data.request_id || Date.now().toString(); + // Write pending file for Discord bot + const pendingDir = path.join(ctx.bridgePath, 'pending'); + if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true }); + const pending: Record = { + ...data, + request_id: rid, + conversation_id: ctx.activeSessionId || '', + timestamp: Date.now() / 1000, + status: 'pending', + project_name: ctx.projectName, + auto_detected: true, + source: 'dom_observer', + step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined, + }; + // File permission: inject multi-choice buttons + const cmdLower = (data.command || '').toLowerCase(); + if (cmdLower.includes('allow') && !pending.buttons) { + // Dedup: skip if another file_permission pending was created within 10s + const nowMs = Date.now(); + try { + const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json')); + for (const ef of existingFiles) { + const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8')); + if (existing.step_type === 'file_permission' && existing.status === 'pending' + && existing.project_name === ctx.projectName) { + const age = nowMs - (existing.timestamp * 1000); + if (age < 10_000 && age >= 0) { + ctx.logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' })); + return; + } + } + } + } catch { } + + pending.buttons = [ + { text: 'Allow Once', index: 0 }, + { text: 'Allow This Conversation', index: 1 }, + { text: 'Deny', index: 2 }, + ]; + pending.step_type = 'file_permission'; + // Clean description: remove button labels from text + const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim(); + pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`; + } + fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); + // WS dual-write + if (ctx.wsBridge && ctx.wsBridge.isConnected()) { + ctx.wsBridge.sendPending({ + request_id: rid, + command: pending.command || data.command || '', + description: pending.description || data.description || '', + step_type: pending.step_type, + status: 'pending', + buttons: pending.buttons, + project_name: ctx.projectName, + }); + ctx.logToFile(`[HTTP-WS] pending sent via WS: ${rid}`); + } + ctx.logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, request_id: rid })); + } catch (e: any) { + ctx.logToFile(`[HTTP] pending error: ${e.message}`); + res.writeHead(400); res.end(JSON.stringify({ error: e.message })); + } + }); +} + +function _handleGetResponse(_req: any, res: any, url: URL, ctx: HttpBridgeContext) { + const rid = url.pathname.split('/')[2]; + const respFile = path.join(ctx.bridgePath, 'response', `${rid}.json`); + if (fs.existsSync(respFile)) { + try { + const data = JSON.parse(fs.readFileSync(respFile, 'utf8')); + ctx.logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`); + // Delay deletion: processResponseFile (response watcher) may need to read it too. + // The watcher fires with 300ms delay, so 2s is safe. + setTimeout(() => { + try { fs.unlinkSync(respFile); } catch { } + }, 2000); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + } catch { + res.writeHead(200); res.end(JSON.stringify({ waiting: true })); + } + } else { + res.writeHead(200); res.end(JSON.stringify({ waiting: true })); + } +} + +function _handleTriggerClick(res: any, ctx: HttpBridgeContext) { + if (clickTrigger && (Date.now() - clickTrigger.timestamp) < 30000) { + const trigger = clickTrigger; + clickTrigger = null; // consume once + ctx.logToFile(`[HTTP] trigger-click consumed: ${trigger.action}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ action: trigger.action })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ action: null })); + } +} + +function _handleDeepInspect(res: any, ctx: HttpBridgeContext) { + deepInspectRequested = true; + ctx.logToFile('[HTTP] deep-inspect triggered — waiting for renderer result...'); + // Wait up to 10s for renderer to POST result + const timeout = setTimeout(() => { + deepInspectWaiters = deepInspectWaiters.filter(w => w !== waiter); + if (deepInspectResult) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(deepInspectResult)); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'timeout', message: 'Renderer did not respond in 10s. Is the v3 script loaded?' })); + } + }, 10000); + const waiter = (data: any) => { + clearTimeout(timeout); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + }; + deepInspectWaiters.push(waiter); +} + +function _handleDeepInspectTrigger(res: any) { + const requested = deepInspectRequested; + deepInspectRequested = false; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ inspect: requested })); +} + +function _handleDeepInspectResult(req: any, res: any, ctx: HttpBridgeContext) { + let body = ''; + req.on('data', (c: string) => body += c); + req.on('end', () => { + try { + const data = JSON.parse(body); + deepInspectResult = data; + ctx.logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`); + // Write to file for reference + const inspectFile = path.join(ctx.bridgePath, 'deep-inspect-result.json'); + fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2)); + // Notify waiters + const waiters = [...deepInspectWaiters]; + deepInspectWaiters = []; + waiters.forEach(w => w(data)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } catch (e: any) { + ctx.logToFile(`[HTTP] deep-inspect-result parse error: ${e.message}`); + res.writeHead(400); res.end(JSON.stringify({ error: e.message })); + } + }); +} diff --git a/extension/src/step-probe.ts b/extension/src/step-probe.ts index 0d5a36d..2181ab1 100644 --- a/extension/src/step-probe.ts +++ b/extension/src/step-probe.ts @@ -20,7 +20,7 @@ export interface BridgeContext { lastPendingStepIndex: number; stallProbed: boolean; sawRunningAfterPending: boolean; - clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null; + setClickTrigger: (action: 'approve' | 'reject') => void; logToFile: (msg: string) => void; workspaceUri: string; diffReviewMetadata: Map; @@ -1453,9 +1453,9 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string // ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ── try { const triggerAction = approved ? 'approve' : 'reject'; - ctx.logToFile(`[APPROVAL-2] Setting ctx.clickTrigger=${triggerAction} for renderer DOM click`); - ctx.clickTrigger = { action: triggerAction as 'approve' | 'reject', timestamp: Date.now() }; - ctx.logToFile(`[APPROVAL-2] ✅ ctx.clickTrigger set — renderer will poll and click within 2s`); + ctx.logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`); + ctx.setClickTrigger(triggerAction as 'approve' | 'reject'); + ctx.logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`); } catch (e: any) { ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`); }