Harden Chrome bridge framing and preserve panel state for IDE beta 7
This commit is contained in:
1 parent
fb6f57cd36
commit
57876d11f0
8 files changed
+186
-12
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
AITURK Chrome — Windows beta 0.1.0
|
AITURK Chrome — Windows beta 0.1.0
|
||||||
|
|
||||||
GEREKSİNİMLER
|
GEREKSİNİMLER
|
||||||
Windows 10/11, Chrome 116 veya üzeri, Chrome köprüsü içeren AITURK IDE.
|
Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.7 veya üzeri.
|
||||||
Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır.
|
Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır.
|
||||||
İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut
|
İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut
|
||||||
konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez.
|
konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# AITURK Chrome beta
|
||||||
|
|
||||||
|
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
|
||||||
|
Requires AITURK IDE 1.0.0-beta.7 or later; see `KURULUM.txt` for Turkish setup.
|
||||||
|
|
||||||
|
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
||||||
|
messaging. The per-user .NET Framework host authenticates to Electron's random
|
||||||
|
named pipe with a separate capability from the user-local discovery file.
|
||||||
|
Electron retains the gateway token, allows only six semantic operations and
|
||||||
|
reuses the existing local backend. It resumes the stored conversation, maps its
|
||||||
|
runtime identity, then submits only the new prompt. No parallel agent backend,
|
||||||
|
history reconstruction, model API key in the browser or telemetry is involved.
|
||||||
|
|
||||||
|
Only the active local profile is supported. The list searches titles among
|
||||||
|
desktop sessions in the most recent 200 records. The transcript shows the last
|
||||||
|
40 records, with up to 8,000 characters per visible message; the model's existing
|
||||||
|
context is not truncated by this display policy. Approvals and clarifications
|
||||||
|
remain in the IDE. A backend event is stamped once and delivered to every
|
||||||
|
attached viewer so the desktop can still show and answer these requests.
|
||||||
|
|
||||||
|
## Build and verification
|
||||||
|
|
||||||
|
From the repository root, on Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/build-chrome.ps1 -OutputDirectory C:/path/to/new-output-directory
|
||||||
|
$env:AITURK_TEST_NATIVE_HOST = 'C:/path/printed/by/build/AITURK Chrome Kur.exe'
|
||||||
|
node --test apps/chrome/tests/bridge.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The build uses the Windows .NET Framework C# compiler, not a downloaded binary.
|
||||||
|
`AITURK_TEST_NATIVE_HOST` enables the real framing / named-pipe / HTTP / WebSocket
|
||||||
|
integration test against a disposable fixture, without installing registry
|
||||||
|
entries or invoking a paid model. Without it the native host test is skipped.
|
||||||
|
|
||||||
|
UI tests require the existing desktop workspace's jsdom dependency:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm ci --workspace apps/desktop --include-workspace-root
|
||||||
|
node --test apps/chrome/tests/panel.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run backend fan-out and replay tests with the canonical hermetic runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/run_tests.sh -j 1 --paths tests/tui_gateway/test_chrome_viewer_fanout.py:tests/test_tui_gateway_event_replay.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Publish an IDE installer from the same clean commit (including the backend
|
||||||
|
change), the Chrome ZIP and their SHA-256 checksums together. Update the download
|
||||||
|
page only after the immutable release assets are available. This is an unsigned
|
||||||
|
developer-mode beta, not a Chrome Web Store or signed Windows release.
|
||||||
@@ -3,6 +3,7 @@ import { capturePageContext, composePrompt } from './page-context.js'
|
|||||||
const $ = id => document.getElementById(id)
|
const $ = id => document.getElementById(id)
|
||||||
let port, connected = false, sessions = [], selected = '', generation = 0, nextId = 0
|
let port, connected = false, sessions = [], selected = '', generation = 0, nextId = 0
|
||||||
let stream, attachment = null, sending = false, opening = false, connectionGeneration = 0
|
let stream, attachment = null, sending = false, opening = false, connectionGeneration = 0
|
||||||
|
let bufferedEvents = [], turnGeneration = 0
|
||||||
const pending = new Map()
|
const pending = new Map()
|
||||||
const notice = text => { $('notice').textContent = text; $('notice').hidden = !text }
|
const notice = text => { $('notice').textContent = text; $('notice').hidden = !text }
|
||||||
const activity = text => { $('activity').textContent = text }
|
const activity = text => { $('activity').textContent = text }
|
||||||
@@ -112,6 +113,7 @@ function paintHistory(messages) {
|
|||||||
async function openSession(id) {
|
async function openSession(id) {
|
||||||
const intent = ++generation
|
const intent = ++generation
|
||||||
selected = id; opening = Boolean(id); stream = null
|
selected = id; opening = Boolean(id); stream = null
|
||||||
|
bufferedEvents = []
|
||||||
attachment = null; $('attachment').hidden = true
|
attachment = null; $('attachment').hidden = true
|
||||||
$('message').value = ''; $('stop').hidden = true
|
$('message').value = ''; $('stop').hidden = true
|
||||||
notice(''); controls()
|
notice(''); controls()
|
||||||
@@ -127,24 +129,31 @@ async function openSession(id) {
|
|||||||
if (data.attention) notice('Bu konuşma IDE’de onay veya ek bilgi bekliyor.')
|
if (data.attention) notice('Bu konuşma IDE’de onay veya ek bilgi bekliyor.')
|
||||||
await chrome.storage.local.set({ selectedSession: id })
|
await chrome.storage.local.set({ selectedSession: id })
|
||||||
} catch (error) { if (intent === generation) { selected = ''; notice(error.message) } }
|
} catch (error) { if (intent === generation) { selected = ''; notice(error.message) } }
|
||||||
finally { if (intent === generation) { opening = false; controls() } }
|
finally {
|
||||||
|
if (intent === generation) {
|
||||||
|
opening = false; controls()
|
||||||
|
const events = bufferedEvents; bufferedEvents = []
|
||||||
|
events.forEach(handleEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reconcile() {
|
async function reconcile() {
|
||||||
const intent = generation, sessionId = selected
|
const intent = generation, sessionId = selected, turn = turnGeneration
|
||||||
try {
|
try {
|
||||||
const data = await request('session.history', { sessionId })
|
const data = await request('session.history', { sessionId })
|
||||||
if (intent === generation && sessionId === selected && !opening) paintHistory(data.messages)
|
if (intent === generation && sessionId === selected && !opening && !stream && turn === turnGeneration) paintHistory(data.messages)
|
||||||
} catch (error) { if (intent === generation) notice(error.message) }
|
} catch (error) { if (intent === generation) notice(error.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEvent(frame) {
|
function handleEvent(frame) {
|
||||||
if (frame.event === 'disconnected') { disconnect(frame.message); return }
|
if (frame.event === 'disconnected') { disconnect(frame.message); return }
|
||||||
if (frame.event === 'error') { notice(frame.message); return }
|
if (frame.event === 'error') { notice(frame.message); return }
|
||||||
if (frame.sessionId !== selected || opening) return
|
if (frame.sessionId !== selected) return
|
||||||
|
if (opening) { if (bufferedEvents.length < 2000) bufferedEvents.push(frame); return }
|
||||||
const nearBottom = $('transcript').scrollHeight - $('transcript').scrollTop - $('transcript').clientHeight < 120
|
const nearBottom = $('transcript').scrollHeight - $('transcript').scrollTop - $('transcript').clientHeight < 120
|
||||||
switch (frame.event) {
|
switch (frame.event) {
|
||||||
case 'message.start': stream = null; $('stop').hidden = false; activity('AITURK düşünüyor…'); break
|
case 'message.start': turnGeneration++; stream = null; $('stop').hidden = false; activity('AITURK düşünüyor…'); break
|
||||||
case 'message.delta':
|
case 'message.delta':
|
||||||
stream ||= bubble('assistant', '', true)
|
stream ||= bubble('assistant', '', true)
|
||||||
stream.textContent = (stream.textContent + frame.text).slice(-100000)
|
stream.textContent = (stream.textContent + frame.text).slice(-100000)
|
||||||
@@ -169,14 +178,17 @@ $('composer').addEventListener('submit', async event => {
|
|||||||
const text = composePrompt(draft, attachment)
|
const text = composePrompt(draft, attachment)
|
||||||
const intent = generation, sessionId = selected
|
const intent = generation, sessionId = selected
|
||||||
sending = true; controls(); notice(''); activity('IDE’ye gönderiliyor…')
|
sending = true; controls(); notice(''); activity('IDE’ye gönderiliyor…')
|
||||||
|
const optimistic = bubble('user', text); scrollEnd()
|
||||||
try {
|
try {
|
||||||
await request('message.send', { sessionId, text })
|
await request('message.send', { sessionId, text })
|
||||||
if (intent !== generation) return
|
if (intent !== generation) return
|
||||||
// Keep server history authoritative; optimistic text is replaced on finish.
|
// Keep server history authoritative; optimistic text is replaced on finish.
|
||||||
bubble('user', text); scrollEnd()
|
|
||||||
$('message').value = ''; attachment = null; $('attachment').hidden = true
|
$('message').value = ''; attachment = null; $('attachment').hidden = true
|
||||||
$('stop').hidden = false; activity('Mesaj IDE’ye iletildi; iş sürüyorsa sıraya alınır.')
|
$('stop').hidden = false; activity('Mesaj IDE’ye iletildi; iş sürüyorsa sıraya alınır.')
|
||||||
} catch (error) { notice(error.message + ' Taslağınız korundu. Otomatik tekrar gönderilmedi.') }
|
} catch (error) {
|
||||||
|
optimistic.parentElement?.remove()
|
||||||
|
notice(error.message + ' Taslağınız korundu. Otomatik tekrar gönderilmedi.')
|
||||||
|
}
|
||||||
finally { sending = false; controls() }
|
finally { sending = false; controls() }
|
||||||
})
|
})
|
||||||
$('message').addEventListener('input', controls)
|
$('message').addEventListener('input', controls)
|
||||||
|
|||||||
@@ -147,11 +147,13 @@ test('Windows named pipe requires separate capability, native host frames Turkis
|
|||||||
const stop = await startChromeBridge({ directory, resolveBackend: f.resolve })
|
const stop = await startChromeBridge({ directory, resolveBackend: f.resolve })
|
||||||
t.after(stop)
|
t.after(stop)
|
||||||
const discovery = JSON.parse(await readFile(join(directory, 'chrome-bridge.json'), 'utf8'))
|
const discovery = JSON.parse(await readFile(join(directory, 'chrome-bridge.json'), 'utf8'))
|
||||||
|
for (const hello of [null, { token: 'x'.repeat(64) }, { token: 'ı'.repeat(64) }, {}]) {
|
||||||
const bad = connect(`\\\\.\\pipe\\${discovery.pipe}`)
|
const bad = connect(`\\\\.\\pipe\\${discovery.pipe}`)
|
||||||
await once(bad, 'connect')
|
await once(bad, 'connect')
|
||||||
const closed = once(bad, 'close')
|
const closed = once(bad, 'close')
|
||||||
bad.write(JSON.stringify({ token: 'x'.repeat(64) }) + '\n')
|
bad.write(JSON.stringify(hello) + '\n')
|
||||||
await closed
|
await closed
|
||||||
|
}
|
||||||
const child = spawn(process.env.AITURK_TEST_NATIVE_HOST,
|
const child = spawn(process.env.AITURK_TEST_NATIVE_HOST,
|
||||||
['chrome-extension://ikcplfhgapaabedillejmggjcabpkolp/'],
|
['chrome-extension://ikcplfhgapaabedillejmggjcabpkolp/'],
|
||||||
{ env: { ...process.env, LOCALAPPDATA: local }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
{ env: { ...process.env, LOCALAPPDATA: local }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { capturePageContext } from '../extension/page-context.js'
|
||||||
|
|
||||||
|
const { JSDOM } = createRequire(import.meta.url)('jsdom')
|
||||||
|
const eventHub = () => ({ listeners: [], addListener(callback) { this.listeners.push(callback) }, emit(value) { this.listeners.forEach(callback => callback(value)) } })
|
||||||
|
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||||
|
async function settle(condition) {
|
||||||
|
for (let count = 0; count < 100; count++) { if (condition()) return; await tick() }
|
||||||
|
assert.ok(condition(), 'UI did not settle')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function panel(t, respond) {
|
||||||
|
const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8')
|
||||||
|
const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' })
|
||||||
|
const calls = []
|
||||||
|
const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) {
|
||||||
|
calls.push(message)
|
||||||
|
queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port))
|
||||||
|
} }
|
||||||
|
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option,
|
||||||
|
chrome: { runtime: { connectNative: () => port }, storage: { local: { get: async () => ({}), set: async () => {} } } } })
|
||||||
|
await import(`../extension/panel.js?test=${Math.random()}`)
|
||||||
|
t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] })
|
||||||
|
const get = id => dom.window.document.getElementById(id)
|
||||||
|
await settle(() => !get('sessions').disabled)
|
||||||
|
return { dom, calls, port, get, async select(id) {
|
||||||
|
get('sessions').value = id
|
||||||
|
get('sessions').dispatchEvent(new dom.window.Event('change'))
|
||||||
|
await settle(() => !get('sessions').disabled)
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureResponse(message, emit) {
|
||||||
|
const result = message.action === 'status' ? { profile: 'default' }
|
||||||
|
: message.action === 'sessions.list' ? { profile: 'default', sessions: [{ id: 'one', title: 'Birinci iş' }, { id: 'two', title: 'İkinci iş' }] }
|
||||||
|
: message.action === 'session.open' ? { messages: [{ role: 'user', text: '<img src=x onerror=alert(1)> önceden yazıldı' }], model: 'IDE modeli' }
|
||||||
|
: { messages: [] }
|
||||||
|
emit({ id: message.id, result })
|
||||||
|
}
|
||||||
|
|
||||||
|
test('panel safely renders real history and puts the submitted user before early streaming tokens', async t => {
|
||||||
|
const p = await panel(t, (message, emit) => {
|
||||||
|
if (message.action !== 'message.send') return fixtureResponse(message, emit)
|
||||||
|
emit({ event: 'message.start', sessionId: 'one' })
|
||||||
|
emit({ event: 'message.delta', sessionId: 'one', text: 'İlk yanıt' })
|
||||||
|
emit({ id: message.id, result: { accepted: true } })
|
||||||
|
})
|
||||||
|
await p.select('one')
|
||||||
|
assert.equal(p.get('transcript').querySelector('img'), null)
|
||||||
|
assert.ok(p.get('transcript').textContent.includes('<img'))
|
||||||
|
p.get('message').value = 'Buradan devam edelim'
|
||||||
|
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
|
||||||
|
await settle(() => p.get('message').value === '')
|
||||||
|
const messages = [...p.get('transcript').querySelectorAll('.text')].map(node => node.textContent)
|
||||||
|
assert.deepEqual(messages.slice(-2), ['Buradan devam edelim', 'İlk yanıt'])
|
||||||
|
assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1)
|
||||||
|
assert.equal(p.calls.find(call => call.action === 'message.send').params.sessionId, 'one')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('task switching drops old events; disconnect keeps draft and does not retry a mutation', async t => {
|
||||||
|
const p = await panel(t, (message, emit, port) => {
|
||||||
|
if (message.action === 'message.send') { port.onDisconnect.emit(); return }
|
||||||
|
fixtureResponse(message, emit)
|
||||||
|
})
|
||||||
|
await p.select('one')
|
||||||
|
await p.select('two')
|
||||||
|
p.port.onMessage.emit({ event: 'message.delta', sessionId: 'one', text: 'YANLIŞ KONUŞMA' })
|
||||||
|
assert.ok(!p.get('transcript').textContent.includes('YANLIŞ'))
|
||||||
|
p.get('message').value = 'Kaybolmasın'
|
||||||
|
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
|
||||||
|
await settle(() => p.get('notice').textContent.includes('Taslağınız korundu'))
|
||||||
|
assert.equal(p.get('message').value, 'Kaybolmasın')
|
||||||
|
assert.equal(p.calls.filter(call => call.action === 'message.send').length, 1)
|
||||||
|
assert.equal(p.get('send').disabled, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('events arriving while history opens are not silently lost', async t => {
|
||||||
|
const p = await panel(t, (message, emit) => {
|
||||||
|
if (message.action === 'session.open') {
|
||||||
|
emit({ event: 'message.start', sessionId: message.params.sessionId })
|
||||||
|
emit({ event: 'message.delta', sessionId: message.params.sessionId, text: 'Açılırken gelen yanıt' })
|
||||||
|
}
|
||||||
|
fixtureResponse(message, emit)
|
||||||
|
})
|
||||||
|
await p.select('one')
|
||||||
|
assert.ok(p.get('transcript').textContent.includes('Açılırken gelen yanıt'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit page capture excludes form values and strips URL credentials in query and fragment', t => {
|
||||||
|
const dom = new JSDOM('<body><p>Görünür içerik</p><input value="secret"><textarea>private draft</textarea><div hidden>hidden secret</div><script>private code</script></body>',
|
||||||
|
{ url: 'https://example.test/page?token=secret#private' })
|
||||||
|
Object.assign(globalThis, { window: dom.window, document: dom.window.document, location: dom.window.location,
|
||||||
|
NodeFilter: dom.window.NodeFilter, getComputedStyle: dom.window.getComputedStyle })
|
||||||
|
dom.window.HTMLElement.prototype.getClientRects = () => [{ width: 100 }]
|
||||||
|
t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'location', 'NodeFilter', 'getComputedStyle']) delete globalThis[key] })
|
||||||
|
const context = capturePageContext()
|
||||||
|
assert.equal(context.text, 'Görünür içerik')
|
||||||
|
assert.equal(context.url, 'https://example.test/page')
|
||||||
|
assert.equal(context.selection, false)
|
||||||
|
})
|
||||||
@@ -223,7 +223,7 @@ export async function startChromeBridge({ directory, resolveBackend }) {
|
|||||||
let message
|
let message
|
||||||
try { message = JSON.parse(line) } catch { socket.destroy(); return }
|
try { message = JSON.parse(line) } catch { socket.destroy(); return }
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
if (typeof message.token !== 'string' || message.token.length !== token.length ||
|
if (!message || typeof message.token !== 'string' || !/^[a-f0-9]{64}$/.test(message.token) ||
|
||||||
!timingSafeEqual(Buffer.from(message.token), Buffer.from(token))) { socket.destroy(); return }
|
!timingSafeEqual(Buffer.from(message.token), Buffer.from(token))) { socket.destroy(); return }
|
||||||
authenticated = true
|
authenticated = true
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@@ -245,8 +245,13 @@ export async function startChromeBridge({ directory, resolveBackend }) {
|
|||||||
server.listen(`\\\\.\\pipe\\${pipe}`, resolve)
|
server.listen(`\\\\.\\pipe\\${pipe}`, resolve)
|
||||||
})
|
})
|
||||||
const temporary = `${discovery}.${process.pid}.tmp`
|
const temporary = `${discovery}.${process.pid}.tmp`
|
||||||
|
try {
|
||||||
await writeFile(temporary, JSON.stringify({ protocol: 1, pipe, token }), { mode: 0o600 })
|
await writeFile(temporary, JSON.stringify({ protocol: 1, pipe, token }), { mode: 0o600 })
|
||||||
await rename(temporary, discovery)
|
await rename(temporary, discovery)
|
||||||
|
} catch (error) {
|
||||||
|
server.close()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
return async () => {
|
return async () => {
|
||||||
for (const socket of sockets) socket.destroy()
|
for (const socket of sockets) socket.destroy()
|
||||||
server.close()
|
server.close()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.6",
|
"version": "1.0.0-beta.7",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"version": "1.0.0-beta.6",
|
"version": "1.0.0-beta.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/core": "0.2.23",
|
"@assistant-ui/core": "0.2.23",
|
||||||
|
|||||||
Reference in new issue
Block a user