fix(bridge): system audit + 5-file bug fix — PATS Deny trigger removal, auto_resolved chat dedup, UUID filenames, IP rate limit leak, bot.py deque

This commit is contained in:
2026-03-15 22:59:47 +09:00
parent 429cae47b7
commit c9f44afcf1
9 changed files with 193 additions and 107 deletions

View File

@@ -502,3 +502,21 @@
- **해결**: (1) `FALSE_POSITIVE_RE``Deny|Allow Once|Allow This Conversation|Dismiss|Decline` 추가, (2) bot.py auto-approve에 reject-word command 차단 가드, (3) smart button_index 선택 (방어) - **해결**: (1) `FALSE_POSITIVE_RE``Deny|Allow Once|Allow This Conversation|Dismiss|Decline` 추가, (2) bot.py auto-approve에 reject-word command 차단 가드, (3) smart button_index 선택 (방어)
- **주의**: `FALSE_POSITIVE_RE`는 렌더러 인라인 스크립트 안에 있으므로 **VSIX 빌드 → AG 풀 재시작** 필요. 새 UI 버튼 패턴 추가 시 반드시 이 필터 점검. **수정 시 해당 파일만 보지 말고 전체 데이터 플로우(producer→consumer→side effects) 분석 필수 (AGENT.md 규칙 #10)** - **주의**: `FALSE_POSITIVE_RE`는 렌더러 인라인 스크립트 안에 있으므로 **VSIX 빌드 → AG 풀 재시작** 필요. 새 UI 버튼 패턴 추가 시 반드시 이 필터 점검. **수정 시 해당 파일만 보지 말고 전체 데이터 플로우(producer→consumer→side effects) 분석 필수 (AGENT.md 규칙 #10)**
### [2026-03-15] PATS 배열 Deny 트리거 — 근본 수정
- **증상**: DOM Observer가 "Deny"를 주 트리거로 사용하여 command="Deny" pending 생성. step_probe MERGE와 결합하여 디스코드에 Deny 더미 전달
- **원인**: `PATS` 배열에 `{re:/^Deny$/i, type:'permission'}`이 포함되어 있어 DOM 순서상 Deny가 먼저 스캔됨. `FALSE_POSITIVE_RE`가 HTTP POST 경로에서는 차단하지만 `writePendingApproval` 직접 파일 작성 경로를 우회
- **해결**: PATS에서 거절/보조 버튼(Deny, Reject all, Dismiss) 제거. 긍정 버튼만 그룹 트리거 → 보조 버튼은 `ALL_ACTION_RE` + `collectSiblingButtons`로 형제 수집
- **주의**: PATS = "그룹 생성 트리거", ALL_ACTION_RE = "형제 수집 패턴". 새 버튼 추가 시 이 2단계 구조를 반드시 이해하고 추가
### [2026-03-15] Auto-Resolved 채팅 폭주 — 루프 내 writeChatSnapshot
- **증상**: "✅ AG에서 직접 승인됨" 메시지가 pending 파일 개수(4~5개)만큼 Discord에 반복 전송
- **원인**: auto_resolved 루프 내부에서 매 파일마다 `writeChatSnapshot()` 호출. 또한 `conversation_id` 미검증으로 타 세션 pending도 오염
- **해결**: (1) writeChatSnapshot을 루프 바깥으로 이동(resolvedCount > 0일 때 1회), (2) `pd.conversation_id === activeSessionId` 조건 추가, (3) primaryCommand에서 'Deny'/'Allow' 텍스트 제외
- **주의**: Bridge 파일 루프에서 외부 시스템(Discord)에 메시지를 보낼 때는 반드시 루프 바깥에서 집계 후 1회 발송
### [2026-03-15] 이전 분석 오판(False Positive) — 교훈
- **증상**: 시스템 감사 시 P0/P1으로 보고한 문제들이 실제로는 코드 방어 로직(멱등성, try-catch, 의도된 exact-match)으로 이미 방어되고 있었음
- **원인**: 로컬 코드 스니펫만 보고 판단. 전체 데이터 생명주기를 끝까지 추적하지 않아 방어 로직을 놓침
- **해결**: 나노단위 전체 Flow 추적으로 교차 검증 후 진짜 결함만 추림 (P2 3건, P3 2건)
- **주의**: **코드 감사 시 반드시 producer→transport→consumer→side effects 전체 경로를 추적. 단편적 로컬 분석으로 위험도를 과장하지 말 것**

10
bot.py
View File

@@ -11,6 +11,7 @@ import asyncio
import json import json
import logging import logging
import time import time
from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -180,7 +181,7 @@ class GravityBot(commands.Bot):
self.session_category: discord.CategoryChannel | None = None self.session_category: discord.CategoryChannel | None = None
self.guild: discord.Guild | None = None self.guild: discord.Guild | None = None
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
self._processed_message_ids: set[int] = set() # dedup for Gateway event replay self._processed_message_ids: deque[int] = deque(maxlen=200) # dedup for Gateway event replay
self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup) self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup)
self.gateway = None # Set by main.py in gateway mode self.gateway = None # Set by main.py in gateway mode
@@ -800,12 +801,7 @@ class GravityBot(commands.Bot):
# Dedup: Discord Gateway can deliver MESSAGE_CREATE twice on reconnection # Dedup: Discord Gateway can deliver MESSAGE_CREATE twice on reconnection
if message.id in self._processed_message_ids: if message.id in self._processed_message_ids:
return return
self._processed_message_ids.add(message.id) self._processed_message_ids.append(message.id)
# Keep set bounded (last 200 messages)
if len(self._processed_message_ids) > 200:
excess = len(self._processed_message_ids) - 100
for _ in range(excess):
self._processed_message_ids.pop()
# Determine project from channel # Determine project from channel
project = self.channel_to_project.get(message.channel.id) project = self.channel_to_project.get(message.channel.id)

