274 lines
12 KiB
JavaScript
274 lines
12 KiB
JavaScript
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 query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||
const data = await this.rest(`/api/sessions?${query}`)
|
||
const rows = Array.isArray(data) ? data : data.sessions || []
|
||
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').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 {}
|
||
}
|
||
}
|