The AITURK Chrome extension now registers the user's Chrome as the desktop backend's browser controller, the way the ChatGPT extension lets its agent work in the user's browser. The agent's existing browser tools (navigate, snapshot, click, type, scroll, back, press, get_images, vision) run in its own background tabs grouped as "AITURK", or a tab the user hands over. Backend: a local desktop lane in the browser-control broker. One controller registered from the loopback desktop transport is offered to every desktop session as a lane template; each session still gets its own exact scope on first use. The lane has its own switch (browser.extension_control.local_desktop, on by default); with no extension registered, or after it disconnects, the normal browser backend runs unchanged. browser_vision turns the extension's capture into the usual native/auxiliary vision result. Bridge: forwards controller commands only to a connection that registered, refuses unknown or oversized commands, relays results. Extension: service-worker controller with its own native connection, trusted chrome.debugger input (press/release only: a hover move waits 5 s on background tabs), refusal of password and payment-card fields and of browser, extension, file and Web Store pages, panel switch, Stop, tab hand-over, recent actions, Alt+Shift+A and right-click "send to AITURK". Cancelling Chrome's debugging bar stops the agent. Existing panel flows and privacy rules are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
17 KiB
JavaScript
354 lines
17 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
|
||
}
|
||
|
||
// Browser actions the extension may offer the IDE agent. The backend applies
|
||
// its own allowlist again; this one keeps unknown names off the wire.
|
||
export const BROWSER_CAPABILITIES = Object.freeze(['browser_navigate', 'browser_snapshot', 'browser_click',
|
||
'browser_type', 'browser_scroll', 'browser_back', 'browser_press', 'browser_get_images', 'browser_vision'])
|
||
const COMMAND_ID = /^[a-f0-9]{16,64}$/
|
||
|
||
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',
|
||
'browser.register', 'browser.result'].includes(message.action)) {
|
||
throw new Error('Desteklenmeyen eklenti isteği.')
|
||
}
|
||
const params = message.params || {}
|
||
if (message.action === 'browser.register') {
|
||
if (typeof params.enabled !== 'boolean') throw new Error('Tarayıcı denetimi ayarı geçersiz.')
|
||
if (params.enabled && (!Array.isArray(params.capabilities) || !params.capabilities.length ||
|
||
params.capabilities.some(name => !BROWSER_CAPABILITIES.includes(name)) ||
|
||
!/^[a-zA-Z0-9_.:-]{1,120}$/.test(params.controllerId || '') ||
|
||
!/^[a-zA-Z0-9_.:-]{1,120}$/.test(params.browserProfileId || ''))) {
|
||
throw new Error('Tarayıcı denetimi kaydı geçersiz.')
|
||
}
|
||
}
|
||
if (message.action === 'browser.result' &&
|
||
(typeof params.commandId !== 'string' || !COMMAND_ID.test(params.commandId) || typeof params.ok !== 'boolean' ||
|
||
(!params.ok && (typeof params.error !== 'string' || params.error.length > 4000)))) {
|
||
throw new Error('Tarayıcı komut sonucu geçersiz.')
|
||
}
|
||
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
|
||
// True once this connection offered the user's browser to the IDE agent.
|
||
this.controller = 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
|
||
// The backend drops a local browser controller with its transport.
|
||
this.controller = false
|
||
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) {
|
||
if (this.controller && ['browser.controller.command', 'browser.controller.cancel'].includes(event?.type)) {
|
||
this.onControllerEvent(event)
|
||
return
|
||
}
|
||
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ı'}` })
|
||
}
|
||
}
|
||
|
||
onControllerEvent(event) {
|
||
const payload = event.payload || {}
|
||
const commandId = payload.command_id
|
||
if (typeof commandId !== 'string' || !COMMAND_ID.test(commandId)) return
|
||
if (event.type === 'browser.controller.cancel') {
|
||
this.emit({ event: 'browser.cancel', commandId })
|
||
return
|
||
}
|
||
const refuse = error => void this.rpc('browser.controller.local.result',
|
||
{ command_id: commandId, ok: false, error }).catch(() => {})
|
||
if (!BROWSER_CAPABILITIES.includes(payload.action)) return refuse('Chrome bu tarayıcı işlemini desteklemiyor.')
|
||
const args = payload.arguments && typeof payload.arguments === 'object' && !Array.isArray(payload.arguments)
|
||
? payload.arguments : {}
|
||
// A frame over the native limit would drop the whole connection; refuse
|
||
// the one oversized command instead.
|
||
if (Buffer.byteLength(JSON.stringify(args)) > 256 * 1024) return refuse('Tarayıcı komutu çok büyük.')
|
||
this.emit({ event: 'browser.command', commandId, action: payload.action, arguments: args,
|
||
sessionId: typeof event.session_id === 'string' ? event.session_id.slice(0, 160) : '' })
|
||
}
|
||
|
||
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 'browser.register': {
|
||
if (!params.enabled) {
|
||
this.controller = false
|
||
const result = await this.rpc('browser.controller.local.detach', {})
|
||
return { registered: false, detached: Boolean(result?.detached) }
|
||
}
|
||
const result = await this.rpc('browser.controller.local.register', { protocol_version: 1,
|
||
controller_id: params.controllerId, browser_profile_id: params.browserProfileId,
|
||
capabilities: params.capabilities, profile: this.backend.profile || 'default' })
|
||
this.controller = true
|
||
return { registered: true, capabilities: Array.isArray(result?.capabilities) ? result.capabilities : [] }
|
||
}
|
||
case 'browser.result': {
|
||
const result = await this.rpc('browser.controller.local.result', params.ok
|
||
? { command_id: params.commandId, ok: true, result: params.result ?? null }
|
||
: { command_id: params.commandId, ok: false, error: params.error })
|
||
return { accepted: Boolean(result?.accepted) }
|
||
}
|
||
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 {}
|
||
}
|
||
}
|