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:
@@ -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 선택 (방어)
|
||||
- **주의**: `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
10
bot.py
@@ -11,6 +11,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -180,7 +181,7 @@ class GravityBot(commands.Bot):
|
||||
self.session_category: discord.CategoryChannel | None = None
|
||||
self.guild: discord.Guild | None = None
|
||||
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.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
|
||||
if message.id in self._processed_message_ids:
|
||||
return
|
||||
self._processed_message_ids.add(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()
|
||||
self._processed_message_ids.append(message.id)
|
||||
|
||||
# Determine project from channel
|
||||
project = self.channel_to_project.get(message.channel.id)
|
||||
|
||||
13
bridge.py
13
bridge.py
@@ -21,6 +21,7 @@ Transport layer:
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
@@ -410,15 +411,21 @@ class BridgeProtocol:
|
||||
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
|
||||
now = time.time()
|
||||
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
|
||||
CLEANUP_AGE = 86400 # 1 day
|
||||
for fname in self.transport.list_json_files("pending"):
|
||||
data = self.transport.read_json("pending", fname)
|
||||
if data is None:
|
||||
continue
|
||||
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:
|
||||
# Too old — mark expired and skip
|
||||
data["status"] = "expired"
|
||||
self.transport.write_json("pending", fname, data)
|
||||
if data.get("status") != "expired":
|
||||
data["status"] = "expired"
|
||||
self.transport.write_json("pending", fname, data)
|
||||
continue
|
||||
if data.get("status") == "pending":
|
||||
# Filter to known fields only
|
||||
@@ -455,7 +462,7 @@ class BridgeProtocol:
|
||||
|
||||
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
||||
"""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"
|
||||
|
||||
data = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from bridge import LocalTransport, RemoteTransport
|
||||
@@ -338,7 +339,7 @@ class CollectorBridge:
|
||||
project_intervals[project] = _BASE_INTERVAL
|
||||
project_empty_streak[project] = 0
|
||||
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"
|
||||
self.local.write_json("commands", fname, cmd)
|
||||
logger.info(f"[COLLECTOR] ← Gateway: command [{project}] {cmd.get('text', '?')[:30]}")
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
| 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` | ✅ |
|
||||
| 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` | ✅ |
|
||||
|
||||
|
||||
@@ -1222,16 +1222,15 @@ function generateApprovalObserverScript(_port) {
|
||||
});
|
||||
|
||||
// ── 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=[
|
||||
{re:/^Run/i, type:'terminal_command'},
|
||||
{re:/^Accept all$/i, type:'diff_review'},
|
||||
{re:/^Reject all$/i, type:'diff_review'},
|
||||
{re:/^Accept$/i, type:'agent_step'},
|
||||
{re:/^Allow/i, type:'permission'},
|
||||
{re:/^Approve/i, type:'agent_step'},
|
||||
{re:/^Deny$/i, type:'permission'},
|
||||
{re:/^Retry$/i, type:'error_recovery'},
|
||||
{re:/^Dismiss$/i, type:'error_recovery'},
|
||||
];
|
||||
|
||||
// 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
|
||||
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
|
||||
// Mark pending as auto_resolved so bot can update Discord message
|
||||
try {
|
||||
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending'))
|
||||
.filter((f) => f.endsWith('.json'));
|
||||
const nowMs = Date.now();
|
||||
for (const pf of pendingFiles) {
|
||||
const pfPath = path.join(bridgePath, 'pending', pf);
|
||||
let resolvedCount = 0;
|
||||
let primaryCommand = '';
|
||||
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f) => f.endsWith('.json'));
|
||||
const nowMs = Date.now();
|
||||
for (const pf of pendingFiles) {
|
||||
const pfPath = path.join(bridgePath, 'pending', pf);
|
||||
try {
|
||||
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
||||
if (pd.status !== 'pending')
|
||||
continue;
|
||||
// Skip other projects' pendings
|
||||
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 isMatch = pd.step_index === lastPendingStepIndex
|
||||
|| (ageMs < 60_000 && ageMs >= 0);
|
||||
const isMatch = (pd.conversation_id === activeSessionId) &&
|
||||
(pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
|
||||
if (isMatch) {
|
||||
pd.status = 'auto_resolved';
|
||||
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
||||
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf} (age=${Math.round(ageMs / 1000)}s)`);
|
||||
// FIX #3: Notify Discord that user approved locally
|
||||
writeChatSnapshot(`✅ **AG에서 직접 승인됨** (step ${lastPendingStepIndex})\n\n\`${(pd.command || '').substring(0, 200)}\``);
|
||||
resolvedCount++;
|
||||
const cmd = pd.command || '';
|
||||
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) {
|
||||
logToFile(`[AUTO-RESOLVE] error: ${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)}\``);
|
||||
}
|
||||
lastPendingStepIndex = -1;
|
||||
}
|
||||
@@ -2437,37 +2445,53 @@ function setupResponseWatcher() {
|
||||
if (!fs.existsSync(responseDir)) {
|
||||
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 {
|
||||
responseWatcher = fs.watch(responseDir, (event, filename) => {
|
||||
if (filename && filename.endsWith('.json') && event === 'rename') {
|
||||
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);
|
||||
}
|
||||
processAnyResponse(filename);
|
||||
}
|
||||
});
|
||||
console.log('Gravity Bridge: response watcher started');
|
||||
@@ -2475,6 +2499,8 @@ function setupResponseWatcher() {
|
||||
catch (e) {
|
||||
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) {
|
||||
try {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1216,16 +1216,15 @@ function generateApprovalObserverScript(_port: number): string {
|
||||
});
|
||||
|
||||
// ── 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=[
|
||||
{re:/^Run/i, type:'terminal_command'},
|
||||
{re:/^Accept all$/i, type:'diff_review'},
|
||||
{re:/^Reject all$/i, type:'diff_review'},
|
||||
{re:/^Accept$/i, type:'agent_step'},
|
||||
{re:/^Allow/i, type:'permission'},
|
||||
{re:/^Approve/i, type:'agent_step'},
|
||||
{re:/^Deny$/i, type:'permission'},
|
||||
{re:/^Retry$/i, type:'error_recovery'},
|
||||
{re:/^Dismiss$/i, type:'error_recovery'},
|
||||
];
|
||||
|
||||
// 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
|
||||
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
|
||||
// Mark pending as auto_resolved so bot can update Discord message
|
||||
try {
|
||||
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending'))
|
||||
.filter((f: string) => f.endsWith('.json'));
|
||||
let resolvedCount = 0;
|
||||
let primaryCommand = '';
|
||||
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f: string) => f.endsWith('.json'));
|
||||
const nowMs = Date.now();
|
||||
for (const pf of pendingFiles) {
|
||||
const pfPath = path.join(bridgePath, 'pending', pf);
|
||||
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
||||
if (pd.status !== 'pending') continue;
|
||||
// Skip other projects' pendings
|
||||
if (pd.project_name && pd.project_name !== projectName) continue;
|
||||
// Match by step_index OR by recency (< 60s, any source)
|
||||
const ageMs = nowMs - (pd.timestamp * 1000);
|
||||
const isMatch = pd.step_index === lastPendingStepIndex
|
||||
|| (ageMs < 60_000 && ageMs >= 0);
|
||||
if (isMatch) {
|
||||
pd.status = 'auto_resolved';
|
||||
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
||||
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf} (age=${Math.round(ageMs/1000)}s)`);
|
||||
// FIX #3: Notify Discord that user approved locally
|
||||
writeChatSnapshot(`✅ **AG에서 직접 승인됨** (step ${lastPendingStepIndex})\n\n\`${(pd.command || '').substring(0, 200)}\``);
|
||||
}
|
||||
try {
|
||||
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
||||
if (pd.status !== 'pending') continue;
|
||||
if (pd.project_name && pd.project_name !== projectName) continue;
|
||||
// Limit to same session AND (same step or recent)
|
||||
const ageMs = nowMs - (pd.timestamp * 1000);
|
||||
const isMatch = (pd.conversation_id === activeSessionId) &&
|
||||
(pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
|
||||
if (isMatch) {
|
||||
pd.status = 'auto_resolved';
|
||||
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
||||
resolvedCount++;
|
||||
const cmd = pd.command || '';
|
||||
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;
|
||||
}
|
||||
consecutiveIdleCount = 0;
|
||||
@@ -2411,40 +2418,60 @@ function setupResponseWatcher() {
|
||||
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 {
|
||||
responseWatcher = fs.watch(responseDir, (event, filename) => {
|
||||
if (filename && filename.endsWith('.json') && event === 'rename') {
|
||||
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);
|
||||
}
|
||||
processAnyResponse(filename);
|
||||
}
|
||||
});
|
||||
console.log('Gravity Bridge: response watcher started');
|
||||
} catch (e: any) {
|
||||
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) {
|
||||
|
||||
14
gateway.py
14
gateway.py
@@ -20,6 +20,7 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
@@ -94,6 +95,15 @@ class GatewayAPI:
|
||||
window.append(now)
|
||||
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)
|
||||
|
||||
# ─── Health ───
|
||||
@@ -111,7 +121,7 @@ class GatewayAPI:
|
||||
"""Collector pushes a pending approval request."""
|
||||
try:
|
||||
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.setdefault("timestamp", time.time())
|
||||
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
|
||||
if snap_dir:
|
||||
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 = {
|
||||
"id": snap_id,
|
||||
"project_name": project,
|
||||
|
||||
Reference in New Issue
Block a user