/** * 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'; let lastFilePermissionTime = 0; // ─── 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; writeChatSnapshot?: (text: 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`); // DIAGNOSTIC: log ALL requests (except noisy polling endpoints) if (!['/trigger-click', '/deep-inspect-trigger'].includes(url.pathname)) { ctx.logToFile(`[HTTP-REQ] ${req.method} ${url.pathname}`); } // 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 /chat — renderer posts chat snapshots directly if (req.method === 'POST' && url.pathname === '/chat') { _handleChatSnapshot(req, res, ctx); return; } // POST /deep-inspect-result — renderer posts inspection results here if (req.method === 'POST' && url.pathname === '/deep-inspect-result') { _handleDeepInspectResult(req, res, ctx); return; } if (req.method === 'POST' && url.pathname === '/dump-html') { let dumpBody = ''; req.setEncoding('utf8'); req.on('data', (c: string) => dumpBody += c); req.on('end', () => { try { // Save indexed dump for history + latest as dump_html.json let idx = 1; try { const parsed = JSON.parse(dumpBody); idx = parsed.dumpIndex || idx; } catch {} fs.writeFileSync(path.join(ctx.bridgePath, `dump_html_${idx}.json`), dumpBody, 'utf-8'); fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8'); ctx.logToFile(`[HTTP] DOM dump #${idx} saved (${dumpBody.length} bytes)`); } catch (e) { } res.writeHead(200); res.end('ok'); }); return; } if (req.method === 'POST' && url.pathname === '/test-rpc') { let rpcBody = ''; req.setEncoding('utf8'); req.on('data', (c: string) => rpcBody += c); req.on('end', async () => { try { const params = JSON.parse(rpcBody); const result = await sdk.ls.rawRPC(params.method, params.args || {}); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(typeof result === 'string' ? result : JSON.stringify(result)); } catch (e: any) { res.writeHead(500); res.end(e.message); } }); return; } // GET /status — diagnostic endpoint if (req.method === 'GET' && url.pathname === '/status') { const { getStepProbeContext } = require('./step-probe'); const probeCtx = getStepProbeContext(); const status = { projectName: ctx.projectName, activeSessionId: probeCtx.activeSessionId || ctx.activeSessionId, lastPendingStepIndex: probeCtx.lastPendingStepIndex, sessionStalled: probeCtx.sessionStalled, wsConnected: ctx.wsBridge?.isConnected() ?? false, clickTrigger: clickTrigger ? { ...clickTrigger, ageMs: Date.now() - clickTrigger.timestamp } : null, uptime: Math.round(process.uptime()), timestamp: new Date().toISOString(), }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(status, null, 2)); 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.setEncoding('utf8'); req.on('data', (c: string) => body += c); req.on('end', () => { try { const data = JSON.parse(body); // ── v12: Command enrichment FIRST — extract actual command from description ── // Must run before filters so "Always run" with useful description isn't filtered out const rawCmd = (data.command || '').trim(); // v15: Strip Material icon names from description BEFORE enrichment // DOM textContent concatenates icon text (e.g. "content_copy") without separators const ICON_STRIP_RE = /\b(chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|arrow_forward|arrow_back|expand_more|expand_less|more_horiz|more_vert|content_copy|content_paste|check_circle|check|keyboard_arrow_up|keyboard_arrow_down|keyboard_arrow_left|keyboard_arrow_right|slow_motion_video|open_in_new|alternate_email)\b/g; const rawDesc = (data.description || '').replace(ICON_STRIP_RE, '').replace(/\s{2,}/g, ' ').trim(); const GENERIC_BTN_RE = /^(?:Always\s*)?(?:Run|Allow|Accept|Approve)$/i; let enrichedCmd = rawCmd; let enrichedDesc = rawDesc; if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) { // Extract the actual command from description (often includes terminal prompt) // Pattern: "…\project_name > actual_command" const promptMatch = rawDesc.match(/[>»]\s*(.+)/); if (promptMatch && promptMatch[1].trim().length > 3) { const extracted = promptMatch[1].trim(); // v16: Validate extracted text is not just a prompt fragment or path const PROMPT_ONLY_RE = /^.*[>»$#]\s*$/; const TERMINAL_PROMPT_RE = /^[^\n]*\\[^\\>]+\s*[>»]\s*$/; if (!PROMPT_ONLY_RE.test(extracted) && !TERMINAL_PROMPT_RE.test(extracted)) { enrichedCmd = extracted.substring(0, 200); enrichedDesc = `[${rawCmd}] ${rawDesc}`; ctx.logToFile(`[HTTP] command enriched: "${rawCmd}" → "${enrichedCmd.substring(0, 60)}"`); } else { // Prompt-only extraction — filter ctx.logToFile(`[HTTP] enrichment skipped (prompt-only): "${rawCmd}" desc="${rawDesc.substring(0, 60)}"`); ctx.logToFile(`[HTTP] filtered generic+prompt-only: "${rawCmd}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_prompt_only' })); return; } } else { // v16: No prompt marker (> » $ #) found in description — this is terminal OUTPUT, not a command // Observer extracted stdout text from code block (e.g. "No extension.log found", "Log found: ...") ctx.logToFile(`[HTTP] filtered terminal output (no prompt marker): "${rawDesc.substring(0, 60)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'terminal_output' })); return; } } else if (GENERIC_BTN_RE.test(rawCmd) && (rawDesc.length <= 10 || rawDesc === rawCmd)) { // v13: Generic button with no useful description (observer prompt-only context) ctx.logToFile(`[HTTP] filtered generic button no-context: "${rawCmd}" desc="${rawDesc.substring(0, 30)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_no_context' })); return; } // ── Server-side false positive filter (uses enriched cmd) ── const cmd = enrichedCmd; const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Dismiss)$/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; } // v14: Server-side junk content filter — CSS, source code, icon glue // This is the last line of defense regardless of observer version const JUNK_CONTENT_RE = /(!important|::selection|background-color:|var\(--|font-size:|border-[a-z]+:|padding:|margin:|display:\s|===|!==|\|\||\.\btest\(|\.\bmatch\(|\.\breplace\(|_RE[.\s]|\brawDesc\b|\brawCmd\b|\benrichedCmd\b|\bquerySelector\b|\.code-block|\.code-line|\.line-content|\{\s*--|integration\.build)/; // v15: ICON_GLUE_RE now also catches standalone icon names (no trailing [a-zA-Z] required) const ICON_GLUE_RE = /\b(alternate_email|content_copy|content_paste|check_circle|chevron_right|chevron_left|keyboard_arrow|arrow_drop_down|arrow_drop_up|more_horiz|more_vert|expand_more|expand_less)\b/; // v15: Terminal prompt pattern — catches bare prompts like "…\project >" or "PS C:\path>" const BARE_PROMPT_RE = /^[^\n]{0,60}[>»$#]\s*$/; if (JUNK_CONTENT_RE.test(cmd) || ICON_GLUE_RE.test(cmd)) { ctx.logToFile(`[HTTP] filtered junk content: "${cmd.substring(0, 80)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'junk_content' })); return; } // v15: Final bare prompt filter — catches any enriched cmd that's just a terminal prompt if (BARE_PROMPT_RE.test(cmd) && cmd.length < 80) { ctx.logToFile(`[HTTP] filtered bare prompt: "${cmd.substring(0, 80)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'bare_prompt' })); return; } // "Run" button → step_probe handles these with full command detail // Only filter when step_probe IS actively tracking AND cmd is still generic button text if (/^(?:Always\s*)?Run\b/i.test(cmd)) { if (ctx.activeSessionId && (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0)) { ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'} (session=${ctx.activeSessionId.substring(0, 8)})`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true })); return; } // v9: When step_probe has no active session, let DOM observer handle approval ctx.logToFile(`[HTTP] allowing "Run" — step_probe has no active session`); } const rid = data.request_id || Date.now().toString(); const pending: Record = { ...data, request_id: rid, command: enrichedCmd, description: enrichedDesc, conversation_id: ctx.activeSessionId || '', timestamp: Date.now() / 1000, status: 'pending', project_name: ctx.projectName, auto_detected: true, source: 'dom_observer', step_type: data.step_type, buttons: data.buttons, step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined, }; // v17+: "Always run" auto-approve — click button immediately without Discord roundtrip // Check rawCmd first, then fall back to scanning the buttons array // (Observer may detect "Run" first while "Always run" is a sibling button) let alwaysRunIndex = -1; if (/^Always\s+run$/i.test(rawCmd)) { alwaysRunIndex = 0; } else if (Array.isArray(data.buttons)) { for (let bi = 0; bi < data.buttons.length; bi++) { if (/^Always\s+run$/i.test((data.buttons[bi].text || '').trim())) { alwaysRunIndex = bi; break; } } } if (alwaysRunIndex >= 0) { ctx.logToFile(`[HTTP] AUTO-APPROVE "Always run" (btnIdx=${alwaysRunIndex}): enriched="${enrichedCmd.substring(0, 80)}"`); // Write response file so observer's pollResponseGroup picks it up and clicks the button const responseDir = path.join(ctx.bridgePath, 'response'); if (!fs.existsSync(responseDir)) { fs.mkdirSync(responseDir, { recursive: true }); } const respPayload = { request_id: rid, approved: true, button_index: alwaysRunIndex, step_type: data.step_type || 'command', project_name: ctx.projectName, }; fs.writeFileSync( path.join(responseDir, `${rid}.json`), JSON.stringify(respPayload), 'utf-8' ); // Notify Discord (non-interactive "자동 승인" embed) if (ctx.wsBridge && ctx.wsBridge.isConnected()) { ctx.wsBridge.sendPending({ request_id: rid, command: enrichedCmd || rawCmd, description: enrichedDesc, step_type: pending.step_type, status: 'auto_approved', buttons: pending.buttons, project_name: ctx.projectName, }); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, request_id: rid, auto_approved: true })); return; } // File permission: inject multi-choice buttons const cmdLower = enrichedCmd.toLowerCase(); if (cmdLower.includes('allow') && !pending.buttons) { // Dedup: skip if another file_permission pending was created within 10s const nowMs = Date.now(); if (nowMs - lastFilePermissionTime < 10000) { ctx.logToFile(`[HTTP] filtered duplicate file_permission (${nowMs - lastFilePermissionTime}ms old)`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' })); return; } lastFilePermissionTime = nowMs; 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 cleanDesc = enrichedDesc.replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim(); pending.command = `파일 접근 권한${cleanDesc ? ': ' + cleanDesc : ''}`; } // WS dispatch 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="${pending.command || data.command}" btns=${(pending.buttons || data.buttons || []).length} ctx="${(pending.description || data.description || '').substring(0, 80)}"`); if (data._debug_trail) { ctx.logToFile(`[HTTP-DIAG] trail: ${data._debug_trail.substring(0, 500)}`); } 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.setEncoding('utf8'); 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)`); const inspectFile = path.join(ctx.bridgePath, 'deep-inspect-result.json'); fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2)); 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 })); } }); } function _handleChatSnapshot(req: any, res: any, ctx: HttpBridgeContext) { let body = ''; req.setEncoding('utf8'); req.on('data', (c: string) => body += c); req.on('end', () => { try { const data = JSON.parse(body); if (data.text && typeof ctx.writeChatSnapshot === 'function') { const isUser = data.role === 'user'; const prefix = isUser ? '🧑‍💻 **[DOM 추출] 사용자 요청**' : '💬 **[DOM 추출] AI 응답**'; ctx.writeChatSnapshot(`${prefix}\n\n${data.text}`); ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars, role: ${data.role || 'bot'})`); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); } catch (e: any) { ctx.logToFile(`[HTTP] chat parse error: ${e.message}`); res.writeHead(400); res.end(JSON.stringify({ error: e.message })); } }); }