View File

@@ -21,6 +21,7 @@ Transport layer:
import json import json
import time import time
import logging import logging
import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
@@ -410,15 +411,21 @@ class BridgeProtocol:
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()} fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
now = time.time() now = time.time()
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout) MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
CLEANUP_AGE = 86400 # 1 day
for fname in self.transport.list_json_files("pending"): for fname in self.transport.list_json_files("pending"):
data = self.transport.read_json("pending", fname) data = self.transport.read_json("pending", fname)
if data is None: if data is None:
continue continue
ts = data.get("timestamp", 0) ts = data.get("timestamp", 0)
if now - ts > CLEANUP_AGE:
# Too old even to keep as expired — delete to prevent accumulation
self.transport.delete_file("pending", fname)
continue
if now - ts > MAX_AGE: if now - ts > MAX_AGE:
# Too old — mark expired and skip # Too old — mark expired and skip
data["status"] = "expired" if data.get("status") != "expired":
self.transport.write_json("pending", fname, data) data["status"] = "expired"
self.transport.write_json("pending", fname, data)
continue continue
if data.get("status") == "pending": if data.get("status") == "pending":
# Filter to known fields only # Filter to known fields only
@@ -455,7 +462,7 @@ class BridgeProtocol:
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""): def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
"""Write a user text command for Antigravity to consume.""" """Write a user text command for Antigravity to consume."""
cmd_id = f"{int(time.time() * 1000)}" cmd_id = f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
fname = f"{cmd_id}.json" fname = f"{cmd_id}.json"
data = { data = {

View File

@@ -16,6 +16,7 @@ import json
import os import os
import time import time
import logging import logging
import uuid
from pathlib import Path from pathlib import Path
from bridge import LocalTransport, RemoteTransport from bridge import LocalTransport, RemoteTransport
@@ -338,7 +339,7 @@ class CollectorBridge:
project_intervals[project] = _BASE_INTERVAL project_intervals[project] = _BASE_INTERVAL
project_empty_streak[project] = 0 project_empty_streak[project] = 0
for cmd in commands: for cmd in commands:
cmd_id = cmd.get("id", str(int(time.time() * 1000))) cmd_id = cmd.get("id", f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}")
fname = f"{cmd_id}.json" fname = f"{cmd_id}.json"
self.local.write_json("commands", fname, cmd) self.local.write_json("commands", fname, cmd)
logger.info(f"[COLLECTOR] ← Gateway: command [{project}] {cmd.get('text', '?')[:30]}") logger.info(f"[COLLECTOR] ← Gateway: command [{project}] {cmd.get('text', '?')[:30]}")

View File

@@ -8,4 +8,5 @@
| 004 | 10:41~10:53 | 성능 최적화 3건 (pollResponseGroup 1500ms, renderer adaptive idle, Bot single-pass scanner) + VSIX 빌드 | `ae0509f` | ✅ | | 004 | 10:41~10:53 | 성능 최적화 3건 (pollResponseGroup 1500ms, renderer adaptive idle, Bot single-pass scanner) + VSIX 빌드 | `ae0509f` | ✅ |
| 005 | 15:17~17:09 | 크로스 프로젝트 신호 오염 진단 & 승인 플로우 아키텍처 수정 — DEDUP project_name 가드, double-fire auto-approve 제거, 실패 RPC 전략 30+개 삭제 (v0.3.11) | `6739f8f` | ✅ | | 005 | 15:17~17:09 | 크로스 프로젝트 신호 오염 진단 & 승인 플로우 아키텍처 수정 — DEDUP project_name 가드, double-fire auto-approve 제거, 실패 RPC 전략 30+개 삭제 (v0.3.11) | `6739f8f` | ✅ |
| 006 | 18:32~18:51 | Auto-approve 크래시 수정 — DOM Observer Deny false positive 필터 + Bot reject-word 가드 + AGENT.md 규칙 #10 추가 | `5e5f515` | ✅ | | 006 | 18:32~18:51 | Auto-approve 크래시 수정 — DOM Observer Deny false positive 필터 + Bot reject-word 가드 + AGENT.md 규칙 #10 추가 | `5e5f515` | ✅ |
| 007 | 22:00~22:52 | 시스템 전체 감사 + 5개 파일 버그 수정 (PATS Deny 트리거 제거, auto_resolved 채팅 병합, UUID 파일명 충돌방지, IP rate limit 누수, bot.py deque) + VSIX 빌드/배포 | `dcb6bb0` | ✅ |

View File

@@ -1222,16 +1222,15 @@ function generateApprovalObserverScript(_port) {
}); });
// ── Button patterns to detect (order matters: first match wins per scan) ── // ── Button patterns to detect (order matters: first match wins per scan) ──
// ONLY positive triggers should initiate a pending request group.
// Negative/secondary buttons (Deny, Reject, Dismiss) will be collected as siblings.
var PATS=[ var PATS=[
{re:/^Run/i, type:'terminal_command'}, {re:/^Run/i, type:'terminal_command'},
{re:/^Accept all$/i, type:'diff_review'}, {re:/^Accept all$/i, type:'diff_review'},
{re:/^Reject all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'}, {re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'}, {re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'}, {re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'}, {re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'},
]; ];
// ALL actionable button patterns (for grouping siblings in same container) // ALL actionable button patterns (for grouping siblings in same container)
@@ -1932,33 +1931,42 @@ function setupMonitor() {
// Steps progressed — if we had a pending approval, it was handled in AG directly // Steps progressed — if we had a pending approval, it was handled in AG directly
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) { if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
// Mark pending as auto_resolved so bot can update Discord message // Mark pending as auto_resolved so bot can update Discord message
try { let resolvedCount = 0;
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')) let primaryCommand = '';
.filter((f) => f.endsWith('.json')); const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f) => f.endsWith('.json'));
const nowMs = Date.now(); const nowMs = Date.now();
for (const pf of pendingFiles) { for (const pf of pendingFiles) {
const pfPath = path.join(bridgePath, 'pending', pf); const pfPath = path.join(bridgePath, 'pending', pf);
try {
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8')); const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
if (pd.status !== 'pending') if (pd.status !== 'pending')
continue; continue;
// Skip other projects' pendings
if (pd.project_name && pd.project_name !== projectName) if (pd.project_name && pd.project_name !== projectName)
continue; continue;
// Match by step_index OR by recency (< 60s, any source) // Limit to same session AND (same step or recent)
const ageMs = nowMs - (pd.timestamp * 1000); const ageMs = nowMs - (pd.timestamp * 1000);
const isMatch = pd.step_index === lastPendingStepIndex const isMatch = (pd.conversation_id === activeSessionId) &&
|| (ageMs < 60_000 && ageMs >= 0); (pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
if (isMatch) { if (isMatch) {
pd.status = 'auto_resolved'; pd.status = 'auto_resolved';
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8'); fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf} (age=${Math.round(ageMs / 1000)}s)`); resolvedCount++;
// FIX #3: Notify Discord that user approved locally const cmd = pd.command || '';
writeChatSnapshot(`✅ **AG에서 직접 승인됨** (step ${lastPendingStepIndex})\n\n\`${(pd.command || '').substring(0, 200)}\``); if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
primaryCommand = cmd;
}
else if (!primaryCommand) {
primaryCommand = cmd;
}
} }
} }
catch (e) {
logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`);
}
} }
catch (e) { if (resolvedCount > 0) {
logToFile(`[AUTO-RESOLVE] error: ${e.message}`); logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`);
writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``);
} }
lastPendingStepIndex = -1; lastPendingStepIndex = -1;
} }
@@ -2437,37 +2445,53 @@ function setupResponseWatcher() {
if (!fs.existsSync(responseDir)) { if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true }); fs.mkdirSync(responseDir, { recursive: true });
} }
const processAnyResponse = (filename) => {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
// logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
}
catch { }
}
else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
// logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
}
catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
};
const pollAllResponses = () => {
try {
if (!fs.existsSync(responseDir))
return;
for (const f of fs.readdirSync(responseDir)) {
if (f.endsWith('.json')) {
processAnyResponse(f);
}
}
}
catch { }
};
pollAllResponses(); // Process any existing responses on startup
try { try {
responseWatcher = fs.watch(responseDir, (event, filename) => { responseWatcher = fs.watch(responseDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') { if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename); processAnyResponse(filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
}
catch { }
}
else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
}
catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
} }
}); });
console.log('Gravity Bridge: response watcher started'); console.log('Gravity Bridge: response watcher started');
@@ -2475,6 +2499,8 @@ function setupResponseWatcher() {
catch (e) { catch (e) {
console.log(`Gravity Bridge: response watcher failed: ${e.message}`); console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
} }
// Polling fallback: fs.watch on Windows can silently fail
setInterval(pollAllResponses, 3000);
} }
async function processResponseFile(filePath) { async function processResponseFile(filePath) {
try { try {

File diff suppressed because one or more lines are too long

View File

@@ -1216,16 +1216,15 @@ function generateApprovalObserverScript(_port: number): string {
}); });
// ── Button patterns to detect (order matters: first match wins per scan) ── // ── Button patterns to detect (order matters: first match wins per scan) ──
// ONLY positive triggers should initiate a pending request group.
// Negative/secondary buttons (Deny, Reject, Dismiss) will be collected as siblings.
var PATS=[ var PATS=[
{re:/^Run/i, type:'terminal_command'}, {re:/^Run/i, type:'terminal_command'},
{re:/^Accept all$/i, type:'diff_review'}, {re:/^Accept all$/i, type:'diff_review'},
{re:/^Reject all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'}, {re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'}, {re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'}, {re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'}, {re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'},
]; ];
// ALL actionable button patterns (for grouping siblings in same container) // ALL actionable button patterns (for grouping siblings in same container)
@@ -1934,29 +1933,37 @@ function setupMonitor() {
// Steps progressed — if we had a pending approval, it was handled in AG directly // Steps progressed — if we had a pending approval, it was handled in AG directly
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) { if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
// Mark pending as auto_resolved so bot can update Discord message // Mark pending as auto_resolved so bot can update Discord message
try { let resolvedCount = 0;
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')) let primaryCommand = '';
.filter((f: string) => f.endsWith('.json')); const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f: string) => f.endsWith('.json'));
const nowMs = Date.now(); const nowMs = Date.now();
for (const pf of pendingFiles) { for (const pf of pendingFiles) {
const pfPath = path.join(bridgePath, 'pending', pf); const pfPath = path.join(bridgePath, 'pending', pf);
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8')); try {
if (pd.status !== 'pending') continue; const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
// Skip other projects' pendings if (pd.status !== 'pending') continue;
if (pd.project_name && pd.project_name !== projectName) continue; if (pd.project_name && pd.project_name !== projectName) continue;
// Match by step_index OR by recency (< 60s, any source) // Limit to same session AND (same step or recent)
const ageMs = nowMs - (pd.timestamp * 1000); const ageMs = nowMs - (pd.timestamp * 1000);
const isMatch = pd.step_index === lastPendingStepIndex const isMatch = (pd.conversation_id === activeSessionId) &&
|| (ageMs < 60_000 && ageMs >= 0); (pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
if (isMatch) { if (isMatch) {
pd.status = 'auto_resolved'; pd.status = 'auto_resolved';
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8'); fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf} (age=${Math.round(ageMs/1000)}s)`); resolvedCount++;
// FIX #3: Notify Discord that user approved locally const cmd = pd.command || '';
writeChatSnapshot(`✅ **AG에서 직접 승인됨** (step ${lastPendingStepIndex})\n\n\`${(pd.command || '').substring(0, 200)}\``); if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
} primaryCommand = cmd;
} else if (!primaryCommand) {
primaryCommand = cmd;
}
}
} catch (e: any) { logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`); }
}
if (resolvedCount > 0) {
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`);
writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``);
} }
} catch (e: any) { logToFile(`[AUTO-RESOLVE] error: ${e.message}`); }
lastPendingStepIndex = -1; lastPendingStepIndex = -1;
} }
consecutiveIdleCount = 0; consecutiveIdleCount = 0;
@@ -2411,40 +2418,60 @@ function setupResponseWatcher() {
fs.mkdirSync(responseDir, { recursive: true }); fs.mkdirSync(responseDir, { recursive: true });
} }
const processAnyResponse = (filename: string) => {
const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
// logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
} catch { }
} else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
// logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
} catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
};
const pollAllResponses = () => {
try {
if (!fs.existsSync(responseDir)) return;
for (const f of fs.readdirSync(responseDir)) {
if (f.endsWith('.json')) {
processAnyResponse(f);
}
}
} catch { }
};
pollAllResponses(); // Process any existing responses on startup
try { try {
responseWatcher = fs.watch(responseDir, (event, filename) => { responseWatcher = fs.watch(responseDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') { if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename); processAnyResponse(filename);
if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
} catch { }
} else {
// Pending file missing (deleted or auto_resolved) — check response data itself
try {
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
if (respData.project_name && respData.project_name !== projectName) {
logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
return;
}
} catch { }
}
setTimeout(() => processResponseFile(fp), 300);
}
} }
}); });
console.log('Gravity Bridge: response watcher started'); console.log('Gravity Bridge: response watcher started');
} catch (e: any) { } catch (e: any) {
console.log(`Gravity Bridge: response watcher failed: ${e.message}`); console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
} }
// Polling fallback: fs.watch on Windows can silently fail
setInterval(pollAllResponses, 3000);
} }
async function processResponseFile(filePath: string) { async function processResponseFile(filePath: string) {

View File

@@ -20,6 +20,7 @@ import asyncio
import json import json
import time import time
import logging import logging
import uuid
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
@@ -94,6 +95,15 @@ class GatewayAPI:
window.append(now) window.append(now)
self._rate_limits[ip] = window self._rate_limits[ip] = window
# Memory leak prevention: Cleanup stale IPs when mapping grows too large
if len(self._rate_limits) > 1000:
for k in list(self._rate_limits.keys()):
active = [t for t in self._rate_limits[k] if now - t < RATE_LIMIT_WINDOW]
if active:
self._rate_limits[k] = active
else:
del self._rate_limits[k]
return await handler(request) return await handler(request)
# ─── Health ─── # ─── Health ───
@@ -111,7 +121,7 @@ class GatewayAPI:
"""Collector pushes a pending approval request.""" """Collector pushes a pending approval request."""
try: try:
data = await request.json() data = await request.json()
rid = data.get("request_id", str(int(time.time() * 1000))) rid = data.get("request_id", f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}")
data["request_id"] = rid data["request_id"] = rid
data.setdefault("timestamp", time.time()) data.setdefault("timestamp", time.time())
data.setdefault("status", "pending") data.setdefault("status", "pending")
@@ -166,7 +176,7 @@ class GatewayAPI:
snap_dir = self.bot.bridge.transport.bridge_dir / "chat_snapshots" if hasattr(self.bot.bridge.transport, 'bridge_dir') else None snap_dir = self.bot.bridge.transport.bridge_dir / "chat_snapshots" if hasattr(self.bot.bridge.transport, 'bridge_dir') else None
if snap_dir: if snap_dir:
snap_dir.mkdir(parents=True, exist_ok=True) snap_dir.mkdir(parents=True, exist_ok=True)
snap_id = f"{int(time.time() * 1000)}" snap_id = f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
snap_data = { snap_data = {
"id": snap_id, "id": snap_id,
"project_name": project, "project_name": project,