feat(mobile): bind device identities and bound voice usage in 0.1.4 dev

This commit is contained in:
yilsem committed 2026-09-12 12:11:18 +03:00
1 parent ac6687181e
commit 5f75dcc99a
24 files changed
+909 -47

No files matched your search

+4 -2
View File
@@ -3,12 +3,14 @@ import assert from 'node:assert/strict'
import qrcode from 'qrcode-generator'
import jsQR from 'jsqr'
import { parseInvite } from '../src/invite.mjs'
const invite = { version: 1, kind: 'aiturk-mobile-invite', inviteId: '11111111-1111-4111-8111-111111111111',
const invite = { version: 2, desktopFingerprint: Array(32).fill('AB').join(':'), kind: 'aiturk-mobile-invite', inviteId: '11111111-1111-4111-8111-111111111111',
desktopId: '22222222-2222-4222-8222-222222222222', secret: 'a'.repeat(43), expiresAt: new Date(600000).toISOString() }
test('invitation parser refuses URLs, expired and unknown invitations', () => {
assert.equal(parseInvite(JSON.stringify(invite), 0).desktopId, invite.desktopId)
for (const bad of ['https://evil.test', JSON.stringify({ ...invite, version: 2 }), JSON.stringify({ ...invite, secret: 'short' })])
assert.equal(parseInvite(JSON.stringify(invite), 0).desktopFingerprint, invite.desktopFingerprint)
for (const bad of ['https://evil.test', JSON.stringify({ ...invite, version: 1 }),
JSON.stringify({ ...invite, desktopFingerprint: undefined }), JSON.stringify({ ...invite, secret: 'short' })])
assert.throws(() => parseInvite(bad, 0))
assert.throws(() => parseInvite(JSON.stringify(invite), 600000))
})
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { normalizeFingerprint, assertRemoteIdentity, parsePhoneIdentity } from '../src/peer-identity.mjs'
import { openPhoneConnection } from '../src/transport.mjs'
const trusted = Array(32).fill('AB').join(':')
const other = Array(32).fill('CD').join(':')
const line = pin => `a=fingerprint:sha-256 ${pin}`
test('phone identity document binds the complete fingerprint to this pairing and device', () => {
const value = { kind: 'aiturk-phone-identity', version: 1, pairingId: 'pair',
deviceId: '33333333-3333-4333-8333-333333333333', fingerprint: trusted }
assert.deepEqual(parsePhoneIdentity(JSON.stringify(value), 'pair'), { deviceId: value.deviceId, fingerprint: trusted })
for (const bad of [{ ...value, pairingId: 'other' }, { ...value, fingerprint: 'AB' },
{ ...value, deviceId: '__proto__' }, { ...value, version: 2 }])
assert.throws(() => parsePhoneIdentity(JSON.stringify(bad), 'pair'))
})
test('phone refuses absent or malformed saved trust before signaling or opening WebRTC', async () => {
let calls = 0
for (const desktopFingerprints of [undefined, {}, { desktop: 'invalid' }, Object.create({ desktop: trusted })]) {
const account = { credentials: { desktopFingerprints }, call() { calls++; throw new Error('must not contact gateway') } }
await assert.rejects(openPhoneConnection(account, 'desktop', () => {}), /kimliği/)
}
assert.equal(calls, 0)
})
test('a peer identity is a complete SHA-256 fingerprint, not a name or partial digest', () => {
assert.equal(normalizeFingerprint(trusted.toLowerCase()), trusted)
for (const bad of [null, '', 'desktop', 'AB', trusted + ':AB', ' ' + trusted, trusted.replace('AB', 'ZZ')])
assert.throws(() => normalizeFingerprint(bad))
})
test('every advertised DTLS fingerprint must match the independently trusted peer', () => {
assert.doesNotThrow(() => assertRemoteIdentity(`v=0\r\n${line(trusted)}\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n${line(trusted.toLowerCase())}\r\n`, trusted))
for (const bad of ['', 'v=0', line(other), `${line(trusted)}\n${line(other)}`,
line(trusted).replace('sha-256', 'sha-1'), line(trusted) + ' extra', 'x'.repeat(131073)])
assert.throws(() => assertRemoteIdentity(bad, trusted))
assert.throws(() => assertRemoteIdentity(line(trusted), undefined))
})
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { VoiceSession } from '../src/voice.mjs'
import { VOICE_PRICING } from '../src/voice-usage.mjs'
test('stopping during audio resume never requests microphone access afterward', async t => {
let resume, microphoneRequests = 0, closed = 0
const originalContext = globalThis.AudioContext
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
globalThis.AudioContext = class {
resume() { return new Promise(resolve => { resume = resolve }) }
async close() { closed++ }
}
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {
mediaDevices: { async getUserMedia() {
microphoneRequests++
return { getTracks: () => [] }
} },
} })
t.after(() => {
globalThis.AudioContext = originalContext
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator)
else delete globalThis.navigator
})
const voice = new VoiceSession({ model: VOICE_PRICING.model, onError() {} })
t.after(() => voice.stop())
const starting = voice.start()
voice.stop()
resume()
await starting
assert.equal(microphoneRequests, 0)
assert.equal(closed, 1)
assert.equal(voice.socket, undefined)
})
test('a stopped session cannot reopen audio resources', async t => {
let created = 0
const originalContext = globalThis.AudioContext
globalThis.AudioContext = class {
constructor() { created++ }
async resume() {}
async close() {}
}
t.after(() => { globalThis.AudioContext = originalContext })
const voice = new VoiceSession({ model: VOICE_PRICING.model, onError() {} })
voice.stop()
await voice.start(false)
// Clean up an incorrectly restarted interval even when this assertion fails.
clearInterval(voice.usageTimer)
assert.equal(created, 0)
})
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { VOICE_PRICING, VoiceUsage, estimateUsage, voiceLimits, usageLabel } from '../src/voice-usage.mjs'
import { VoiceSession } from '../src/voice.mjs'
const model = VOICE_PRICING.model
const report = { promptTokenCount: 2000, responseTokenCount: 1000, totalTokenCount: 3000,
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 500 }, { modality: 'AUDIO', tokenCount: 1500 }],
responseTokensDetails: [{ modality: 'TEXT', tokenCount: 100 }, { modality: 'AUDIO', tokenCount: 900 }] }
const expected = (500 * VOICE_PRICING.input.TEXT + 1500 * VOICE_PRICING.input.AUDIO +
100 * VOICE_PRICING.output.TEXT + 900 * VOICE_PRICING.output.AUDIO) / 1e6
test('reported input history is billed again on each request, including identical reports', () => {
const usage = new VoiceUsage(model, { minutes: 10, usd: expected * 1.5 })
assert.equal(usage.add(report).reached, false)
const next = usage.add(report)
assert.equal(next.lowUsd, expected * 2); assert.equal(next.highUsd, expected * 2)
assert.equal(next.reports, 2); assert.equal(next.reached, true)
})
test('missing or unknown modality yields a finite range, not zero/free usage', () => {
for (const details of [undefined, [{ modality: 'toString', tokenCount: 3000 }]]) {
const cost = estimateUsage({ promptTokenCount: 3000, promptTokensDetails: details }, model)
assert.ok(cost.lowUsd > 0); assert.ok(cost.highUsd > cost.lowUsd); assert.equal(cost.uncertain, true)
}
const extra = estimateUsage({ ...report, thoughtsTokenCount: 100, toolUsePromptTokenCount: 100 }, model)
assert.ok(extra.highUsd > expected); assert.equal(extra.uncertain, true)
const noBreakdown = estimateUsage({ totalTokenCount: 1000 }, model)
assert.ok(noBreakdown.highUsd > 0); assert.equal(noBreakdown.uncertain, true)
})
test('invalid metadata cannot poison the running total or silently bypass the limit', () => {
const usage = new VoiceUsage(model)
usage.add(report); const before = usage.snapshot()
for (const metadata of [{}, { promptTokenCount: -1 }, { responseTokenCount: NaN },
{ promptTokenCount: '100' }, { ...report, totalTokenCount: 1 },
{ promptTokenCount: 1, promptTokensDetails: [{ modality: 'AUDIO', tokenCount: 2 }] }]) {
assert.throws(() => usage.add(metadata)); assert.deepEqual(usage.snapshot(), before)
}
assert.throws(() => estimateUsage(report, 'unknown-model'))
})
test('limits validate actual numeric bounds and usage label does not claim quota or invoice', () => {
assert.deepEqual(voiceLimits({ minutes: '3', usd: '0.05' }), { minutes: 3, usd: 0.05 })
for (const value of [{ minutes: 0, usd: 1 }, { minutes: 11, usd: 1 }, { minutes: 1, usd: 0 },
{ minutes: 1, usd: Infinity }, { minutes: 1.5, usd: 0.25 }, { minutes: 1, usd: 11 }]) assert.throws(() => voiceLimits(value))
assert.match(usageLabel(new VoiceUsage(model).snapshot(), 0), /henüz bilinmiyor/)
const usage = new VoiceUsage(model); usage.add(report)
assert.match(usageLabel(usage.snapshot(), 30), /IDE modeli dahil değildir/)
})
test('usage threshold closes voice before a co-delivered tool can submit IDE work', async () => {
let dispatched = 0, closed = 0, tracks = 0
const notices = []
const voice = new VoiceSession({ model, limits: { minutes: 10, usd: 0.01 }, onText: (_, text) => notices.push(text),
onState: text => notices.push(text), onSubmit: () => { dispatched++ } })
voice.startedAt = voice.now(); voice.lastUsageAt = voice.startedAt
voice.stream = { getTracks: () => [{ stop() { tracks++ } }] }
voice.socket = { readyState: 1, close() { closed++ }, send() { throw new Error('must not send after cap') } }
await voice.receive({ data: JSON.stringify({ usageMetadata: report,
toolCall: { functionCalls: [{ id: '1', name: 'submit_task', args: {} }] } }) })
voice.text = VoiceSession.prototype.text.bind(voice)
assert.throws(() => voice.text('Bunu yap'))
voice.result({ status: 'complete', text: 'Done' }); voice.stop()
assert.equal(voice.stopped, true); assert.deepEqual([dispatched, closed, tracks], [0, 1, 1])
assert.ok(notices.some(text => text.includes('maliyet sınırına')))
assert.ok(notices.some(text => text.includes('IDE işi devam edebilir')))
})
test('malformed usage closes an established session with a visible error', async () => {
const errors = []
const voice = new VoiceSession({ model, onError: text => errors.push(text) })
voice.ready = true
await voice.receive({ data: JSON.stringify({ usageMetadata: {} }) })
assert.equal(voice.stopped, true); assert.match(errors[0], /hesaplanamadı/)
})
for (const scenario of [
{ minutes: 1, elapsed: 60000, reason: /süresi doldu/ },
{ minutes: 10, elapsed: 120000, reason: /bildirimi alınamadı/ },
]) test(`real voice lifecycle timer stops without resuming: ${scenario.minutes} minute setting`, async t => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] })
let now = 0, connections = 0, closed = 0
const messages = [], usage = []
const originalContext = globalThis.AudioContext, originalSocket = globalThis.WebSocket
globalThis.AudioContext = class { async resume() {} async close() {} }
globalThis.WebSocket = class { constructor() { connections++ } close() { closed++ } }
t.after(() => { globalThis.AudioContext = originalContext; globalThis.WebSocket = originalSocket })
const voice = new VoiceSession({ token: 'test-only', model, now: () => now,
limits: { minutes: scenario.minutes, usd: 0.25 }, onText() {}, onError: text => messages.push(text),
onState: text => messages.push(text), onUsage: value => usage.push(value) })
t.after(() => voice.stop())
await voice.start(false)
await voice.receive({ data: JSON.stringify({ setupComplete: {} }) })
now = scenario.elapsed; t.mock.timers.tick(scenario.elapsed)
assert.equal(voice.stopped, true); assert.deepEqual([connections, closed], [1, 1])
assert.ok(messages.some(text => scenario.reason.test(text)))
assert.equal(usage.at(-1).seconds, scenario.elapsed / 1000)
t.mock.timers.tick(600000); assert.equal(connections, 1)
})