chore(voice): security, cleanup, resilience
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:
Cosmo
2026-04-27 12:44:18 +00:00
parent 3211d62198
commit 05b300d472
9 changed files with 169 additions and 683 deletions

View File

@@ -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)})`)