refactor(extension): split extension.ts into 3 modules - http-bridge, html-patcher, command-handler (#398)
This commit is contained in:
367
extension/src/http-bridge.ts
Normal file
367
extension/src/http-bridge.ts
Normal file
@@ -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<string, { approved: boolean } | null>();
|
||||
|
||||
// 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<number> {
|
||||
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<string, number> = {};
|
||||
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<string, any> = {
|
||||
...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 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user