chore(voice): security, cleanup, resilience
All checks were successful
Deploy / deploy (push) Successful in 1m47s
All checks were successful
Deploy / deploy (push) Successful in 1m47s
Безопасность: - Rate-limit на /api/voice/chat (20/мин per cookie/IP, env VOICE_RATE_LIMIT). Защищает от случайных циклов и утечки PIN. - Усечение user prompt'а до 4000 символов в /api/voice/chat. - Tool-loop защита от циклов: если LLM дважды просит тот же tool с теми же args — прерываем (раньше мог уйти в бесконечный цикл при tool error'ах). Чистка кода: - lib/debug.ts — vlog/vwarn/verror гейтят браузерные логи за NEXT_PUBLIC_VOICE_DEBUG=1 (или localStorage 'voice-debug=1'). Серверные console.log оставлены — полезны в Docker logs. - lib/audio-wav.ts — вынесена дублированная floatToWav из VoiceController. - Удалены orphan компоненты FocusCard.tsx и CountdownCard.tsx (не подключены, отвергнуты по UX-фидбеку). Resilience: - WakeWordDetector: drop-on-busy в onChunk — на медленных устройствах (Android, бюджетный CPU) backlog inference больше не копится. - voice-history fallback на /tmp/voice-history если /data не примонтирован (локальная разработка / нестандартная конфигурация).
This commit is contained in:
@@ -18,6 +18,31 @@ import {
|
||||
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5'
|
||||
const MAX_TOKENS = parseInt(process.env.VOICE_MAX_TOKENS || '300', 10)
|
||||
const MAX_TOOL_ROUNDS = 4
|
||||
const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VOICE_RATE_LIMIT || '20', 10)
|
||||
|
||||
// In-memory rate-limit per IP / cookie (host один — Docker контейнер).
|
||||
// Защита от случайного бесконечного цикла или утечки PIN: даже если
|
||||
// auth_token утечёт, вызов /api/voice/chat будет ограничен.
|
||||
const rateBuckets = new Map<string, { count: number; resetAt: number }>()
|
||||
function rateLimit(key: string): boolean {
|
||||
const now = Date.now()
|
||||
const b = rateBuckets.get(key)
|
||||
if (!b || b.resetAt <= now) {
|
||||
rateBuckets.set(key, { count: 1, resetAt: now + 60_000 })
|
||||
return true
|
||||
}
|
||||
if (b.count >= RATE_LIMIT_PER_MINUTE) return false
|
||||
b.count++
|
||||
return true
|
||||
}
|
||||
// Гигиена: чистим старые бакеты периодически (раз в 5 минут максимум).
|
||||
let lastSweep = 0
|
||||
function sweep() {
|
||||
const now = Date.now()
|
||||
if (now - lastSweep < 5 * 60_000) return
|
||||
lastSweep = now
|
||||
for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k)
|
||||
}
|
||||
|
||||
let _client: Anthropic | null = null
|
||||
function client(): Anthropic {
|
||||
@@ -44,11 +69,23 @@ function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) {
|
||||
type AgentId = 'cosmo' | 'lusya'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Rate-limit по auth_token (или x-voice-internal — для loopback'а от tools).
|
||||
// Идентифицируем клиента: cookie auth_token > x-voice-internal > IP > 'anon'.
|
||||
const cookie = req.headers.get('cookie') || ''
|
||||
const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i)
|
||||
const internal = req.headers.get('x-voice-internal') || ''
|
||||
const fwd = req.headers.get('x-forwarded-for') || ''
|
||||
const ratekey = tokenMatch?.[1] || (internal ? 'internal' : '') || fwd.split(',')[0].trim() || 'anon'
|
||||
sweep()
|
||||
if (!rateLimit(ratekey)) {
|
||||
return NextResponse.json({ error: 'rate_limited' }, { status: 429 })
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
|
||||
return NextResponse.json({ error: 'text required' }, { status: 400 })
|
||||
}
|
||||
const userText: string = body.text.trim()
|
||||
const userText: string = body.text.trim().slice(0, 4000) // защита от gigantic prompts
|
||||
const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo'
|
||||
|
||||
// Echo command в орб
|
||||
@@ -78,6 +115,9 @@ export async function POST(req: Request) {
|
||||
|
||||
let finalText = ''
|
||||
const initialUserIdx = history.length - 1
|
||||
// Защита от tool-cycling: запоминаем последний (name, args) — если LLM
|
||||
// дважды подряд просит одно и то же, прерываем цикл.
|
||||
let lastToolSig = ''
|
||||
|
||||
try {
|
||||
const c = client()
|
||||
@@ -109,6 +149,18 @@ export async function POST(req: Request) {
|
||||
apiMessages.push({ role: 'assistant', content: resp.content as any })
|
||||
|
||||
if (resp.stop_reason === 'tool_use' && toolUses.length) {
|
||||
// Сигнатура текущего раунда — для loop-guard.
|
||||
const sig = toolUses
|
||||
.map((t) => `${t.name}:${JSON.stringify(t.input)}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
if (sig === lastToolSig) {
|
||||
console.warn('[voice/chat] tool cycle detected, breaking loop')
|
||||
finalText += '\nНе получилось выполнить запрос.'
|
||||
break
|
||||
}
|
||||
lastToolSig = sig
|
||||
|
||||
const toolResults: Anthropic.ToolResultBlockParam[] = []
|
||||
for (const tu of toolUses) {
|
||||
console.log(`[voice/chat] tool ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)
|
||||
|
||||
Reference in New Issue
Block a user