feat(mobile): add Android development client and scoped IDE companion

This commit is contained in:
yilsem committed 2026-09-08 16:54:28 +03:00
1 parent 3332458708
commit 9f9f3226a1
84 files changed
+4371 -4

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DeliveryJournal, MobileGatewayClient } from '../../desktop/electron/mobile-adapter.mjs'
import { LiveTokenIssuer } from '../../desktop/electron/mobile-secrets.mjs'
test('durable reservation survives restart and concurrent resend; content stays out of journal', async () => {
const directory = await mkdtemp(join(tmpdir(), 'aiturk-delivery-'))
try {
const journal = new DeliveryJournal(directory), envelope = { text: 'private prompt', model: 'selected' }
const reservations = await Promise.all(Array.from({ length: 5 }, () => journal.reserve('phone', 'task', envelope)))
assert.equal(reservations.filter(r => r.fresh).length, 1)
const restarted = new DeliveryJournal(directory)
assert.equal((await restarted.reserve('phone', 'task', envelope)).state, 'unknown')
await journal.accepted(reservations.find(r => r.fresh))
assert.equal((await restarted.reserve('phone', 'task', envelope)).state, 'accepted')
await assert.rejects(restarted.reserve('phone', 'task', { text: 'changed' }), /farklı/)
const data = await readFile(join(directory, (await readdir(directory))[0]), 'utf8')
assert.ok(!data.includes('private prompt'))
} finally { await rm(directory, { recursive: true, force: true }) }
})
test('approval binds exact backend request and forbids permanent/model-generated permissions', async () => {
const rpcCalls = []
const client = new MobileGatewayClient(() => {}, () => {}, { authorize: async () => {} })
client.selected = { storedId: 'stored', runtimeId: 'runtime' }
client.onEvent({ session_id: 'runtime', type: 'approval.request', payload: { request_id: 'first', command: 'git status', choices: ['once', 'session', 'always', 'deny'] } })
client.rpc = async (...args) => { rpcCalls.push(args); return { resolved: 1 } }
for (const params of [{ requestId: 'old', choice: 'once' }, { requestId: 'first', choice: 'always' }])
await assert.rejects(client.handle({ id: 'req', action: 'approval.respond', params: { sessionId: 'stored', ...params } }))
assert.equal(rpcCalls.length, 0)
await client.handle({ id: 'req', action: 'approval.respond', params: { sessionId: 'stored', requestId: 'first', choice: 'once' } })
assert.deepEqual(rpcCalls[0], ['approval.respond', { session_id: 'runtime', request_id: 'first', choice: 'once', all: false }])
await assert.rejects(client.handle({ id: 'req', action: 'approval.respond', params: { sessionId: 'stored', requestId: 'first', choice: 'once' } }))
})
test('revocation rejects requests before touching IDE', async () => {
let touched = false
const client = new MobileGatewayClient(() => { touched = true }, () => {}, { authorize: async () => { throw new Error('revoked') } })
await assert.rejects(client.handle({ id: 'test', action: 'sessions.list' }), /revoked/)
assert.equal(touched, false)
})
test('ephemeral token is constrained, short lived, and rate limited; errors cannot echo key', async () => {
const requests = [], now = 1000000, key = 'fake-owned-test-key'
const issuer = new LiveTokenIssuer({ load: async () => ({ googleKey: key }) }, async (url, options) => {
requests.push({ url, options }); return { ok: true, json: async () => ({ name: 'auth_tokens/test' }) }
}, () => now)
const result = await issuer.issue('phone')
assert.ok(!JSON.stringify(result).includes(key))
const body = JSON.parse(requests[0].options.body)
assert.equal(body.uses, 1); assert.equal(Date.parse(body.expireTime) - now, 600000)
assert.ok(body.bidiGenerateContentSetup.model.startsWith('models/'))
await assert.rejects(issuer.issue('phone'), /30 saniye/)
const bad = new LiveTokenIssuer({ load: async () => ({ googleKey: key }) }, async () => ({ ok: false, status: 403, text: async () => key }))
await assert.rejects(bad.issue('phone'), error => !error.message.includes(key))
})
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { taskText, taskState } from '../src/behavior.mjs'
import { VoiceSession, pcm16 } from '../src/voice.mjs'
test('research envelope preserves corrections and never broadens permission', () => {
const text = taskText({ intent: 'research', objective: 'Erişilebilirliği incele', scope: 'Yalnız Android',
context: 'Mavi iptal edildi; yeşil olsun.', constraints: 'Kod değişikliği yapma.', output: 'Rapor', completion: 'Kanıtları belirt.' })
assert.match(text, /Değişiklik yapma/); assert.match(text, /Mavi iptal edildi; yeşil olsun/)
assert.throws(() => taskText({ intent: 'execute', objective: 'test' }), /eksik/)
})
test('voice tool only drafts, cannot approve or submit jobs', async () => {
const replies = [], drafts = []
const session = new VoiceSession({ onDraft: (...args) => drafts.push(args) })
session.send = frame => replies.push(frame)
for (const name of ['approval.respond', 'task.submit', 'message.stop']) {
await session.receive({ data: JSON.stringify({ toolCall: { functionCalls: [{ id: name, name, args: { choice: 'once' } }] } }) })
}
assert.equal(drafts.length, 0); assert.equal(replies.length, 3)
assert.ok(replies.every(reply => reply.toolResponse.functionResponses[0].response.error))
})
test('discussions and absent terminal events cannot claim completion', () => {
assert.equal(taskState({ event: 'transcription', text: 'Tamamlandı' }), null)
assert.match(taskState({ event: 'disconnected' }), /bilinmiyor/)
assert.match(taskState({ event: 'approval.request' }), /bekliyor/)
assert.match(taskState({ event: 'message.complete', status: 'error' }), /error/)
})
test('PCM is little endian, clipped, and uses 16k sample output', () => {
const bytes = Buffer.from(pcm16(new Float32Array([-2, 0, 2]), 16000), 'base64')
assert.equal(bytes.readInt16LE(0), -32768); assert.equal(bytes.readInt16LE(4), 32767)
assert.equal(Buffer.from(pcm16(new Float32Array(480), 48000), 'base64').length, 320)
})
test('stopping voice frees every microphone and queued sound without IDE calls', () => {
let tracks = 0, sounds = 0, closed = 0
const voice = new VoiceSession({ onState: () => {} })
voice.stream = { getTracks: () => [{ stop() { tracks++ } }] }
voice.sources.add({ stop() { sounds++ } }); voice.socket = { close() { closed++ } }
voice.stop(); voice.stop()
assert.deepEqual([tracks, sounds, closed], [1, 1, 1])
})