import { randomBytes, timingSafeEqual } from 'node:crypto' import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { createServer } from 'node:net' import { join } from 'node:path' // This is a capability bridge, not a general-purpose gateway proxy. Neither // backend credentials nor filesystem/RPC access are exposed to the browser. export const MAX_FRAME = 512 * 1024 const MAX_TEXT = 48000 const clip = (value, limit = 8000) => typeof value === 'string' ? value.slice(0, limit) : '' const contentText = value => typeof value === 'string' ? value : Array.isArray(value) ? value.filter(part => part?.type === 'text').map(part => part.text || '').join('\n') : '' export function publicMessages(rows) { const messages = (Array.isArray(rows) ? rows : []).filter(row => ['user', 'assistant'].includes(row.role) && row.display_kind !== 'hidden' ).slice(-40).map(row => ({ id: typeof (row.id ?? row.row_id) === 'number' ? (row.id ?? row.row_id) : undefined, role: row.role, text: clip(contentText(row.display_content ?? row.content ?? row.text)), })) // The Chrome native limit counts UTF-8 JSON bytes, not JS characters. // Reserve room for the opening response's in-flight text and metadata; // escaped controls and CJK text must not disconnect a large transcript. const messageBudget = Math.floor((MAX_FRAME - 128000) / Math.max(1, messages.length)) for (const message of messages) { while (Buffer.byteLength(JSON.stringify(message)) > messageBudget) { message.text = message.text.slice(0, Math.floor(message.text.length * 0.8)) } } return messages } export function validateRequest(message) { if (!message || typeof message.id !== 'string' || message.id.length > 80 || !['status', 'sessions.list', 'session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action)) { throw new Error('Desteklenmeyen eklenti isteği.') } const params = message.params || {} if (['session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action) && (typeof params.sessionId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(params.sessionId))) { throw new Error('Geçersiz konuşma kimliği.') } if (message.action === 'message.send' && (typeof params.text !== 'string' || !params.text.trim() || params.text.length > MAX_TEXT)) { throw new Error('Mesaj boş olamaz ve 48.000 karakteri aşamaz.') } return params } export class ChromeGatewayClient { constructor(resolveBackend, emit, webSocket = globalThis.WebSocket) { this.resolveBackend = resolveBackend this.emit = emit this.WebSocket = webSocket this.pending = new Map() this.nextId = 0 this.selected = null this.closed = false } async connect() { if (this.closed) throw new Error('Bağlantı kapandı. Yeniden bağlanın.') if (this.socket?.readyState === 1) return const backend = await this.resolveBackend() const url = new URL(backend.baseUrl) const wsUrl = new URL(backend.wsUrl) if (backend.mode === 'remote' || url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || wsUrl.protocol !== 'ws:' || wsUrl.hostname !== '127.0.0.1' || wsUrl.port !== url.port || !backend.token) { throw new Error('Chrome beta yalnızca bu bilgisayardaki yerel IDE bağlantısını destekler.') } if (this.closed) throw new Error('Bağlantı kapandı.') this.backend = backend const socket = new this.WebSocket(backend.wsUrl) this.socket = socket socket.addEventListener('message', event => { let frame try { frame = JSON.parse(String(event.data)) } catch { return } if (frame.id !== undefined) { const pending = this.pending.get(frame.id) if (!pending) return this.pending.delete(frame.id) clearTimeout(pending.timer) if (frame.error) pending.reject(new Error(clip(frame.error.message, 1000) || 'IDE isteği başarısız.')) else pending.resolve(frame.result) } else if (frame.method === 'event') this.onEvent(frame.params) }) socket.addEventListener('close', () => { for (const pending of this.pending.values()) { clearTimeout(pending.timer) pending.reject(new Error('IDE bağlantısı kesildi. Gönderimi tekrar denemeden önce geçmişi kontrol edin.')) } this.pending.clear() this.selected = null if (!this.closed) this.emit({ event: 'disconnected', message: 'IDE bağlantısı kesildi. Yeniden bağlanın.' }) }) await new Promise((resolve, reject) => { const timer = setTimeout(() => { socket.close(); reject(new Error('IDE bağlantısı zaman aşımına uğradı.')) }, 15000) socket.addEventListener('open', () => { clearTimeout(timer); resolve() }, { once: true }) socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('IDE bağlantısı kurulamadı.')) }, { once: true }) }) } rpc(method, params) { const id = ++this.nextId return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id) reject(new Error('IDE yanıtı beklenirken süre doldu. Gönderimi tekrar denemeden önce geçmişi kontrol edin.')) }, 60000) this.pending.set(id, { resolve, reject, timer }) try { this.socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })) } catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error) } }) } async rest(path) { const response = await fetch(`${this.backend.baseUrl}${path}`, { headers: { 'X-Hermes-Session-Token': this.backend.token }, signal: AbortSignal.timeout(30000), redirect: 'error', }) if (!response.ok) throw new Error(`IDE verisi alınamadı (${response.status}).`) return response.json() } scope() { return this.backend.profile ? { profile: this.backend.profile } : {} } async history(sessionId) { const query = new URLSearchParams({ ...this.scope(), limit: '40', order: 'latest', include_compacted: 'true' }) const page = await this.rest(`/api/sessions/${encodeURIComponent(sessionId)}/messages?${query}`) return { sessionId, messages: publicMessages(page.messages), limited: true } } onEvent(event) { const selected = this.selected if (!selected || event?.session_id !== selected.runtimeId) return const payload = event.payload || {} const type = event.type if (['message.start', 'message.delta', 'message.complete', 'session.error', 'error'].includes(type)) { if (type === 'message.start') selected.running = true if (type === 'message.complete') selected.running = false this.emit({ event: type, sessionId: selected.storedId, text: clip(payload.text || payload.message, 16000), status: clip(payload.status, 100) }) } else if (['approval.request', 'clarify.request', 'sudo.request'].includes(type)) { this.emit({ event: 'attention', sessionId: selected.storedId, message: 'Bu konuşma IDE’de onay veya ek bilgi bekliyor. Devam etmek için IDE’ye geçin.' }) } else if (type === 'tool.start') { this.emit({ event: 'activity', sessionId: selected.storedId, message: `Araç çalışıyor: ${clip(payload.name || payload.tool, 100) || 'IDE aracı'}` }) } } async handle(message) { const params = validateRequest(message) await this.connect() switch (message.action) { case 'status': return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true } case 'sessions.list': { const rows = [], seen = new Set() // The real sessions API validates limit <= 100. Filter before paging: // cron/CLI traffic must not crowd IDE conversations out of the list. for (let offset = 0; offset < 200; offset += 100) { const query = new URLSearchParams({ ...this.scope(), limit: '100', offset: String(offset), source: 'desktop', min_messages: '1', archived: 'exclude', order: 'recent' }) const data = await this.rest(`/api/sessions?${query}`) const page = Array.isArray(data) ? data : data.sessions || [] for (const row of page) { const id = row.id || row.session_id if (row.source !== 'desktop' || !id || seen.has(id)) continue seen.add(id) rows.push(row) } if (page.length < 100) break } return { sessions: rows.slice(0, 200).map(row => ({ id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180), model: clip(row.model, 160), updatedAt: row.last_active || row.started_at, })), profile: this.backend.profile || 'default', limited: true } } case 'session.open': { // Clear foreground first: late events from the previous task cannot leak. this.selected = null const result = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96, source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() }) if (!result.session_id) throw new Error('IDE konuşma kimliğini döndürmedi.') this.selected = { storedId: params.sessionId, runtimeId: result.session_id, running: Boolean(result.running) } return { ...await this.history(params.sessionId), running: Boolean(result.running), model: clip(result.info?.model, 160), inflight: clip(result.inflight?.assistant, 16000), attention: Boolean(result.pending_approval || result.pending_clarify) } } case 'session.history': return this.history(params.sessionId) case 'message.send': case 'message.stop': { if (this.selected?.storedId !== params.sessionId) throw new Error('Önce bu konuşmayı seçin.') if (message.action === 'message.stop') { await this.rpc('session.interrupt', { session_id: this.selected.runtimeId }) return { stopped: true } } await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true }) return { accepted: true } } } } close() { this.closed = true this.socket?.close() } } export async function startChromeBridge({ directory, resolveBackend }) { await mkdir(directory, { recursive: true }) const token = randomBytes(32).toString('hex') const pipe = `aiturk-chrome-${randomBytes(16).toString('hex')}` const discovery = join(directory, 'chrome-bridge.json') const sockets = new Set() const server = createServer(socket => { if (sockets.size >= 8) { socket.destroy(); return } sockets.add(socket) let buffer = '' let authenticated = false let queued = 0 let chain = Promise.resolve() const timer = setTimeout(() => socket.destroy(), 5000) const send = frame => { if (socket.destroyed) return const data = JSON.stringify(frame) if (Buffer.byteLength(data) > MAX_FRAME || socket.writableLength > MAX_FRAME * 2) { socket.destroy(); return } socket.write(data + '\n') } const client = new ChromeGatewayClient(resolveBackend, send) socket.setEncoding('utf8') socket.on('data', chunk => { buffer += chunk if (Buffer.byteLength(buffer) > MAX_FRAME) { socket.destroy(); return } let end while ((end = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, end) buffer = buffer.slice(end + 1) let message try { message = JSON.parse(line) } catch { socket.destroy(); return } if (!authenticated) { if (!message || typeof message.token !== 'string' || !/^[a-f0-9]{64}$/.test(message.token) || !timingSafeEqual(Buffer.from(message.token), Buffer.from(token))) { socket.destroy(); return } authenticated = true clearTimeout(timer) continue } if (++queued > 12) { socket.destroy(); return } chain = chain.then(async () => { if (socket.destroyed) return try { send({ id: message.id, result: await client.handle(message) }) } catch (error) { send({ id: message.id, error: clip(error.message, 1200) || 'IDE bağlantısı başarısız.' }) } }).finally(() => { queued-- }) } }) socket.on('error', () => {}) socket.on('close', () => { clearTimeout(timer); sockets.delete(socket); client.close() }) }) await new Promise((resolve, reject) => { server.once('error', reject) server.listen(`\\\\.\\pipe\\${pipe}`, resolve) }) const temporary = `${discovery}.${process.pid}.tmp` try { await writeFile(temporary, JSON.stringify({ protocol: 1, pipe, token }), { mode: 0o600 }) await rename(temporary, discovery) } catch (error) { server.close() throw error } return async () => { for (const socket of sockets) socket.destroy() server.close() // A newer IDE instance may have replaced discovery; never remove its file. try { if (JSON.parse(await readFile(discovery, 'utf8')).token === token) await unlink(discovery) } catch {} } }