Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4cc0915c | ||
|
|
58dbb4523b | ||
|
|
2ed9a6b2de |
No files matched your search
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AITURK_CLI_KEY_HOLD_MS,
|
AITURK_CLI_KEY_HOLD_MS,
|
||||||
|
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||||
type AiturkCliExit,
|
type AiturkCliExit,
|
||||||
AiturkLoginError,
|
AiturkLoginError,
|
||||||
createAiturkCliRunner,
|
createAiturkCliRunner,
|
||||||
@@ -38,6 +39,8 @@ const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'a
|
|||||||
function harness(
|
function harness(
|
||||||
options: {
|
options: {
|
||||||
start?: { status: number; body?: unknown }
|
start?: { status: number; body?: unknown }
|
||||||
|
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
||||||
|
startErrors?: Error[]
|
||||||
polls?: ({ status: number; body?: unknown } | Error)[]
|
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||||
cliExits?: AiturkCliExit[]
|
cliExits?: AiturkCliExit[]
|
||||||
validate?: unknown
|
validate?: unknown
|
||||||
@@ -47,6 +50,7 @@ function harness(
|
|||||||
) {
|
) {
|
||||||
const calls: { url: string; init: any }[] = []
|
const calls: { url: string; init: any }[] = []
|
||||||
const polls = [...(options.polls ?? [])]
|
const polls = [...(options.polls ?? [])]
|
||||||
|
const startErrors = [...(options.startErrors ?? [])]
|
||||||
const cliExits = [...(options.cliExits ?? [0])]
|
const cliExits = [...(options.cliExits ?? [0])]
|
||||||
const clock = { now: 1_000_000 }
|
const clock = { now: 1_000_000 }
|
||||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||||
@@ -55,6 +59,12 @@ function harness(
|
|||||||
calls.push({ url, init })
|
calls.push({ url, init })
|
||||||
|
|
||||||
if (url.endsWith('/start')) {
|
if (url.endsWith('/start')) {
|
||||||
|
const failure = startErrors.shift()
|
||||||
|
|
||||||
|
if (failure) {
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
|
||||||
const r = options.start ?? { status: 201, body: startBody() }
|
const r = options.start ?? { status: 201, body: startBody() }
|
||||||
|
|
||||||
return { status: r.status, json: async () => r.body }
|
return { status: r.status, json: async () => r.body }
|
||||||
@@ -154,6 +164,47 @@ describe('aiturk login: start', () => {
|
|||||||
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
||||||
|
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
||||||
|
const pending = h.login.start()
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||||
|
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
||||||
|
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||||
|
expect(h.openExternal).not.toHaveBeenCalled()
|
||||||
|
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||||
|
|
||||||
|
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||||
|
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||||
|
// One code only: the approval page is opened once.
|
||||||
|
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
||||||
|
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
||||||
|
const failing = twice.login.start().catch(e => e)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||||
|
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||||
|
expect((await failing).message).toBe('aiturk-login:unavailable')
|
||||||
|
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||||
|
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
||||||
|
|
||||||
|
for (const status of [500, 503, 429, 422]) {
|
||||||
|
const answered = harness({ start: { status } })
|
||||||
|
|
||||||
|
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
||||||
|
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||||
|
expect(answered.timers).toHaveLength(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
||||||
|
|
||||||
|
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||||
|
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||||
|
expect(timedOut.timers).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects malformed codes', async () => {
|
it('rejects malformed codes', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
|
|
||||||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||||||
|
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||||||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||||
|
|
||||||
@@ -237,16 +239,29 @@ export function createAiturkLogin(deps: AiturkLoginDeps) {
|
|||||||
await cancelOnServer(previous.deviceCode)
|
await cancelOnServer(previous.deviceCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: AiturkFetchResponse
|
let response: AiturkFetchResponse | undefined
|
||||||
|
|
||||||
try {
|
// A long-idle process can fail its first request on a dead pooled
|
||||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||||
client: 'ide',
|
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||||
device_name: deviceName(deps.hostname()),
|
// one start cannot knowingly leave two codes behind. A timeout already
|
||||||
also_cli: true
|
// used its whole budget and is not retried.
|
||||||
})
|
for (let attempt = 1; response === undefined; attempt++) {
|
||||||
} catch {
|
try {
|
||||||
throw new AiturkLoginError('unavailable')
|
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||||
|
client: 'ide',
|
||||||
|
device_name: deviceName(deps.hostname()),
|
||||||
|
also_cli: true
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const name = (error as null | { name?: unknown })?.name
|
||||||
|
|
||||||
|
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||||||
|
throw new AiturkLoginError('unavailable')
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execFileSync, spawn } from 'node:child_process'
|
import { execFile, execFileSync, spawn } from 'node:child_process'
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
shell,
|
shell,
|
||||||
systemPreferences
|
systemPreferences
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
|
import { WebSocket as RelayWebSocket } from 'ws'
|
||||||
|
|
||||||
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
|
||||||
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
import { createAiturkCliRunner, createAiturkLogin } from './aiturk-login'
|
||||||
@@ -17866,16 +17867,33 @@ function getMobileLink(): MobileLink {
|
|||||||
return { baseUrl: connection.baseUrl, token: connection.token }
|
return { baseUrl: connection.baseUrl, token: connection.token }
|
||||||
},
|
},
|
||||||
isRemoteMode: mobileLinkRemoteMode,
|
isRemoteMode: mobileLinkRemoteMode,
|
||||||
firewall: createWindowsFirewall({ spawn, platform: process.platform, env: process.env }),
|
firewall: createWindowsFirewall({
|
||||||
|
spawn,
|
||||||
|
platform: process.platform,
|
||||||
|
env: process.env,
|
||||||
|
// Read-only rule query (fixed arguments, no shell, no elevation). netsh answers in the
|
||||||
|
// console code page; latin1 keeps every byte and the parser needs no translated word.
|
||||||
|
run: (file, args) =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
execFile(file, args, { windowsHide: true, timeout: 8000, encoding: 'latin1', maxBuffer: 256 * 1024 }, (error, stdout) => {
|
||||||
|
const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? ((error as { code?: unknown }).code as number) : null) : 0
|
||||||
|
|
||||||
|
resolve({ code, stdout: String(stdout ?? '') })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
fetch: (url, init) => fetch(url, init as RequestInit),
|
fetch: (url, init) => fetch(url, init as RequestInit),
|
||||||
|
// Outbound relay socket (turkservis.online) and the pinned loopback sockets to our own proxy.
|
||||||
|
WebSocket: RelayWebSocket,
|
||||||
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
addresses: async () => selectLanAddresses(os.networkInterfaces(), await defaultRouteAddress()),
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
pcName: os.hostname(),
|
pcName: os.hostname(),
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
// Method + path + status only (no token, code, query or body). Successful
|
// Method + path + status only (no token, code, query or body). Successful
|
||||||
// API traffic is not logged; pairing and refusals are, for diagnosis.
|
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||||
|
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||||
log: line => {
|
log: line => {
|
||||||
if (/ \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||||
rememberLog(`[mobile-lan] ${line}`)
|
rememberLog(`[mobile-lan] ${line}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,56 +6,72 @@ import { drawQr } from './mobile/qr.mjs'
|
|||||||
const TEXT = {
|
const TEXT = {
|
||||||
tr: {
|
tr: {
|
||||||
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
eyebrow: 'AITURK · BİLGİSAYAR', title: 'Telefon bağlantısı',
|
||||||
intro: 'AITURK Mobil uygulaması, aynı Wi-Fi ağındayken bu bilgisayardaki IDE konuşmalarınızı görür ve sohbet eder. Hesap girişi gerekmez.',
|
intro: 'Telefonunuz internetin olduğu her yerden bu bilgisayardaki IDE’ye bağlanır; aynı Wi-Fi gerekmez. Bağlantı uçtan uca şifrelidir. Bilgisayar açık ve AITURK IDE çalışıyor olmalı.',
|
||||||
enabled: 'Telefon bağlantısı açık', disabled: 'Telefon bağlantısı kapalı',
|
toggle: 'Telefon bağlantısını kullan',
|
||||||
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
remote: 'IDE şu an uzak bir ağ geçidine bağlı. Telefon bağlantısı yalnız yerel IDE bağlantısıyla çalışır; Ayarlar’dan yerel bağlantıyı seçin.',
|
||||||
firewallNeeded: 'Windows Güvenlik Duvarı izni henüz verilmedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
relayLabel: 'İnternet üzerinden bağlantı', lanLabel: 'Aynı Wi-Fi’da doğrudan bağlantı',
|
||||||
firewallDenied: 'Windows izin penceresi reddedildi; Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
relay: { connected: 'hazır', connecting: 'bağlanıyor…', failed: 'kurulamadı (yeniden denenecek)', idle: 'beklemede (telefon eklenince bağlanır)',
|
||||||
firewallFailed: 'Windows Güvenlik Duvarı kuralı eklenemedi. İzin olmadan telefon bu bilgisayara ulaşamayabilir.',
|
replaced: 'başka bir AITURK IDE aynı kaydı kullanıyor (30 sn sonra yeniden denenecek)', busy: 'servis dolu (yeniden denenecek)' },
|
||||||
retry: 'Yeniden dene', addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
lanOpen: port => `açık (bağlantı noktası ${port})`, lanOff: 'kapalı', lanIdle: 'izin verildi (telefon eklenince açılır)',
|
||||||
|
lanNoRule: 'Windows Güvenlik Duvarı izni yok', lanDenied: 'kapalı (Windows izin penceresi reddedildi)', lanFailed: 'kapalı (Güvenlik Duvarı kuralı eklenemedi)',
|
||||||
|
lanAllow: 'Aynı Wi-Fi’da doğrudan bağlantıya da izin ver', allow: 'İzin ver', lanDisallow: 'Doğrudan bağlantıyı kapat',
|
||||||
|
reissued: 'Röle kimliği yenilendi. Daha önce eklenen telefonları yeniden eşleştirin: telefonda bu bilgisayarı kaldırıp “Telefon ekle” ile yeniden bağlayın.',
|
||||||
|
addPhone: 'Telefon ekle', cancel: 'Vazgeç',
|
||||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||||
orCode: 'ya da telefonda şu kodu yazın:',
|
orCode: 'ya da telefonda şu kodu yazın:',
|
||||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.',
|
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
|
||||||
sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||||
|
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||||
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
devices: 'Bağlı telefonlar', noDevices: 'Henüz bağlı telefon yok.',
|
||||||
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
added: 'Eklenme', lastSeen: 'Son görülme', online: 'şu an bağlı', remove: 'Kaldır',
|
||||||
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
removed: name => `${name} kaldırıldı.`, paired: name => `${name} bağlandı.`,
|
||||||
footer: port => `Bağlantı noktası: ${port}`, removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
removeRule: 'Güvenlik Duvarı kuralını kaldır', ruleRemoved: 'Güvenlik Duvarı kuralı kaldırıldı.',
|
||||||
|
renew: 'Röle kimliğini yenile', renewed: 'Röle kimliği yenilendi. Telefonları yeniden eşleştirin.',
|
||||||
|
renewConfirm: 'Röle kimliği yenilenirse bağlı telefonların hepsi kaldırılır ve yeniden eşleştirilmeleri gerekir. Devam edilsin mi?',
|
||||||
errors: {
|
errors: {
|
||||||
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
remote_mode: 'IDE uzak ağ geçidine bağlıyken telefon bağlantısı açılamaz.',
|
||||||
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
vault_unavailable: 'Windows güvenli anahtar kasası kullanılamıyor; telefon bağlantısı açılamadı.',
|
||||||
not_enabled: 'Önce telefon bağlantısını açın.',
|
not_enabled: 'Önce telefon bağlantısını açın.',
|
||||||
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor.',
|
no_lan_address: 'Bu bilgisayar bir yerel ağa (Wi-Fi ya da kablo) bağlı görünmüyor ve internet üzerinden bağlantı da kurulamadı.',
|
||||||
|
relay_unavailable: 'İnternete ulaşılamıyor, yeniden deneyin.',
|
||||||
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
port_unavailable: 'Boş bağlantı noktası bulunamadı (47321–47340 kullanımda).',
|
||||||
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
failed: 'İşlem tamamlanamadı. Yeniden deneyin.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
eyebrow: 'AITURK · COMPUTER', title: 'Phone connection',
|
||||||
intro: 'The AITURK Mobile app can see and continue this computer’s IDE conversations while on the same Wi-Fi network. No account sign-in is needed.',
|
intro: 'Your phone connects to the IDE on this computer from anywhere with internet access; the same Wi-Fi is not needed. The connection is end-to-end encrypted. The computer must be on and AITURK IDE must be running.',
|
||||||
enabled: 'Phone connection is on', disabled: 'Phone connection is off',
|
toggle: 'Use phone connection',
|
||||||
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
remote: 'The IDE is connected to a remote gateway. Phone connection only works with the local IDE connection; choose the local connection in Settings.',
|
||||||
firewallNeeded: 'Windows Firewall permission has not been granted yet. Without it the phone may not reach this computer.',
|
relayLabel: 'Connection over the internet', lanLabel: 'Direct connection on the same Wi-Fi',
|
||||||
firewallDenied: 'The Windows permission prompt was declined, so the Firewall rule was not added. Without it the phone may not reach this computer.',
|
relay: { connected: 'ready', connecting: 'connecting…', failed: 'could not be set up (will retry)', idle: 'waiting (connects once a phone is added)',
|
||||||
firewallFailed: 'The Windows Firewall rule could not be added. Without it the phone may not reach this computer.',
|
replaced: 'another AITURK IDE is using the same registration (will retry in 30 s)', busy: 'service is full (will retry)' },
|
||||||
retry: 'Try again', addPhone: 'Add a phone', cancel: 'Cancel',
|
lanOpen: port => `on (port ${port})`, lanOff: 'off', lanIdle: 'allowed (opens once a phone is added)',
|
||||||
|
lanNoRule: 'no Windows Firewall permission', lanDenied: 'off (the Windows permission prompt was declined)', lanFailed: 'off (the Firewall rule could not be added)',
|
||||||
|
lanAllow: 'Also allow a direct connection on the same Wi-Fi', allow: 'Allow', lanDisallow: 'Turn direct connection off',
|
||||||
|
reissued: 'The relay identity was renewed. Pair the phones added earlier again: remove this computer on the phone and connect with “Add a phone”.',
|
||||||
|
addPhone: 'Add a phone', cancel: 'Cancel',
|
||||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||||
orCode: 'or type this code on the phone:',
|
orCode: 'or type this code on the phone:',
|
||||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.',
|
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
|
||||||
sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||||
|
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||||
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
devices: 'Connected phones', noDevices: 'No phone is connected yet.',
|
||||||
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
added: 'Added', lastSeen: 'Last seen', online: 'connected now', remove: 'Remove',
|
||||||
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
removed: name => `${name} was removed.`, paired: name => `${name} connected.`,
|
||||||
footer: port => `Port: ${port}`, removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
removeRule: 'Remove the Firewall rule', ruleRemoved: 'The Firewall rule was removed.',
|
||||||
|
renew: 'Renew the relay identity', renewed: 'The relay identity was renewed. Pair the phones again.',
|
||||||
|
renewConfirm: 'Renewing the relay identity removes every connected phone; they must be paired again. Continue?',
|
||||||
errors: {
|
errors: {
|
||||||
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
remote_mode: 'Phone connection cannot be turned on while the IDE uses a remote gateway.',
|
||||||
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
vault_unavailable: 'The Windows secure key store is unavailable; phone connection could not be turned on.',
|
||||||
not_enabled: 'Turn phone connection on first.',
|
not_enabled: 'Turn phone connection on first.',
|
||||||
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable).',
|
no_lan_address: 'This computer does not seem to be on a local network (Wi-Fi or cable) and the internet connection could not be set up either.',
|
||||||
|
relay_unavailable: 'The internet cannot be reached, try again.',
|
||||||
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
port_unavailable: 'No free port was found (47321–47340 are in use).',
|
||||||
failed: 'That did not work. Try again.',
|
failed: 'That did not work. Try again.',
|
||||||
},
|
},
|
||||||
@@ -66,8 +82,9 @@ const t = TEXT[lang], host = window.mobileHost, $ = id => document.getElementByI
|
|||||||
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
const when = value => new Date(value).toLocaleString(lang === 'tr' ? 'tr-TR' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||||
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||||
['t-or-code', 'orCode'], ['t-same-wifi', 'sameWifi'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['firewall-retry', 'retry'],
|
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||||
|
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||||
|
|
||||||
let current = null, drawnQr = '', busy = false
|
let current = null, drawnQr = '', busy = false
|
||||||
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||||
@@ -83,10 +100,20 @@ function tick() {
|
|||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
current = state
|
current = state
|
||||||
$('enabled').checked = state.enabled; $('t-enabled').textContent = state.enabled ? t.enabled : t.disabled
|
$('enabled').checked = state.enabled
|
||||||
$('remote-box').hidden = !state.remote
|
$('remote-box').hidden = !state.remote
|
||||||
const firewall = { needed: t.firewallNeeded, denied: t.firewallDenied, failed: t.firewallFailed }[state.firewall]
|
// Status rows say what is REALLY up right now: the relay socket, the LAN socket, the Windows rule as queried.
|
||||||
$('firewall-box').hidden = !(state.enabled && firewall); $('firewall-text').textContent = firewall || ''
|
const relay = state.relay || { status: 'off' }, lan = state.lan || { direct: false, listening: false, port: null, firewall: 'unknown' }
|
||||||
|
$('status-box').hidden = !state.enabled || state.remote
|
||||||
|
const relayKind = relay.status === 'failed' && t.relay[relay.code] ? relay.code : relay.status === 'off' ? 'idle' : relay.status
|
||||||
|
$('relay-status').textContent = t.relay[relayKind]; $('relay-status').dataset.state = relay.status
|
||||||
|
$('reissued-box').hidden = !(state.enabled && relay.reissued)
|
||||||
|
const noRule = lan.direct && lan.firewall === 'missing'
|
||||||
|
$('lan-status').textContent = lan.listening ? t.lanOpen(lan.port) : noRule ? t.lanNoRule : lan.direct ? t.lanIdle
|
||||||
|
: state.firewall === 'denied' ? t.lanDenied : state.firewall === 'failed' ? t.lanFailed : t.lanOff
|
||||||
|
$('lan-status').dataset.state = lan.listening ? 'connected' : noRule ? 'failed' : 'off'
|
||||||
|
$('lan-allow').hidden = lan.direct && !noRule; $('lan-allow').textContent = noRule ? t.allow : t.lanAllow
|
||||||
|
$('lan-disallow').hidden = !lan.direct
|
||||||
$('pair-section').hidden = !state.enabled || state.remote
|
$('pair-section').hidden = !state.enabled || state.remote
|
||||||
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
$('pair-begin').hidden = Boolean(state.pairing); $('pair-live').hidden = !state.pairing
|
||||||
if (state.pairing) {
|
if (state.pairing) {
|
||||||
@@ -94,6 +121,10 @@ function render(state) {
|
|||||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||||
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||||
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||||
|
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
|
||||||
|
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
|
||||||
|
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||||
|
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||||
tick()
|
tick()
|
||||||
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
} else { drawnQr = ''; $('pair-code').textContent = '' }
|
||||||
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
$('devices-section').hidden = !state.enabled && state.devices.length === 0
|
||||||
@@ -109,9 +140,9 @@ function render(state) {
|
|||||||
label.append(name, added, seen); row.append(label, button)
|
label.append(name, added, seen); row.append(label, button)
|
||||||
return row
|
return row
|
||||||
}))
|
}))
|
||||||
$('footer').hidden = state.port === null
|
$('footer').hidden = !state.enabled || state.remote
|
||||||
$('footer-info').textContent = state.port === null ? '' : t.footer(state.port)
|
$('relay-renew').hidden = !relay.ready
|
||||||
$('firewall-remove').hidden = state.firewall !== 'ok'
|
$('firewall-remove').hidden = lan.firewall !== 'allowed' || state.firewall === 'not-needed'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(work) {
|
async function run(work) {
|
||||||
@@ -126,7 +157,10 @@ async function refresh() { try { render(await host.call('state')) } catch (error
|
|||||||
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||||
$('firewall-retry').onclick = () => run(async () => { notice(''); render(await host.call('firewall.retry')) })
|
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
|
||||||
|
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||||
|
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||||
|
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||||
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
$('firewall-remove').onclick = () => run(async () => { render(await host.call('firewall.remove')); notice(t.ruleRemoved) })
|
||||||
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
host.onEvent(event => { if (!event?.state) return; render(event.state); if (event.type === 'paired') notice(t.paired(event.name)) })
|
||||||
setInterval(tick, 1000)
|
setInterval(tick, 1000)
|
||||||
|
|||||||
@@ -14,3 +14,14 @@
|
|||||||
.device button { flex:none; margin:0 }
|
.device button { flex:none; margin:0 }
|
||||||
footer { margin-top:34px }
|
footer { margin-top:34px }
|
||||||
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
button.link { background:none; color:var(--accent); min-height:0; padding:0; margin:0; font-weight:600; text-decoration:underline }
|
||||||
|
.status-row { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:4px 12px; border-bottom:1px solid var(--stroke); padding:10px 0 }
|
||||||
|
.status-row strong { color:var(--text); overflow-wrap:anywhere }
|
||||||
|
.status-row strong::before { content:''; display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:8px; background:var(--stroke) }
|
||||||
|
.status-row strong[data-state=connected]::before { background:var(--accent) }
|
||||||
|
.status-row strong[data-state=failed]::before { background:var(--danger) }
|
||||||
|
.status-actions { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:12px 0 }
|
||||||
|
.status-actions button { margin:0; min-height:44px }
|
||||||
|
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||||
|
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||||
|
footer button.link { min-height:44px }
|
||||||
|
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
|
||||||
@@ -6,7 +6,12 @@
|
|||||||
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
<label class="switch"><input id="enabled" type="checkbox"><span id="t-enabled"></span></label>
|
||||||
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
<p id="notice" class="status" role="status" aria-live="polite" hidden></p>
|
||||||
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
<section id="remote-box" class="warning" hidden><p id="t-remote"></p></section>
|
||||||
<section id="firewall-box" class="warning" hidden><p id="firewall-text"></p><button id="firewall-retry" class="secondary"></button></section>
|
<section id="status-box" hidden>
|
||||||
|
<div class="status-row"><span id="t-relay-label"></span><strong id="relay-status" role="status" aria-live="polite"></strong></div>
|
||||||
|
<div class="status-row"><span id="t-lan-label"></span><strong id="lan-status" role="status" aria-live="polite"></strong></div>
|
||||||
|
<div class="status-actions"><button id="lan-allow" class="secondary"></button><button id="lan-disallow" class="link" hidden></button></div>
|
||||||
|
</section>
|
||||||
|
<section id="reissued-box" class="warning" hidden><p id="t-reissued"></p></section>
|
||||||
<section id="pair-section" hidden>
|
<section id="pair-section" hidden>
|
||||||
<button id="pair-begin"></button>
|
<button id="pair-begin"></button>
|
||||||
<div id="pair-live" hidden>
|
<div id="pair-live" hidden>
|
||||||
@@ -15,7 +20,9 @@
|
|||||||
<p id="t-or-code"></p>
|
<p id="t-or-code"></p>
|
||||||
<p id="pair-code" class="code" aria-live="off"></p>
|
<p id="pair-code" class="code" aria-live="off"></p>
|
||||||
<p id="pair-code-path"></p>
|
<p id="pair-code-path"></p>
|
||||||
|
<button id="pair-republish" class="link" hidden></button>
|
||||||
<p id="pair-countdown" class="countdown"></p>
|
<p id="pair-countdown" class="countdown"></p>
|
||||||
|
<p id="pair-security" class="security"></p>
|
||||||
<p id="t-same-wifi" class="hint"></p>
|
<p id="t-same-wifi" class="hint"></p>
|
||||||
<button id="pair-cancel" class="secondary"></button>
|
<button id="pair-cancel" class="secondary"></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,5 +30,5 @@
|
|||||||
<section id="devices-section" hidden>
|
<section id="devices-section" hidden>
|
||||||
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
<h2 id="t-devices"></h2><p id="devices-empty"></p><div id="devices"></div>
|
||||||
</section>
|
</section>
|
||||||
<footer id="footer" hidden><small id="footer-info"></small> <button id="firewall-remove" class="link"></button></footer>
|
<footer id="footer" hidden><button id="relay-renew" class="link" hidden></button> <button id="firewall-remove" class="link" hidden></button></footer>
|
||||||
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
</main><script type="module" src="mobile-host-ui.mjs"></script></body></html>
|
||||||
@@ -20,9 +20,16 @@ export function createMobileWindow({ assets, link, show = true }) {
|
|||||||
disable: () => link.disable(),
|
disable: () => link.disable(),
|
||||||
'pair.begin': () => link.pairBegin(),
|
'pair.begin': () => link.pairBegin(),
|
||||||
'pair.cancel': () => link.pairCancel(),
|
'pair.cancel': () => link.pairCancel(),
|
||||||
|
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
|
||||||
|
'pair.republish': () => link.pairRepublish(),
|
||||||
devices: () => link.devices(),
|
devices: () => link.devices(),
|
||||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||||
'firewall.retry': () => link.firewallRetry(),
|
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||||
|
'lan.allow': () => link.lanAllow(),
|
||||||
|
'lan.disallow': () => link.lanDisallow(),
|
||||||
|
'firewall.allow': () => link.lanAllow(),
|
||||||
|
'firewall.retry': () => link.lanAllow(),
|
||||||
|
'relay.renew': () => link.relayRenew(),
|
||||||
'firewall.remove': () => link.firewallRemove(),
|
'firewall.remove': () => link.firewallRemove(),
|
||||||
}
|
}
|
||||||
const handler = async (event, action, params) => {
|
const handler = async (event, action, params) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
@@ -18,20 +19,28 @@ import { WebSocket, WebSocketServer } from 'ws'
|
|||||||
import { createSelfSignedCertificate } from './mobile-lan-cert'
|
import { createSelfSignedCertificate } from './mobile-lan-cert'
|
||||||
import {
|
import {
|
||||||
buildPairQrPayload,
|
buildPairQrPayload,
|
||||||
|
cancelPairOffer,
|
||||||
createLanProxy,
|
createLanProxy,
|
||||||
createMobileLink,
|
createMobileLink,
|
||||||
createWindowsFirewall,
|
createWindowsFirewall,
|
||||||
firewallCommand,
|
firewallCommand,
|
||||||
|
firewallQueryCommand,
|
||||||
formatPairCode,
|
formatPairCode,
|
||||||
generatePairCode,
|
generatePairCode,
|
||||||
isPhoneForbidden,
|
isPhoneForbidden,
|
||||||
isPrivateIpv4,
|
isPrivateIpv4,
|
||||||
|
MOBILE_LAN_DIRECT_HOST,
|
||||||
|
MOBILE_LOOPBACK_HOST,
|
||||||
MobileLinkError,
|
MobileLinkError,
|
||||||
type MobileLinkEvent,
|
type MobileLinkEvent,
|
||||||
normalizePairCode,
|
normalizePairCode,
|
||||||
PAIR_CODE_ALPHABET,
|
PAIR_CODE_ALPHABET,
|
||||||
|
PAIR_OFFER_MAX_TRIES,
|
||||||
|
PAIR_OFFER_RETRY_DELAYS_MS,
|
||||||
pairLookup,
|
pairLookup,
|
||||||
|
parseFirewallRuleOutput,
|
||||||
parseMobileLinkStored,
|
parseMobileLinkStored,
|
||||||
|
publishPairOffer,
|
||||||
selectLanAddresses
|
selectLanAddresses
|
||||||
} from './mobile-lan-proxy'
|
} from './mobile-lan-proxy'
|
||||||
|
|
||||||
@@ -191,9 +200,9 @@ function wsOpen(url: string, ca: string, extra: Record<string, unknown> = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectOutcome = (port: number) =>
|
const connectOutcome = (port: number, host = '127.0.0.1') =>
|
||||||
new Promise<string>(resolve => {
|
new Promise<string>(resolve => {
|
||||||
const socket = net.connect(port, '127.0.0.1')
|
const socket = net.connect(port, host)
|
||||||
|
|
||||||
socket.once('connect', () => {
|
socket.once('connect', () => {
|
||||||
socket.destroy()
|
socket.destroy()
|
||||||
@@ -698,15 +707,66 @@ describe('LAN proxy: TLS, listening and logs', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── controller ──────────────────────────────────────────────────────────────
|
// ── controller ──────────────────────────────────────────────────────────────
|
||||||
|
// Stand-in for the LAN bind (production: 0.0.0.0). Still loopback, so no test socket leaves this machine,
|
||||||
|
// but it is a DIFFERENT address from 127.0.0.1, which is what "listening on the LAN or not" needs.
|
||||||
|
const LAN_HOST = '127.0.0.2'
|
||||||
|
const RELAY_IDS = ['3f2b8c1e-5d47-4a90-8e21-6b0c9d7a1f55', '9a1d0c2b-7e63-4f18-b5a4-2c8e6f013d77', '5c7e9a13-2b46-4d80-9f3e-1a2b3c4d5e6f']
|
||||||
|
const HOST_TOKENS = RELAY_IDS.map((_id, index) => `tsmr_${String.fromCharCode(65 + index).repeat(43)}`)
|
||||||
|
|
||||||
|
/** Host socket that opens at once and never carries traffic; enough for the controller tests. */
|
||||||
|
class FakeHostSocket extends EventEmitter {
|
||||||
|
sent: (Buffer | string)[] = []
|
||||||
|
closed: null | number = null
|
||||||
|
bufferedAmount = 0
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public url: string,
|
||||||
|
public headers: Record<string, string>
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
setTimeout(() => this.emit('open'), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
send(data: Buffer | string) {
|
||||||
|
this.sent.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code = 1000) {
|
||||||
|
if (this.closed === null) {
|
||||||
|
this.closed = code
|
||||||
|
setTimeout(() => this.emit('close', code), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function linkHarness(over: Record<string, any> = {}) {
|
function linkHarness(over: Record<string, any> = {}) {
|
||||||
const vault = { value: { accountToken: 'eski-alan' } as Record<string, any> }
|
const vault = { value: { accountToken: 'eski-alan' } as Record<string, any> }
|
||||||
const store = { value: null as any, saves: 0 }
|
const store = { value: (over.stored ?? null) as any, saves: 0 }
|
||||||
const firewallCalls: string[] = []
|
const firewallCalls: string[] = []
|
||||||
const firewallResults: any[] = over.firewallResults ?? []
|
const firewallResults: any[] = over.firewallResults ?? []
|
||||||
const fetchCalls: { url: string; init: any }[] = []
|
const fetchCalls: { url: string; init: any }[] = []
|
||||||
const events: MobileLinkEvent[] = []
|
const events: MobileLinkEvent[] = []
|
||||||
const logs: string[] = []
|
const logs: string[] = []
|
||||||
const state = { remote: false, offerStatus: over.offerStatus ?? 201 }
|
const hostSockets: FakeHostSocket[] = []
|
||||||
|
/** Outcomes of successive `POST /offers` calls (status or 'throw'); afterwards `state.offerStatus` applies. */
|
||||||
|
const offerPlan: (number | 'throw')[] = [...(over.offerPlan ?? [])]
|
||||||
|
/** Waits the controller asked for between tries (real values); they are shortened to 1 ms here. */
|
||||||
|
const retrySleeps: number[] = []
|
||||||
|
/** With `holdRetries` a wait ends only when the test calls the function. */
|
||||||
|
const heldRetries: (() => void)[] = []
|
||||||
|
const RETRY_WAITS = new Set<number>([...PAIR_OFFER_RETRY_DELAYS_MS])
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
remote: false,
|
||||||
|
offerStatus: over.offerStatus ?? 201,
|
||||||
|
/** While set, `POST /offers` answers only after it resolves. */
|
||||||
|
offerGate: null as null | Promise<void>,
|
||||||
|
/** false = `POST /sessions` fails (no internet). */
|
||||||
|
sessions: over.sessions ?? true,
|
||||||
|
sessionCount: 0,
|
||||||
|
/** What `netsh … show rule` would answer. */
|
||||||
|
rule: (over.rule ?? { state: 'absent' }) as any
|
||||||
|
}
|
||||||
|
|
||||||
const deps = {
|
const deps = {
|
||||||
vault: {
|
vault: {
|
||||||
@@ -728,36 +788,89 @@ function linkHarness(over: Record<string, any> = {}) {
|
|||||||
addRule: async (port: number) => {
|
addRule: async (port: number) => {
|
||||||
firewallCalls.push(`add:${port}`)
|
firewallCalls.push(`add:${port}`)
|
||||||
|
|
||||||
return firewallResults.shift() ?? { ok: true }
|
const result = firewallResults.shift() ?? { ok: true }
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
state.rule = { state: 'present', ports: [port] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
removeRule: async () => {
|
removeRule: async () => {
|
||||||
firewallCalls.push('remove')
|
firewallCalls.push('remove')
|
||||||
|
state.rule = { state: 'absent' }
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
|
},
|
||||||
|
queryRule: async () => {
|
||||||
|
firewallCalls.push('query')
|
||||||
|
|
||||||
|
return structuredClone(state.rule)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fetch: async (url: string, init: any) => {
|
fetch: async (url: string, init: any) => {
|
||||||
fetchCalls.push({ url, init })
|
fetchCalls.push({ url, init })
|
||||||
|
|
||||||
if (state.offerStatus === 'throw') {
|
if (url.endsWith('/mobile-relay/sessions')) {
|
||||||
|
if (!state.sessions) {
|
||||||
|
throw new Error('network down')
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = state.sessionCount++
|
||||||
|
|
||||||
|
return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOffer = url.endsWith('/offers')
|
||||||
|
const offerOutcome = isOffer && offerPlan.length ? offerPlan.shift() : state.offerStatus
|
||||||
|
|
||||||
|
if (isOffer && state.offerGate) {
|
||||||
|
await state.offerGate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offerOutcome === 'throw') {
|
||||||
throw new Error('network down')
|
throw new Error('network down')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.endsWith('/offers')) {
|
if (isOffer) {
|
||||||
return {
|
return {
|
||||||
status: state.offerStatus,
|
status: offerOutcome,
|
||||||
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
|
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: 200, json: async () => ({ cancelled: true }) }
|
return { status: 200, json: async () => ({ cancelled: true }) }
|
||||||
},
|
},
|
||||||
|
relayConnect: (url: string, headers: Record<string, string>) => {
|
||||||
|
const socket = new FakeHostSocket(url, headers)
|
||||||
|
|
||||||
|
hostSockets.push(socket)
|
||||||
|
|
||||||
|
return socket
|
||||||
|
},
|
||||||
addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'],
|
addresses: async () => over.addresses ?? ['192.168.1.20', '10.0.0.5'],
|
||||||
version: '1.0.0-test',
|
version: '1.0.0-test',
|
||||||
pcName: 'TEST-PC',
|
pcName: 'TEST-PC',
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
listenHost: '127.0.0.1',
|
listenHost: LAN_HOST,
|
||||||
ports: [0],
|
ports: [0],
|
||||||
|
// Real timers, except the waits between tries of a request: those are recorded and cut to 1 ms.
|
||||||
|
setTimer: (fn: () => void, ms: number) => {
|
||||||
|
if (!RETRY_WAITS.has(ms)) {
|
||||||
|
return setTimeout(fn, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrySleeps.push(ms)
|
||||||
|
|
||||||
|
if (over.holdRetries) {
|
||||||
|
heldRetries.push(fn)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return setTimeout(fn, 1)
|
||||||
|
},
|
||||||
|
clearTimer: (handle: unknown) => clearTimeout(handle as NodeJS.Timeout),
|
||||||
log: (line: string) => logs.push(line)
|
log: (line: string) => logs.push(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,7 +882,11 @@ function linkHarness(over: Record<string, any> = {}) {
|
|||||||
return link
|
return link
|
||||||
}
|
}
|
||||||
|
|
||||||
return { link: make(), make, vault, store, firewallCalls, fetchCalls, events, logs, state }
|
/** Firewall calls that change something (the read-only query is asked often). */
|
||||||
|
const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query')
|
||||||
|
const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers'))
|
||||||
|
|
||||||
|
return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('mobile link controller', () => {
|
describe('mobile link controller', () => {
|
||||||
@@ -781,8 +898,9 @@ describe('mobile link controller', () => {
|
|||||||
const enabled = await h.link.enable()
|
const enabled = await h.link.enable()
|
||||||
const port = enabled.port as number
|
const port = enabled.port as number
|
||||||
|
|
||||||
expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'ok', remote: false })
|
expect(enabled).toMatchObject({ enabled: true, listening: false, firewall: 'needed', remote: false })
|
||||||
expect(h.firewallCalls).toEqual([`add:${port}`])
|
// Turning the feature on never touches the firewall (no UAC prompt): only the read-only query runs.
|
||||||
|
expect(h.firewallWrites()).toEqual([])
|
||||||
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
||||||
|
|
||||||
const pairing = await h.link.pairBegin()
|
const pairing = await h.link.pairBegin()
|
||||||
@@ -794,9 +912,8 @@ describe('mobile link controller', () => {
|
|||||||
|
|
||||||
expect(cancelled).toMatchObject({ listening: false, pairing: null })
|
expect(cancelled).toMatchObject({ listening: false, pairing: null })
|
||||||
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
||||||
// The firewall is asked once per port, not on every enable.
|
|
||||||
await h.link.enable()
|
await h.link.enable()
|
||||||
expect(h.firewallCalls).toEqual([`add:${port}`])
|
expect(h.firewallWrites()).toEqual([])
|
||||||
await h.link.stop()
|
await h.link.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -811,13 +928,15 @@ describe('mobile link controller', () => {
|
|||||||
const fp = h.vault.value.lanTls.fingerprint
|
const fp = h.vault.value.lanTls.fingerprint
|
||||||
|
|
||||||
expect(pairing.code).toMatch(new RegExp(`^[${PAIR_CODE_ALPHABET}]{8}$`))
|
expect(pairing.code).toMatch(new RegExp(`^[${PAIR_CODE_ALPHABET}]{8}$`))
|
||||||
expect(pairing.qr).toBe(`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${pairing.code}`)
|
// Default (direct LAN access off): no `u`, only the fingerprint, the code and the relay id.
|
||||||
|
expect(pairing.qr).toBe(`aiturk://pair?v=1&fp=${fp}&c=${pairing.code}&r=${RELAY_IDS[0]}`)
|
||||||
|
expect(pairing.securityCode).toBe(fp.slice(0, 8))
|
||||||
expect(fp).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
expect(fp).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||||
expect(pairing.codePath).toBe('pending')
|
expect(pairing.codePath).toBe('pending')
|
||||||
|
|
||||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||||
|
|
||||||
const offer = h.fetchCalls[0]
|
const offer = h.offers()[0]
|
||||||
const body = JSON.parse(offer.init.body)
|
const body = JSON.parse(offer.init.body)
|
||||||
|
|
||||||
expect(offer.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/offers')
|
expect(offer.url).toBe('https://turkservis.online/v1/aiturk/mobile-pair/offers')
|
||||||
@@ -826,8 +945,9 @@ describe('mobile link controller', () => {
|
|||||||
expect(offer.init.signal).toBeInstanceOf(AbortSignal)
|
expect(offer.init.signal).toBeInstanceOf(AbortSignal)
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
lookup: crypto.createHash('sha256').update(`aiturk-mobile-pair-v1:${pairing.code}`).digest('hex'),
|
lookup: crypto.createHash('sha256').update(`aiturk-mobile-pair-v1:${pairing.code}`).digest('hex'),
|
||||||
urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`],
|
urls: [],
|
||||||
fp
|
fp,
|
||||||
|
relay: RELAY_IDS[0]
|
||||||
})
|
})
|
||||||
expect(offer.init.body).not.toContain(pairing.code)
|
expect(offer.init.body).not.toContain(pairing.code)
|
||||||
expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code)
|
expect(JSON.stringify(offer.init.headers)).not.toContain(pairing.code)
|
||||||
@@ -922,7 +1042,7 @@ describe('mobile link controller', () => {
|
|||||||
expect(off).toMatchObject({ enabled: false, listening: false })
|
expect(off).toMatchObject({ enabled: false, listening: false })
|
||||||
expect(off.devices).toHaveLength(1)
|
expect(off.devices).toHaveLength(1)
|
||||||
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
expect(await connectOutcome(port)).toBe('ECONNREFUSED')
|
||||||
expect(h.firewallCalls).toEqual([`add:${port}`]) // disabling never removes the rule
|
expect(h.firewallWrites()).toEqual([]) // neither enabling nor disabling touches the rule
|
||||||
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:not_enabled')
|
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:not_enabled')
|
||||||
|
|
||||||
// Disabled at app start: no socket.
|
// Disabled at app start: no socket.
|
||||||
@@ -938,7 +1058,7 @@ describe('mobile link controller', () => {
|
|||||||
const restarted = h.make()
|
const restarted = h.make()
|
||||||
const resumed = await restarted.start()
|
const resumed = await restarted.start()
|
||||||
|
|
||||||
expect(resumed).toMatchObject({ enabled: true, listening: true, port, firewall: 'ok' })
|
expect(resumed).toMatchObject({ enabled: true, listening: true, port, lan: { direct: false, listening: false } })
|
||||||
expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200)
|
expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token })).status).toBe(200)
|
||||||
|
|
||||||
await restarted.stop()
|
await restarted.stop()
|
||||||
@@ -952,7 +1072,7 @@ describe('mobile link controller', () => {
|
|||||||
await expect(h.link.enable()).rejects.toBeInstanceOf(MobileLinkError)
|
await expect(h.link.enable()).rejects.toBeInstanceOf(MobileLinkError)
|
||||||
await expect(h.link.enable()).rejects.toThrow('aiturk-mobile:remote_mode')
|
await expect(h.link.enable()).rejects.toThrow('aiturk-mobile:remote_mode')
|
||||||
expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, remote: true })
|
expect(await h.link.state()).toMatchObject({ enabled: false, listening: false, remote: true })
|
||||||
expect(h.firewallCalls).toEqual([])
|
expect(h.firewallWrites()).toEqual([])
|
||||||
|
|
||||||
h.state.remote = false
|
h.state.remote = false
|
||||||
await h.link.enable()
|
await h.link.enable()
|
||||||
@@ -962,26 +1082,126 @@ describe('mobile link controller', () => {
|
|||||||
await h.link.stop()
|
await h.link.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('a refused UAC prompt leaves a clear state and "Yeniden dene" adds the rule', async () => {
|
it('"İzin ver" is the only thing that adds the firewall rule; a refused UAC prompt leaves direct access off', async () => {
|
||||||
const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] })
|
const h = linkHarness({ firewallResults: [{ ok: false, reason: 'denied' }, { ok: false, reason: 'failed' }] })
|
||||||
const state = await h.link.enable()
|
const state = await h.link.enable()
|
||||||
|
|
||||||
expect(state).toMatchObject({ enabled: true, firewall: 'denied' })
|
expect(state).toMatchObject({ enabled: true, firewall: 'needed', lan: { direct: false, listening: false, firewall: 'missing' } })
|
||||||
expect((await h.link.firewallRetry()).firewall).toBe('failed')
|
expect(h.firewallWrites()).toEqual([])
|
||||||
expect((await h.link.firewallRetry()).firewall).toBe('ok')
|
expect(await h.link.lanAllow()).toMatchObject({ firewall: 'denied', lan: { direct: false, firewall: 'missing' } })
|
||||||
expect(h.firewallCalls).toHaveLength(3)
|
expect(await h.link.firewallRetry()).toMatchObject({ firewall: 'failed', lan: { direct: false } })
|
||||||
expect((await h.link.firewallRetry()).firewall).toBe('ok')
|
expect(await h.link.firewallAllow()).toMatchObject({ firewall: 'ok', lan: { direct: true, firewall: 'allowed' } })
|
||||||
expect(h.firewallCalls).toHaveLength(3)
|
expect(h.firewallWrites()).toHaveLength(3)
|
||||||
expect((await h.link.firewallRemove()).firewall).toBe('needed')
|
// The rule is there (asked from Windows, not remembered): no second prompt.
|
||||||
expect(h.firewallCalls.at(-1)).toBe('remove')
|
expect((await h.link.lanAllow()).firewall).toBe('ok')
|
||||||
|
expect(h.firewallWrites()).toHaveLength(3)
|
||||||
|
expect(await h.link.firewallRemove()).toMatchObject({ firewall: 'needed', lan: { direct: false, firewall: 'missing' } })
|
||||||
|
expect(h.firewallWrites().at(-1)).toBe('remove')
|
||||||
|
await expect(linkHarness().link.lanAllow()).rejects.toThrow('aiturk-mobile:not_enabled')
|
||||||
await h.link.stop()
|
await h.link.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('without a usable LAN address pairing is refused; a failing vault is reported by code only', async () => {
|
it('default bind is 127.0.0.1 only; direct LAN access moves the socket to the LAN bind and puts `u` into the QR and the offer', async () => {
|
||||||
const h = linkHarness({ addresses: ['8.8.8.8', '169.254.3.4'] })
|
expect(MOBILE_LOOPBACK_HOST).toBe('127.0.0.1')
|
||||||
|
expect(MOBILE_LAN_DIRECT_HOST).toBe('0.0.0.0')
|
||||||
|
|
||||||
|
const h = linkHarness()
|
||||||
|
|
||||||
await h.link.enable()
|
await h.link.enable()
|
||||||
|
|
||||||
|
const first = await h.link.pairBegin()
|
||||||
|
const port = first.port as number
|
||||||
|
|
||||||
|
// Listening, but only on loopback: the stand-in LAN address refuses the connection.
|
||||||
|
expect(first).toMatchObject({ listening: true, lan: { direct: false, listening: false, port, firewall: 'missing' } })
|
||||||
|
expect(await connectOutcome(port, '127.0.0.1')).toBe('open')
|
||||||
|
expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED')
|
||||||
|
expect(first.pairing!.qr).not.toContain('u=')
|
||||||
|
await until(() => h.offers().length === 1)
|
||||||
|
expect(JSON.parse(h.offers()[0].init.body)).toMatchObject({ urls: [], relay: RELAY_IDS[0] })
|
||||||
|
|
||||||
|
// The user allows direct access: rule added now, pairing on screen is cancelled, socket moves.
|
||||||
|
const allowed = await h.link.lanAllow()
|
||||||
|
|
||||||
|
expect(h.firewallWrites()).toEqual([`add:${port}`])
|
||||||
|
expect(allowed).toMatchObject({ pairing: null, listening: false, lan: { direct: true, listening: false, firewall: 'allowed' } })
|
||||||
|
expect(h.store.value.lanDirect).toBe(true)
|
||||||
|
|
||||||
|
const second = await h.link.pairBegin()
|
||||||
|
const fp = h.vault.value.lanTls.fingerprint
|
||||||
|
|
||||||
|
expect(second).toMatchObject({ listening: true, lan: { direct: true, listening: true, port } })
|
||||||
|
expect(await connectOutcome(port, LAN_HOST)).toBe('open')
|
||||||
|
expect(second.pairing!.qr).toBe(
|
||||||
|
`aiturk://pair?v=1&u=192.168.1.20:${port}&u=10.0.0.5:${port}&fp=${fp}&c=${second.pairing!.code}&r=${RELAY_IDS[0]}`
|
||||||
|
)
|
||||||
|
await until(() => h.offers().length === 2)
|
||||||
|
expect(JSON.parse(h.offers()[1].init.body)).toMatchObject({
|
||||||
|
urls: [`https://192.168.1.20:${port}`, `https://10.0.0.5:${port}`],
|
||||||
|
relay: RELAY_IDS[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// The rule disappears outside the IDE: the next state query notices and goes back to loopback.
|
||||||
|
h.state.rule = { state: 'absent' }
|
||||||
|
|
||||||
|
const lost = await h.link.state()
|
||||||
|
|
||||||
|
expect(lost).toMatchObject({ listening: true, lan: { direct: true, listening: false, firewall: 'missing' } })
|
||||||
|
expect(await connectOutcome(port, LAN_HOST)).toBe('ECONNREFUSED')
|
||||||
|
expect(await connectOutcome(port, '127.0.0.1')).toBe('open')
|
||||||
|
|
||||||
|
// A rule for another port is not permission for this one.
|
||||||
|
h.state.rule = { state: 'present', ports: [port === 47321 ? 47322 : 47321] }
|
||||||
|
expect((await h.link.state()).lan.firewall).toBe('missing')
|
||||||
|
h.state.rule = { state: 'unknown' }
|
||||||
|
expect((await h.link.state()).lan.firewall).toBe('unknown')
|
||||||
|
|
||||||
|
const off = await h.link.lanDisallow()
|
||||||
|
|
||||||
|
expect(off).toMatchObject({ pairing: null, lan: { direct: false, listening: false } })
|
||||||
|
expect(h.firewallWrites()).toEqual([`add:${port}`]) // turning it off needs no prompt
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a record written by beta.15 (enabled, firewallPort set, no lanDirect) stays on loopback and raises no prompt', async () => {
|
||||||
|
const id = '0f8fad5b-d9cb-469f-a165-70867728950e'
|
||||||
|
|
||||||
|
const h = linkHarness({
|
||||||
|
rule: { state: 'present', ports: [47321] },
|
||||||
|
stored: { v: 1, enabled: true, port: null, firewallPort: 47321, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: 'a'.repeat(64), createdAt: 5, lastSeenAt: 5 }] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const resumed = await h.link.start()
|
||||||
|
|
||||||
|
expect(resumed).toMatchObject({ enabled: true, listening: true, lan: { direct: false, listening: false } })
|
||||||
|
expect(await connectOutcome(resumed.port as number, LAN_HOST)).toBe('ECONNREFUSED')
|
||||||
|
expect(h.firewallWrites()).toEqual([])
|
||||||
|
// The relay follows the same condition as the listener: a paired phone is enough.
|
||||||
|
await until(() => h.events.some(event => event.state.relay.status === 'connected'))
|
||||||
|
expect(h.hostSockets).toHaveLength(1)
|
||||||
|
await h.link.stop()
|
||||||
|
expect(h.hostSockets[0].closed).toBe(1000)
|
||||||
|
expect((await h.link.state()).relay.status).toBe('off')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('without a relay registration "Telefon ekle" starts nothing unless direct LAN access is on', async () => {
|
||||||
|
const h = linkHarness({ sessions: false, addresses: ['8.8.8.8', '169.254.3.4'] })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
await expect(h.link.pairBegin()).rejects.toThrow(/^aiturk-mobile:relay_unavailable$/)
|
||||||
|
expect(await h.link.state()).toMatchObject({ pairing: null, listening: false, relay: { ready: false, status: 'off' } })
|
||||||
|
expect(h.offers()).toEqual([])
|
||||||
|
|
||||||
|
// Direct access on, but no usable LAN address either.
|
||||||
|
await h.link.lanAllow()
|
||||||
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address')
|
await expect(h.link.pairBegin()).rejects.toThrow('aiturk-mobile:no_lan_address')
|
||||||
|
|
||||||
|
// Internet back: the relay alone is enough, even with no LAN address at all.
|
||||||
|
h.state.sessions = true
|
||||||
|
|
||||||
|
const state = await h.link.pairBegin()
|
||||||
|
|
||||||
|
expect(state.pairing!.qr).toMatch(new RegExp(`^aiturk://pair\\?v=1&fp=[A-Za-z0-9_-]{43}&c=[A-Z2-9]{8}&r=${RELAY_IDS[0]}$`))
|
||||||
await h.link.stop()
|
await h.link.stop()
|
||||||
|
|
||||||
const broken = createMobileLink({
|
const broken = createMobileLink({
|
||||||
@@ -1007,6 +1227,71 @@ describe('mobile link controller', () => {
|
|||||||
await expect(broken.enable()).rejects.toThrow(/^aiturk-mobile:vault_unavailable$/)
|
await expect(broken.enable()).rejects.toThrow(/^aiturk-mobile:vault_unavailable$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('relay: same on/off condition as the listener, token only in the vault and the Authorization header, renewal drops the phones', async () => {
|
||||||
|
const h = linkHarness()
|
||||||
|
|
||||||
|
expect((await h.link.enable()).relay).toEqual({ status: 'off', code: null, ready: false, reissued: false })
|
||||||
|
expect(h.hostSockets).toHaveLength(0)
|
||||||
|
|
||||||
|
const state = await h.link.pairBegin()
|
||||||
|
const port = state.port as number
|
||||||
|
const ca = h.vault.value.lanTls.cert
|
||||||
|
|
||||||
|
await until(() => h.events.some(event => event.state.relay.status === 'connected'))
|
||||||
|
expect(h.hostSockets[0].url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[0]}`)
|
||||||
|
expect(h.hostSockets[0].headers).toEqual({ Authorization: `Bearer ${HOST_TOKENS[0]}` })
|
||||||
|
|
||||||
|
const sessions = h.fetchCalls.find(entry => entry.url.endsWith('/sessions'))!
|
||||||
|
|
||||||
|
expect(sessions.url).toBe('https://turkservis.online/v1/aiturk/mobile-relay/sessions')
|
||||||
|
expect(sessions.init).toMatchObject({ method: 'POST', body: '{}', redirect: 'error' })
|
||||||
|
expect(sessions.init.signal).toBeInstanceOf(AbortSignal)
|
||||||
|
|
||||||
|
// relayId on disk, hostToken only in the vault.
|
||||||
|
expect(h.store.value.relayId).toBe(RELAY_IDS[0])
|
||||||
|
expect(h.vault.value.relay).toEqual({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] })
|
||||||
|
expect(h.vault.value.accountToken).toBe('eski-alan')
|
||||||
|
|
||||||
|
const claim = await call(port, 'POST', '/aiturk/pair/claim', { ca, body: { code: state.pairing!.code, device_name: 'Pixel' } })
|
||||||
|
|
||||||
|
expect(claim.status).toBe(200)
|
||||||
|
await until(() => h.events.some(event => event.type === 'paired'))
|
||||||
|
expect((await h.link.state()).relay).toEqual({ status: 'connected', code: null, ready: true, reissued: false })
|
||||||
|
// One registration for the whole life of the pairing: the phone's stored id must not change.
|
||||||
|
expect(h.fetchCalls.filter(entry => entry.url.endsWith('/sessions'))).toHaveLength(1)
|
||||||
|
|
||||||
|
// "Röle kimliğini yenile".
|
||||||
|
const renewed = await h.link.relayRenew()
|
||||||
|
|
||||||
|
expect(renewed).toMatchObject({ devices: [], listening: false, relay: { status: 'off', ready: true, reissued: true } })
|
||||||
|
expect(h.store.value.relayId).toBe(RELAY_IDS[1])
|
||||||
|
expect(h.vault.value.relay.hostToken).toBe(HOST_TOKENS[1])
|
||||||
|
expect((await call(port, 'GET', '/api/sessions', { ca, token: claim.json.token }).catch(() => ({ status: 0 }))).status).not.toBe(200)
|
||||||
|
await until(() => h.hostSockets[0].closed !== null)
|
||||||
|
|
||||||
|
const again = await h.link.pairBegin()
|
||||||
|
|
||||||
|
expect(again.pairing!.qr).toContain(`&r=${RELAY_IDS[1]}`)
|
||||||
|
await until(() => h.events.at(-1)?.state.relay.status === 'connected')
|
||||||
|
// Every socket after the renewal carries the new token; the old one is never sent again.
|
||||||
|
expect(h.hostSockets.length).toBeGreaterThan(1)
|
||||||
|
expect(h.hostSockets.slice(1).every(socket => socket.headers.Authorization === `Bearer ${HOST_TOKENS[1]}`)).toBe(true)
|
||||||
|
expect(h.hostSockets.at(-1)!.url).toBe(`wss://turkservis.online/v1/aiturk/mobile-relay/host/${RELAY_IDS[1]}`)
|
||||||
|
|
||||||
|
// Renewal that cannot reach the gateway changes nothing.
|
||||||
|
h.state.sessions = false
|
||||||
|
await expect(h.link.relayRenew()).rejects.toThrow('aiturk-mobile:relay_unavailable')
|
||||||
|
expect(h.store.value.relayId).toBe(RELAY_IDS[1])
|
||||||
|
|
||||||
|
// The host token never reaches a log line, an event, a state answer or the plain store.
|
||||||
|
const exposed = [h.logs.join('\n'), JSON.stringify(h.events), JSON.stringify(await h.link.state()), JSON.stringify(h.store.value)].join('\n')
|
||||||
|
|
||||||
|
expect(exposed).not.toContain('tsmr_')
|
||||||
|
expect(h.logs.filter(line => line.startsWith('relay '))).toEqual(expect.arrayContaining(['relay connecting', 'relay connected', 'relay off']))
|
||||||
|
expect(h.logs.filter(line => line.startsWith('relay ')).every(line => /^relay (?:off|connecting|connected|failed:[a-z_]+)$/.test(line))).toBe(true)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
it('a regenerated certificate drops phones paired against the old one', async () => {
|
it('a regenerated certificate drops phones paired against the old one', async () => {
|
||||||
const h = linkHarness()
|
const h = linkHarness()
|
||||||
|
|
||||||
@@ -1029,6 +1314,306 @@ describe('mobile link controller', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('mobile link controller: rendezvous record retries', () => {
|
||||||
|
const lookupOf = (h: ReturnType<typeof linkHarness>, index: number) => JSON.parse(h.offers()[index].init.body).lookup
|
||||||
|
const cancels = (h: ReturnType<typeof linkHarness>) => h.fetchCalls.filter(entry => entry.url.endsWith('/cancel'))
|
||||||
|
|
||||||
|
const noSecretInLogs = (h: ReturnType<typeof linkHarness>, code: string) => {
|
||||||
|
const text = h.logs.join('\n')
|
||||||
|
|
||||||
|
expect(text).not.toContain(code)
|
||||||
|
expect(text).not.toContain(pairLookup(code))
|
||||||
|
expect(text).not.toContain('tsmp_')
|
||||||
|
}
|
||||||
|
|
||||||
|
it('first request rejects, the second answers 201: the code path becomes ready (one lost request does not close it)', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: ['throw', 201] })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
|
||||||
|
const state = await h.link.pairBegin()
|
||||||
|
|
||||||
|
expect(state.pairing!.codePath).toBe('pending')
|
||||||
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||||
|
expect(h.offers()).toHaveLength(2)
|
||||||
|
expect(h.retrySleeps).toEqual([500])
|
||||||
|
// Same record both times, and each try has its own timeout signal.
|
||||||
|
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
|
||||||
|
expect(h.offers()[1].init.signal).not.toBe(h.offers()[0].init.signal)
|
||||||
|
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
|
||||||
|
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer published'])
|
||||||
|
noSecretInLogs(h, state.pairing!.code)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('two 503 answers, then 201: ready on the third try', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: [503, 503, 201] })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
await h.link.pairBegin()
|
||||||
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||||
|
expect(h.offers()).toHaveLength(3)
|
||||||
|
expect(h.retrySleeps).toEqual([500, 1500])
|
||||||
|
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a lost answer followed by 409 means the first request landed: ready, and no cancel is sent without a token', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: ['throw', 409] })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
await h.link.pairBegin()
|
||||||
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||||
|
expect(h.offers()).toHaveLength(2)
|
||||||
|
expect((await h.link.pairCancel()).pairing).toBeNull()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
expect(cancels(h)).toEqual([])
|
||||||
|
await h.link.stop()
|
||||||
|
expect(cancels(h)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('422 is final: one try, unavailable', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: [422, 201] })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
|
||||||
|
const state = await h.link.pairBegin()
|
||||||
|
|
||||||
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
expect(h.offers()).toHaveLength(1)
|
||||||
|
expect(h.retrySleeps).toEqual([])
|
||||||
|
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 1 try'])
|
||||||
|
noSecretInLogs(h, state.pairing!.code)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('four failing tries: unavailable only after the fourth, never earlier', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: ['throw', 503, 429, 'throw', 201], holdRetries: true })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
|
||||||
|
const state = await h.link.pairBegin()
|
||||||
|
|
||||||
|
for (let tries = 1; tries <= 3; tries++) {
|
||||||
|
await until(() => h.offers().length === tries && h.heldRetries.length === 1)
|
||||||
|
// Still being tried: the window keeps saying "hazırlanıyor…".
|
||||||
|
expect((await h.link.state()).pairing!.codePath).toBe('pending')
|
||||||
|
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
|
||||||
|
h.heldRetries.shift()!()
|
||||||
|
}
|
||||||
|
|
||||||
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||||
|
expect(h.offers()).toHaveLength(4)
|
||||||
|
expect(h.retrySleeps).toEqual([500, 1500, 3000])
|
||||||
|
expect(h.heldRetries).toEqual([])
|
||||||
|
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 4 tries'])
|
||||||
|
noSecretInLogs(h, state.pairing!.code)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancelling the pairing stops the tries; a record that lands after the cancel is cancelled on the gateway', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: ['throw', 201], holdRetries: true })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
await h.link.pairBegin()
|
||||||
|
await until(() => h.offers().length === 1 && h.heldRetries.length === 1)
|
||||||
|
expect((await h.link.pairCancel()).pairing).toBeNull()
|
||||||
|
// The wait ends after the pairing is gone: no further request.
|
||||||
|
h.heldRetries.shift()!()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
expect(h.offers()).toHaveLength(1)
|
||||||
|
expect(cancels(h)).toEqual([])
|
||||||
|
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual([])
|
||||||
|
|
||||||
|
// A NEW pairing is not confused with the old one, and a request in flight when pairing ends is cleaned up.
|
||||||
|
let release: () => void = () => undefined
|
||||||
|
|
||||||
|
h.state.offerGate = new Promise<void>(resolve => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
await h.link.pairBegin()
|
||||||
|
await until(() => h.offers().length === 2)
|
||||||
|
await h.link.pairCancel()
|
||||||
|
release()
|
||||||
|
await until(() => cancels(h).length === 1)
|
||||||
|
expect(cancels(h)[0].init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pair.republish publishes the running pairing again: new request, same code, same QR, same expiry', async () => {
|
||||||
|
const h = linkHarness({ offerPlan: [422] })
|
||||||
|
|
||||||
|
await h.link.enable()
|
||||||
|
|
||||||
|
const begun = (await h.link.pairBegin()).pairing!
|
||||||
|
|
||||||
|
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||||
|
expect(h.offers()).toHaveLength(1)
|
||||||
|
|
||||||
|
const again = (await h.link.pairRepublish()).pairing!
|
||||||
|
|
||||||
|
expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' })
|
||||||
|
await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready')
|
||||||
|
expect(h.offers()).toHaveLength(2)
|
||||||
|
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
|
||||||
|
expect(JSON.parse(h.offers()[1].init.body)).toEqual(JSON.parse(h.offers()[0].init.body))
|
||||||
|
|
||||||
|
// Nothing to do while ready (or pending), and nothing without a pairing.
|
||||||
|
expect((await h.link.pairRepublish()).pairing).toMatchObject({ code: begun.code, codePath: 'ready' })
|
||||||
|
expect(h.offers()).toHaveLength(2)
|
||||||
|
|
||||||
|
// The code on screen still pairs the phone.
|
||||||
|
const claim = await call((await h.link.state()).port as number, 'POST', '/aiturk/pair/claim', {
|
||||||
|
ca: h.vault.value.lanTls.cert,
|
||||||
|
body: { code: begun.code, device_name: 'Pixel' }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(claim.status).toBe(200)
|
||||||
|
await until(() => h.events.some(event => event.type === 'paired'))
|
||||||
|
expect((await h.link.pairRepublish()).pairing).toBeNull()
|
||||||
|
expect(h.offers()).toHaveLength(2)
|
||||||
|
await h.link.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pair.republish after four lost requests: a 409 on its first try is our own record (ready); after a 422 it is not', async () => {
|
||||||
|
const lost = linkHarness({ offerPlan: ['throw', 'throw', 'throw', 'throw', 409] })
|
||||||
|
|
||||||
|
await lost.link.enable()
|
||||||
|
await lost.link.pairBegin()
|
||||||
|
await until(() => lost.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||||
|
expect(lost.offers()).toHaveLength(4)
|
||||||
|
await lost.link.pairRepublish()
|
||||||
|
await until(() => lost.events.at(-1)?.state.pairing?.codePath === 'ready')
|
||||||
|
expect(lost.offers()).toHaveLength(5)
|
||||||
|
await lost.link.stop()
|
||||||
|
expect(cancels(lost)).toEqual([])
|
||||||
|
|
||||||
|
const refused = linkHarness({ offerPlan: [422, 409] })
|
||||||
|
|
||||||
|
await refused.link.enable()
|
||||||
|
await refused.link.pairBegin()
|
||||||
|
await until(() => refused.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||||
|
await refused.link.pairRepublish()
|
||||||
|
await until(() => refused.offers().length === 2 && refused.events.at(-1)?.state.pairing?.codePath === 'unavailable')
|
||||||
|
await refused.link.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('publishPairOffer / cancelPairOffer', () => {
|
||||||
|
const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] }
|
||||||
|
const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }
|
||||||
|
|
||||||
|
function plan(outcomes: (number | 'throw')[]) {
|
||||||
|
const sleeps: number[] = []
|
||||||
|
const logs: string[] = []
|
||||||
|
let calls = 0
|
||||||
|
|
||||||
|
const fetcher = async () => {
|
||||||
|
const outcome = outcomes[calls++] ?? 201
|
||||||
|
|
||||||
|
if (outcome === 'throw') {
|
||||||
|
throw new Error('fetch failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: outcome, json: async () => good }
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
sleep: async (ms: number) => {
|
||||||
|
sleeps.push(ms)
|
||||||
|
},
|
||||||
|
log: (line: string) => logs.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fetcher, options, sleeps, logs, calls: () => calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('waits 0.5 s, 1.5 s and 3 s between at most four tries', async () => {
|
||||||
|
const p = plan(['throw', 500, 429, 'throw', 201])
|
||||||
|
|
||||||
|
expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull()
|
||||||
|
expect(p.calls()).toBe(PAIR_OFFER_MAX_TRIES)
|
||||||
|
expect(p.calls()).toBe(4)
|
||||||
|
expect(p.sleeps).toEqual([500, 1500, 3000])
|
||||||
|
expect(p.sleeps).toEqual([...PAIR_OFFER_RETRY_DELAYS_MS])
|
||||||
|
expect(p.logs).toEqual(['offer failed after 4 tries'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries a thrown error, 5xx and 429; 201 ends it', async () => {
|
||||||
|
for (const first of ['throw', 500, 502, 503, 504, 429] as const) {
|
||||||
|
const p = plan([first, 201])
|
||||||
|
|
||||||
|
expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual(good)
|
||||||
|
expect(p.calls()).toBe(2)
|
||||||
|
expect(p.logs).toEqual(['offer published'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not retry 422 or any other final answer; a first-try 409 is somebody else’s record', async () => {
|
||||||
|
for (const status of [422, 400, 401, 403, 404, 409, 200, 302]) {
|
||||||
|
const p = plan([status, 201])
|
||||||
|
|
||||||
|
expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull()
|
||||||
|
expect(p.calls()).toBe(1)
|
||||||
|
expect(p.sleeps).toEqual([])
|
||||||
|
expect(p.logs).toEqual(['offer failed after 1 try'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('409 after a lost answer or a 5xx is our own record (no token); 409 after 429 is not', async () => {
|
||||||
|
for (const first of ['throw', 502] as const) {
|
||||||
|
const p = plan([first, 409])
|
||||||
|
|
||||||
|
expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual({ offerId: null, cancelToken: null })
|
||||||
|
expect(p.calls()).toBe(2)
|
||||||
|
expect(p.logs).toEqual(['offer published'])
|
||||||
|
}
|
||||||
|
|
||||||
|
const limited = plan([429, 409])
|
||||||
|
|
||||||
|
expect(await publishPairOffer(limited.fetcher, input, limited.options)).toBeNull()
|
||||||
|
expect(limited.calls()).toBe(2)
|
||||||
|
|
||||||
|
const told = plan([409])
|
||||||
|
|
||||||
|
expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops as soon as the pairing is over, without a log line', async () => {
|
||||||
|
const p = plan(['throw', 'throw', 201])
|
||||||
|
let over = false
|
||||||
|
|
||||||
|
const result = await publishPairOffer(p.fetcher, input, {
|
||||||
|
...p.options,
|
||||||
|
cancelled: () => over,
|
||||||
|
sleep: async ms => {
|
||||||
|
p.sleeps.push(ms)
|
||||||
|
over = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(p.calls()).toBe(1)
|
||||||
|
expect(p.logs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancelPairOffer sends nothing when there is no token', async () => {
|
||||||
|
const urls: string[] = []
|
||||||
|
|
||||||
|
const fetcher = async (url: string) => {
|
||||||
|
urls.push(url)
|
||||||
|
|
||||||
|
return { status: 200, json: async () => ({}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
await cancelPairOffer(fetcher, { offerId: null, cancelToken: null })
|
||||||
|
expect(urls).toEqual([])
|
||||||
|
await cancelPairOffer(fetcher, good)
|
||||||
|
expect(urls).toEqual(['https://turkservis.online/v1/aiturk/mobile-pair/cancel'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('pure helpers', () => {
|
describe('pure helpers', () => {
|
||||||
it('pairing code: alphabet, normalisation, display form, lookup digest', () => {
|
it('pairing code: alphabet, normalisation, display form, lookup digest', () => {
|
||||||
for (let i = 0; i < 200; i++) {
|
for (let i = 0; i < 200; i++) {
|
||||||
@@ -1142,7 +1727,153 @@ describe('pure helpers', () => {
|
|||||||
port: 99999,
|
port: 99999,
|
||||||
devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null]
|
devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, token: 'amd_x' }, { id: 'x', name: 'bad', tokenHash: hash, createdAt: 1 }, null]
|
||||||
})
|
})
|
||||||
).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }] })
|
).toEqual({
|
||||||
expect(parseMobileLinkStored(null)).toEqual({ enabled: false, port: null, firewallPort: null, fingerprint: null, devices: [] })
|
enabled: false,
|
||||||
|
port: null,
|
||||||
|
firewallPort: null,
|
||||||
|
fingerprint: null,
|
||||||
|
devices: [{ id, name: 'Pixel', tokenHash: hash, createdAt: 5, lastSeenAt: 5 }],
|
||||||
|
lanDirect: false,
|
||||||
|
relayId: null,
|
||||||
|
relayReissued: false
|
||||||
|
})
|
||||||
|
expect(parseMobileLinkStored(null)).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
port: null,
|
||||||
|
firewallPort: null,
|
||||||
|
fingerprint: null,
|
||||||
|
devices: [],
|
||||||
|
lanDirect: false,
|
||||||
|
relayId: null,
|
||||||
|
relayReissued: false
|
||||||
|
})
|
||||||
|
// Only an explicit `true` turns direct LAN access on; a relay id must be a uuid.
|
||||||
|
expect(parseMobileLinkStored({ lanDirect: 'yes', relayId: 'https://evil.example', relayReissued: 1 })).toMatchObject({ lanDirect: false, relayId: null, relayReissued: false })
|
||||||
|
expect(parseMobileLinkStored({ lanDirect: true, relayId: RELAY_IDS[0].toUpperCase(), relayReissued: true })).toMatchObject({
|
||||||
|
lanDirect: true,
|
||||||
|
relayId: RELAY_IDS[0],
|
||||||
|
relayReissued: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('QR payload and rendezvous body: `r` / `relay` is a plain lower-case uuid, `u` / `urls` optional only next to it', async () => {
|
||||||
|
const fp = 'f'.repeat(43)
|
||||||
|
const relay = RELAY_IDS[0]
|
||||||
|
|
||||||
|
expect(buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(`aiturk://pair?v=1&fp=${fp}&c=ABCDEFGH&r=${relay}`)
|
||||||
|
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay })).toBe(
|
||||||
|
`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH&r=${relay}`
|
||||||
|
)
|
||||||
|
expect(buildPairQrPayload({ addresses: ['192.168.1.20'], port: 47321, fingerprint: fp, code: 'ABCDEFGH' })).toBe(`aiturk://pair?v=1&u=192.168.1.20:47321&fp=${fp}&c=ABCDEFGH`)
|
||||||
|
|
||||||
|
// Anything that is not exactly a uuid is dropped, never echoed into the QR.
|
||||||
|
for (const bad of [`https://turkservis.online/v1/aiturk/mobile-relay/r/${relay}`, relay.toUpperCase(), `${relay}&u=1.2.3.4:1`, '', 7 as any]) {
|
||||||
|
expect(buildPairQrPayload({ addresses: ['10.0.0.5'], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).not.toContain('r=')
|
||||||
|
expect(() => buildPairQrPayload({ addresses: [], port: 47321, fingerprint: fp, code: 'ABCDEFGH', relay: bad })).toThrow('aiturk-mobile:no_lan_address')
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodies: any[] = []
|
||||||
|
|
||||||
|
const fetcher = async (_url: string, init: any) => {
|
||||||
|
bodies.push(JSON.parse(init.body))
|
||||||
|
|
||||||
|
return { status: 201, json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay })).not.toBeNull()
|
||||||
|
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: ['10.0.0.5'], port: 47321, fingerprint: fp })).not.toBeNull()
|
||||||
|
// Neither a LAN url nor a relay: nothing is published.
|
||||||
|
expect(await publishPairOffer(fetcher, { code: 'ABCDEFGH', addresses: [], port: 47321, fingerprint: fp, relay: 'nope' })).toBeNull()
|
||||||
|
expect(bodies).toEqual([
|
||||||
|
{ lookup: pairLookup('ABCDEFGH'), urls: [], fp, relay },
|
||||||
|
{ lookup: pairLookup('ABCDEFGH'), urls: ['https://10.0.0.5:47321'], fp }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('firewall rule query: fixed netsh arguments; English and Turkish answers; present / absent / other port / disabled / unreadable', async () => {
|
||||||
|
const query = firewallQueryCommand('C:\\Windows')
|
||||||
|
|
||||||
|
expect(query).toEqual({ file: 'C:\\Windows\\System32\\netsh.exe', args: ['advfirewall', 'firewall', 'show', 'rule', 'name=AITURK IDE Telefon Baglantisi'] })
|
||||||
|
expect(firewallQueryCommand("C:\\x'; calc; '").file).toBe('C:\\Windows\\System32\\netsh.exe')
|
||||||
|
|
||||||
|
const english = (port: string, enabled = 'Yes') =>
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'Rule Name: AITURK IDE Telefon Baglantisi',
|
||||||
|
'----------------------------------------------------------------------',
|
||||||
|
`Enabled: ${enabled}`,
|
||||||
|
'Direction: In',
|
||||||
|
'Profiles: Private,Public',
|
||||||
|
'Grouping: ',
|
||||||
|
'LocalIP: Any',
|
||||||
|
'RemoteIP: Any',
|
||||||
|
'Protocol: TCP',
|
||||||
|
`LocalPort: ${port}`,
|
||||||
|
'RemotePort: Any',
|
||||||
|
'Edge traversal: No',
|
||||||
|
'Action: Allow',
|
||||||
|
'Ok.',
|
||||||
|
''
|
||||||
|
].join('\r\n')
|
||||||
|
|
||||||
|
// Turkish Windows, as the bytes arrive when the console code page is read as latin1 (labels garbled on purpose).
|
||||||
|
const turkish = (port: string, enabled = 'Evet') =>
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'Kural Adý: AITURK IDE Telefon Baglantisi',
|
||||||
|
'----------------------------------------------------------------------',
|
||||||
|
`Etkin: ${enabled}`,
|
||||||
|
'Yön: Gelen',
|
||||||
|
'Profiller: Özel,Ortak',
|
||||||
|
'Gruplandýrma: ',
|
||||||
|
'YerelIP: Herhangi',
|
||||||
|
'UzakIP: Herhangi',
|
||||||
|
'Ýletiþim Kuralý: TCP',
|
||||||
|
`YerelBağlantıNoktası: ${port}`,
|
||||||
|
'UzakBağlantıNoktası: Herhangi',
|
||||||
|
'Kenar geçiþi: Hayýr',
|
||||||
|
'Eylem: Ýzin Ver',
|
||||||
|
'Tamam.',
|
||||||
|
''
|
||||||
|
].join('\r\n')
|
||||||
|
|
||||||
|
expect(parseFirewallRuleOutput(english('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
|
||||||
|
expect(parseFirewallRuleOutput(turkish('47321'), 0)).toEqual({ state: 'present', ports: [47321] })
|
||||||
|
expect(parseFirewallRuleOutput(english('47330'), 0)).toEqual({ state: 'present', ports: [47330] })
|
||||||
|
expect(parseFirewallRuleOutput(turkish('47321,47325-47326'), 0)).toEqual({ state: 'present', ports: [47321, 47325, 47326] })
|
||||||
|
// Two rules under the same name (the port changed once): both ports count.
|
||||||
|
expect(parseFirewallRuleOutput(english('47321') + turkish('47322'), 0)).toEqual({ state: 'present', ports: [47321, 47322] })
|
||||||
|
// A disabled rule allows nothing.
|
||||||
|
expect(parseFirewallRuleOutput(english('47321', 'No'), 0)).toEqual({ state: 'present', ports: [] })
|
||||||
|
expect(parseFirewallRuleOutput(turkish('47321', 'Hayýr'), 0)).toEqual({ state: 'present', ports: [] })
|
||||||
|
expect(parseFirewallRuleOutput('\r\nNo rules match the specified criteria.\r\n\r\n', 1)).toEqual({ state: 'absent' })
|
||||||
|
expect(parseFirewallRuleOutput('\r\nBelirtilen ölçütlerle eþleen kural yok.\r\n\r\n', 1)).toEqual({ state: 'absent' })
|
||||||
|
// No answer at all (netsh missing, timeout) is not "no rule".
|
||||||
|
expect(parseFirewallRuleOutput('', null)).toEqual({ state: 'unknown' })
|
||||||
|
expect(parseFirewallRuleOutput('', 1)).toEqual({ state: 'unknown' })
|
||||||
|
expect(parseFirewallRuleOutput('something unexpected', 0)).toEqual({ state: 'unknown' })
|
||||||
|
|
||||||
|
const ran: any[] = []
|
||||||
|
|
||||||
|
const firewall = createWindowsFirewall({
|
||||||
|
platform: 'win32',
|
||||||
|
env: { SystemRoot: 'C:\\Windows' },
|
||||||
|
spawn: () => {
|
||||||
|
throw new Error('the query must not use the elevated path')
|
||||||
|
},
|
||||||
|
run: async (file, args) => {
|
||||||
|
ran.push({ file, args })
|
||||||
|
|
||||||
|
return { code: 0, stdout: english('47321') }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await firewall.queryRule!()).toEqual({ state: 'present', ports: [47321] })
|
||||||
|
expect(ran).toEqual([query])
|
||||||
|
|
||||||
|
const failing = createWindowsFirewall({ platform: 'win32', env: {}, spawn: () => ({ on: () => undefined }), run: async () => Promise.reject(new Error('x')) })
|
||||||
|
|
||||||
|
expect(await failing.queryRule!()).toEqual({ state: 'unknown' })
|
||||||
|
expect(await createWindowsFirewall({ platform: 'linux', env: {}, spawn: () => ({ on: () => undefined }) }).queryRule!()).toEqual({ state: 'unknown' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,733 @@
|
|||||||
|
/**
|
||||||
|
* mobile-relay-client.ts
|
||||||
|
*
|
||||||
|
* "Telefon bağlantısı" through the turkservis.online tunnel (contract:
|
||||||
|
* workspace/aiturk-mobil-tunel-spec.md, v3). The IDE opens ONE outbound
|
||||||
|
* WebSocket to the gateway. For every phone stream the gateway announces
|
||||||
|
* (`open`), this client opens a PLAIN TCP connection to this IDE's own TLS
|
||||||
|
* proxy on loopback (electron/mobile-lan-proxy.ts) and copies bytes both ways.
|
||||||
|
*
|
||||||
|
* It never terminates TLS and never looks at a byte: the TLS session is
|
||||||
|
* between the phone (which pins the proxy's certificate) and the proxy. So the
|
||||||
|
* gateway — and this file — only ever carry ciphertext, and every rule (device
|
||||||
|
* token, forbidden list, root 404, WebSocket path list) stays in the proxy.
|
||||||
|
*
|
||||||
|
* Security contract (do not relax):
|
||||||
|
* - `hostToken` (`tsmr_…`) lives only in the injected credential store (the
|
||||||
|
* controller keeps it in the safeStorage vault). It is sent in the
|
||||||
|
* `Authorization` header of the host WebSocket and nowhere else: not in a
|
||||||
|
* log line, a status object or an error message.
|
||||||
|
* - Log lines are status changes only.
|
||||||
|
* - Errors are fixed codes.
|
||||||
|
*
|
||||||
|
* Everything that touches the outside is injected (WebSocket connector, fetch,
|
||||||
|
* local TCP dial, timers, credential store) so the tests run against a fake
|
||||||
|
* relay on 127.0.0.1 and a fake clock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MOBILE_RELAY_ORIGIN = 'https://turkservis.online'
|
||||||
|
export const MOBILE_RELAY_PATH = '/v1/aiturk/mobile-relay'
|
||||||
|
export const MOBILE_RELAY_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
/** Largest payload of one binary frame, either direction (contract §1). */
|
||||||
|
export const RELAY_CHUNK_BYTES = 64 * 1024
|
||||||
|
/** Stream id prefix of a binary frame: big-endian uint32. */
|
||||||
|
export const RELAY_FRAME_HEADER_BYTES = 4
|
||||||
|
/** Pending bytes allowed per direction of one stream (contract §1 / §2). */
|
||||||
|
export const RELAY_BUFFER_LIMIT_BYTES = 1024 * 1024
|
||||||
|
/** Reconnect delays: 2 s, then growing to 30 s. */
|
||||||
|
export const RELAY_BACKOFF_MS: readonly number[] = [2000, 4000, 8000, 16_000, 30_000]
|
||||||
|
/** The gateway pings every 20 s and drops a silent host after 60 s; we give up a little later. */
|
||||||
|
export const RELAY_SILENCE_MS = 75_000
|
||||||
|
/** Concurrent phone streams (the gateway allows 16 per relay). */
|
||||||
|
export const RELAY_MAX_STREAMS = 16
|
||||||
|
/** Upper bound for one frame on the host socket; a data frame is at most 64 KiB + 4. */
|
||||||
|
export const RELAY_MAX_FRAME_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
const BUFFER_RESUME_BYTES = 256 * 1024
|
||||||
|
const BUFFER_POLL_MS = 25
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||||
|
const HOST_TOKEN_RE = /^tsmr_[A-Za-z0-9_-]{43}$/
|
||||||
|
|
||||||
|
export const relayHostUrl = (relayId: string) => `wss://turkservis.online${MOBILE_RELAY_PATH}/host/${relayId}`
|
||||||
|
|
||||||
|
/** The `r` of the QR and the `relay` of the rendezvous record: a lower-case uuid, nothing else. */
|
||||||
|
export function isRelayId(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && UUID_RE.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayCredentials {
|
||||||
|
relayId: string
|
||||||
|
hostToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRelayCredentials(value: unknown): value is RelayCredentials {
|
||||||
|
const row = value as null | RelayCredentials
|
||||||
|
|
||||||
|
return Boolean(row) && isRelayId(row.relayId) && typeof row.hostToken === 'string' && HOST_TOKEN_RE.test(row.hostToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the `POST /sessions` answer: `{relayId, hostToken}` and nothing we would follow. */
|
||||||
|
export function parseRelaySession(data: unknown): null | RelayCredentials {
|
||||||
|
const row = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
||||||
|
const candidate = { relayId: typeof row.relayId === 'string' ? row.relayId.toLowerCase() : row.relayId, hostToken: row.hostToken }
|
||||||
|
|
||||||
|
return isRelayCredentials(candidate) ? { relayId: candidate.relayId, hostToken: candidate.hostToken } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayFetchResponse {
|
||||||
|
status: number
|
||||||
|
json: () => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayFetch = (url: string, init: Record<string, unknown>) => Promise<RelayFetchResponse>
|
||||||
|
|
||||||
|
export type RelayFailureCode =
|
||||||
|
| 'register_failed'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'vault_unavailable'
|
||||||
|
| 'connect_failed'
|
||||||
|
| 'token_rejected'
|
||||||
|
| 'replaced'
|
||||||
|
| 'busy'
|
||||||
|
| 'timeout'
|
||||||
|
|
||||||
|
/** Wait before the single second try of a request whose first try never got an answer. */
|
||||||
|
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
|
||||||
|
|
||||||
|
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
|
||||||
|
export function isTimeoutError(error: unknown): boolean {
|
||||||
|
const name = (error as null | { name?: unknown })?.name
|
||||||
|
|
||||||
|
return name === 'TimeoutError' || name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkRetryOptions {
|
||||||
|
/** Injected wait (tests: fake clock). Default: a real timer. */
|
||||||
|
sleep?: (ms: number) => Promise<void>
|
||||||
|
retryDelayMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /sessions`; a fixed failure code instead of any upstream text.
|
||||||
|
*
|
||||||
|
* A process that sat idle for a long time can fail its first request on a dead
|
||||||
|
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
|
||||||
|
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
|
||||||
|
*/
|
||||||
|
export async function registerRelaySession(
|
||||||
|
fetcher: RelayFetch,
|
||||||
|
options: NetworkRetryOptions = {}
|
||||||
|
): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||||
|
const sleep = options.sleep ?? realSleep
|
||||||
|
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
let response: RelayFetchResponse
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
redirect: 'error',
|
||||||
|
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= 2 || isTimeoutError(error)) {
|
||||||
|
return { error: 'register_failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (response.status !== 201) {
|
||||||
|
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||||
|
} catch {
|
||||||
|
return { error: 'register_failed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What the client needs from the outside ──────────────────────────────────
|
||||||
|
|
||||||
|
/** Host WebSocket (the `ws` package fits: it can send request headers). */
|
||||||
|
export interface RelaySocket {
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||||
|
/** A string is a text frame, a Buffer a binary frame. */
|
||||||
|
send: (data: Buffer | string) => unknown
|
||||||
|
close: (code?: number) => unknown
|
||||||
|
terminate?: () => unknown
|
||||||
|
/** Stop / continue reading from the gateway (back-pressure towards a slow local connection). */
|
||||||
|
pause?: () => unknown
|
||||||
|
resume?: () => unknown
|
||||||
|
readonly bufferedAmount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayConnect = (url: string, headers: Record<string, string>) => RelaySocket
|
||||||
|
|
||||||
|
export interface RelayWebSocketCtor {
|
||||||
|
new (url: string, options?: Record<string, unknown>): any
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain TCP connection to the local proxy (`net.Socket` fits). */
|
||||||
|
export interface RelayLocalSocket {
|
||||||
|
on: (event: string, listener: (...args: any[]) => void) => unknown
|
||||||
|
write: (data: Buffer) => boolean
|
||||||
|
destroy: () => unknown
|
||||||
|
pause: () => unknown
|
||||||
|
resume: () => unknown
|
||||||
|
readonly writableLength?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null = the proxy is not listening right now. */
|
||||||
|
export type RelayDial = () => null | RelayLocalSocket
|
||||||
|
|
||||||
|
/** One binary frame: stream id (big-endian uint32) + payload. */
|
||||||
|
export function encodeRelayFrame(id: number, payload: Buffer): Buffer {
|
||||||
|
const frame = Buffer.allocUnsafe(RELAY_FRAME_HEADER_BYTES + payload.length)
|
||||||
|
|
||||||
|
frame.writeUInt32BE(id, 0)
|
||||||
|
payload.copy(frame, RELAY_FRAME_HEADER_BYTES)
|
||||||
|
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pieces of at most `size` bytes, in order, without copying. */
|
||||||
|
export function splitRelayChunks(chunk: Buffer, size = RELAY_CHUNK_BYTES): Buffer[] {
|
||||||
|
const parts: Buffer[] = []
|
||||||
|
|
||||||
|
for (let offset = 0; offset < chunk.length; offset += size) {
|
||||||
|
parts.push(chunk.subarray(offset, offset + size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
const validStreamId = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0 && (value as number) <= 0xffffffff
|
||||||
|
|
||||||
|
// ── The client ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RelayStatus = 'off' | 'connecting' | 'connected' | 'failed'
|
||||||
|
|
||||||
|
export interface RelayState {
|
||||||
|
status: RelayStatus
|
||||||
|
/** Fixed code while `failed` (a retry is always scheduled), otherwise null. */
|
||||||
|
code: null | RelayFailureCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayCredentialStore {
|
||||||
|
/** null when there is no registration yet. Throwing = the vault is unusable. */
|
||||||
|
load: () => Promise<null | RelayCredentials>
|
||||||
|
/** `reissued` = this replaces a registration (rejected by the gateway, or renewed by the user). */
|
||||||
|
save: (credentials: RelayCredentials, info: { reissued: boolean }) => Promise<void>
|
||||||
|
clear: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelayClientDeps {
|
||||||
|
connect: RelayConnect
|
||||||
|
fetch: RelayFetch
|
||||||
|
dial: RelayDial
|
||||||
|
credentials: RelayCredentialStore
|
||||||
|
setTimer?: (fn: () => void, ms: number) => unknown
|
||||||
|
clearTimer?: (handle: unknown) => void
|
||||||
|
/** Status changes only. */
|
||||||
|
log?: (line: string) => void
|
||||||
|
onState?: (state: RelayState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stream {
|
||||||
|
local: RelayLocalSocket
|
||||||
|
paused: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRelayClient(deps: RelayClientDeps) {
|
||||||
|
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms))
|
||||||
|
const clearTimer = deps.clearTimer ?? (handle => clearTimeout(handle as NodeJS.Timeout))
|
||||||
|
|
||||||
|
let state: RelayState = { status: 'off', code: null }
|
||||||
|
let wanted = false
|
||||||
|
let generation = 0
|
||||||
|
let attempt = 0
|
||||||
|
let socket: null | RelaySocket = null
|
||||||
|
let credentials: null | RelayCredentials = null
|
||||||
|
/** The gateway rejected the stored registration; the next one replaces it. */
|
||||||
|
let rejected = false
|
||||||
|
let registering: null | Promise<RelayCredentials | { error: RelayFailureCode }> = null
|
||||||
|
let retryTimer: unknown = null
|
||||||
|
let silenceTimer: unknown = null
|
||||||
|
const streams = new Map<number, Stream>()
|
||||||
|
/** Same (injectable) clock as every other wait of this client. */
|
||||||
|
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||||
|
|
||||||
|
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||||
|
if (state.status === status && state.code === code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state = { status, code }
|
||||||
|
deps.log?.(`relay ${status}${code ? `:${code}` : ''}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
deps.onState?.(state)
|
||||||
|
} catch {
|
||||||
|
// a broken listener must not break the relay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers() {
|
||||||
|
if (retryTimer !== null) {
|
||||||
|
clearTimer(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (silenceTimer !== null) {
|
||||||
|
clearTimer(silenceTimer)
|
||||||
|
silenceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored registration, or a new one. One request at a time. */
|
||||||
|
function register(): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||||
|
if (credentials) {
|
||||||
|
return Promise.resolve(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
registering ??= (async (): Promise<RelayCredentials | { error: RelayFailureCode }> => {
|
||||||
|
let stored: null | RelayCredentials
|
||||||
|
|
||||||
|
try {
|
||||||
|
stored = await deps.credentials.load()
|
||||||
|
} catch {
|
||||||
|
return { error: 'vault_unavailable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRelayCredentials(stored)) {
|
||||||
|
credentials = stored
|
||||||
|
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||||
|
|
||||||
|
if ('error' in fresh) {
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.credentials.save(fresh, { reissued: rejected })
|
||||||
|
} catch {
|
||||||
|
return { error: 'vault_unavailable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected = false
|
||||||
|
credentials = fresh
|
||||||
|
|
||||||
|
return fresh
|
||||||
|
})().finally(() => {
|
||||||
|
registering = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return registering
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropStreams() {
|
||||||
|
for (const stream of streams.values()) {
|
||||||
|
stream.local.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry(code: RelayFailureCode, delay?: number) {
|
||||||
|
const wait = delay ?? RELAY_BACKOFF_MS[Math.min(attempt, RELAY_BACKOFF_MS.length - 1)]
|
||||||
|
|
||||||
|
attempt++
|
||||||
|
setState('failed', code)
|
||||||
|
retryTimer = setTimer(() => {
|
||||||
|
retryTimer = null
|
||||||
|
void connect()
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!wanted || socket) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mine = ++generation
|
||||||
|
|
||||||
|
clearTimers()
|
||||||
|
setState('connecting')
|
||||||
|
|
||||||
|
const current = await register()
|
||||||
|
|
||||||
|
if (!wanted || mine !== generation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('error' in current) {
|
||||||
|
retry(current.error)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws: RelaySocket
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = deps.connect(relayHostUrl(current.relayId), { Authorization: `Bearer ${current.hostToken}` })
|
||||||
|
} catch {
|
||||||
|
retry('connect_failed')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = ws
|
||||||
|
|
||||||
|
const send = (data: Buffer | string) => {
|
||||||
|
if (socket === ws) {
|
||||||
|
try {
|
||||||
|
ws.send(data)
|
||||||
|
} catch {
|
||||||
|
// the close event follows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = (value: Record<string, unknown>) => send(JSON.stringify(value))
|
||||||
|
|
||||||
|
const watch = () => {
|
||||||
|
if (silenceTimer !== null) {
|
||||||
|
clearTimer(silenceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
silenceTimer = setTimer(() => {
|
||||||
|
silenceTimer = null
|
||||||
|
|
||||||
|
if (socket === ws) {
|
||||||
|
try {
|
||||||
|
;(ws.terminate ?? ws.close).call(ws)
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
|
||||||
|
down(1006, 'timeout')
|
||||||
|
}
|
||||||
|
}, RELAY_SILENCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const down = (code: number, reason?: RelayFailureCode) => {
|
||||||
|
if (socket !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = null
|
||||||
|
clearTimers()
|
||||||
|
dropStreams()
|
||||||
|
|
||||||
|
if (!wanted) {
|
||||||
|
setState('off')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4401: the token was refused. 4404 / HTTP 404: the gateway no longer knows this relay
|
||||||
|
// (unused for 30 days). Either way the registration is dead: take a new one.
|
||||||
|
if (code === 4401 || code === 4404) {
|
||||||
|
credentials = null
|
||||||
|
rejected = true
|
||||||
|
void deps.credentials
|
||||||
|
.clear()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(() => {
|
||||||
|
if (wanted && socket === null && retryTimer === null && mine === generation) {
|
||||||
|
retry('token_rejected')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 4409) {
|
||||||
|
// Another IDE instance holds this registration: wait 30 s instead of fighting it.
|
||||||
|
retry('replaced', RELAY_BACKOFF_MS[RELAY_BACKOFF_MS.length - 1])
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4429: the relay service is full; the normal growing delay applies.
|
||||||
|
retry(reason ?? (code === 4429 ? 'busy' : 'connect_failed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway → local back-pressure: while any local connection is behind, stop reading the host socket.
|
||||||
|
const stalled = new Set<Stream>()
|
||||||
|
|
||||||
|
const unstall = (stream: Stream) => {
|
||||||
|
if (stalled.delete(stream) && stalled.size === 0 && socket === ws) {
|
||||||
|
ws.resume?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends one stream on both sides. `tell` = the gateway does not know yet. */
|
||||||
|
const endStream = (id: number, stream: Stream, code: number, tell: boolean) => {
|
||||||
|
if (streams.get(id) !== stream) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.delete(id)
|
||||||
|
unstall(stream)
|
||||||
|
stream.local.destroy()
|
||||||
|
|
||||||
|
if (tell) {
|
||||||
|
control({ t: 'close', id, code })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openStream = (id: number) => {
|
||||||
|
if (streams.has(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streams.size >= RELAY_MAX_STREAMS) {
|
||||||
|
return control({ t: 'close', id, code: 4429 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let local: null | RelayLocalSocket
|
||||||
|
|
||||||
|
try {
|
||||||
|
local = deps.dial()
|
||||||
|
} catch {
|
||||||
|
local = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!local) {
|
||||||
|
return control({ t: 'close', id, code: 4503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream: Stream = { local, paused: false }
|
||||||
|
let failed = false
|
||||||
|
|
||||||
|
const live = () => streams.get(id) === stream
|
||||||
|
|
||||||
|
// Local → gateway back-pressure: stop reading the proxy while the host socket is behind.
|
||||||
|
const drain = () => {
|
||||||
|
if (!live() || socket !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ws.bufferedAmount ?? 0) > BUFFER_RESUME_BYTES) {
|
||||||
|
setTimer(drain, BUFFER_POLL_MS)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.paused = false
|
||||||
|
local.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.set(id, stream)
|
||||||
|
local.on('connect', () => {
|
||||||
|
if (live()) {
|
||||||
|
control({ t: 'opened', id })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
local.on('data', (chunk: Buffer) => {
|
||||||
|
if (!live()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes are forwarded exactly as read: no parsing, no TLS here.
|
||||||
|
for (const part of splitRelayChunks(chunk)) {
|
||||||
|
send(encodeRelayFrame(id, part))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stream.paused && (ws.bufferedAmount ?? 0) > RELAY_BUFFER_LIMIT_BYTES) {
|
||||||
|
stream.paused = true
|
||||||
|
local.pause()
|
||||||
|
setTimer(drain, BUFFER_POLL_MS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
local.on('drain', () => unstall(stream))
|
||||||
|
local.on('error', () => {
|
||||||
|
failed = true
|
||||||
|
})
|
||||||
|
local.on('close', () => endStream(id, stream, failed ? 1011 : 1000, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', () => {
|
||||||
|
if (socket === ws) {
|
||||||
|
attempt = 0
|
||||||
|
setState('connected')
|
||||||
|
watch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ws.on('close', (code: number) => down(Number(code) || 1006))
|
||||||
|
ws.on('error', () => down(1006))
|
||||||
|
// A refusal at the HTTP level (before the WebSocket exists) means the same as the close codes.
|
||||||
|
ws.on('unexpected-response', (req: { destroy?: () => void }, res: { statusCode?: number; resume?: () => void }) => {
|
||||||
|
const status = Number(res?.statusCode)
|
||||||
|
|
||||||
|
res?.resume?.()
|
||||||
|
req?.destroy?.()
|
||||||
|
down(status === 401 || status === 403 ? 4401 : status === 404 ? 4404 : status === 429 ? 4429 : 1006)
|
||||||
|
})
|
||||||
|
ws.on('message', (data: unknown, isBinary?: boolean) => {
|
||||||
|
if (socket !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watch()
|
||||||
|
|
||||||
|
if (isBinary) {
|
||||||
|
const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer)
|
||||||
|
|
||||||
|
if (frame.length <= RELAY_FRAME_HEADER_BYTES) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = frame.readUInt32BE(0)
|
||||||
|
const stream = streams.get(id)
|
||||||
|
|
||||||
|
// Late or unknown ids are ignored.
|
||||||
|
if (!stream) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.local.write(frame.subarray(RELAY_FRAME_HEADER_BYTES))
|
||||||
|
|
||||||
|
const pending = stream.local.writableLength ?? 0
|
||||||
|
|
||||||
|
// Gateway → local: a proxy that does not read must not make this process hold the data.
|
||||||
|
// First stop reading the gateway; if the backlog still reaches 1 MiB, give the stream up.
|
||||||
|
if (pending > RELAY_BUFFER_LIMIT_BYTES) {
|
||||||
|
endStream(id, stream, 4408, true)
|
||||||
|
} else if (pending > BUFFER_RESUME_BYTES && ws.pause && !stalled.has(stream)) {
|
||||||
|
stalled.add(stream)
|
||||||
|
ws.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: any
|
||||||
|
|
||||||
|
try {
|
||||||
|
message = JSON.parse(typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : String(data))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.t === 'ping') {
|
||||||
|
return control({ t: 'pong' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validStreamId(message.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.t === 'open') {
|
||||||
|
return openStream(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.t === 'close') {
|
||||||
|
const stream = streams.get(message.id)
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
endStream(message.id, stream, 1000, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
state: (): RelayState => state,
|
||||||
|
|
||||||
|
/** Open phone streams right now. */
|
||||||
|
streams: (): number => streams.size,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure a registration exists (stored or new) and returns its id; null
|
||||||
|
* when the gateway or the vault is unavailable. Does not open the socket.
|
||||||
|
*/
|
||||||
|
async ensureRegistered(): Promise<null | string> {
|
||||||
|
const current = await register()
|
||||||
|
|
||||||
|
return 'error' in current ? null : current.relayId
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Röle kimliğini yenile": take a NEW registration. The old one is replaced
|
||||||
|
* only once the new one is stored; false = nothing changed.
|
||||||
|
*/
|
||||||
|
async renew(): Promise<boolean> {
|
||||||
|
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||||
|
|
||||||
|
if ('error' in fresh) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deps.credentials.save(fresh, { reissued: true })
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials = fresh
|
||||||
|
rejected = false
|
||||||
|
|
||||||
|
if (wanted) {
|
||||||
|
// Drop the socket that was authenticated with the old token and come back with the new one.
|
||||||
|
api.stop()
|
||||||
|
api.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (wanted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wanted = true
|
||||||
|
attempt = 0
|
||||||
|
void connect()
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
wanted = false
|
||||||
|
generation++
|
||||||
|
clearTimers()
|
||||||
|
|
||||||
|
const ws = socket
|
||||||
|
|
||||||
|
socket = null
|
||||||
|
dropStreams()
|
||||||
|
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.close(1000)
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('off')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayClient = ReturnType<typeof createRelayClient>
|
||||||
@@ -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.15",
|
"version": "1.0.0-beta.18",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -1470,6 +1470,34 @@ def _wait_for_oneshot_background_completions(cli) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_approval_required(session_id) -> None:
|
||||||
|
"""Report approvals a one-shot run could not put in front of a human.
|
||||||
|
|
||||||
|
One ``approval_required: <json>`` line per blocked request on stderr,
|
||||||
|
next to ``session_id:``, so the process that launched this run can tell
|
||||||
|
"blocked, needs a human" from "still working" without parsing prose.
|
||||||
|
Nothing was applied for these requests; resuming the same session in an
|
||||||
|
interactive surface is the way to get them approved.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from tools.approval import drain_human_approval_required
|
||||||
|
records = drain_human_approval_required()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for record in records:
|
||||||
|
try:
|
||||||
|
if not record.get("session_id"):
|
||||||
|
record["session_id"] = session_id or ""
|
||||||
|
record["resume"] = "interactive"
|
||||||
|
print(
|
||||||
|
"approval_required: "
|
||||||
|
+ json.dumps(record, ensure_ascii=True, sort_keys=True),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
def _finalize_single_query(cli) -> None:
|
def _finalize_single_query(cli) -> None:
|
||||||
"""Close one-shot CLI resources before releasing the active session lease."""
|
"""Close one-shot CLI resources before releasing the active session lease."""
|
||||||
try:
|
try:
|
||||||
@@ -22360,6 +22388,7 @@ def main(
|
|||||||
|
|
||||||
# Session ID goes to stderr so piped stdout is clean.
|
# Session ID goes to stderr so piped stdout is clean.
|
||||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||||
|
_emit_approval_required(cli.session_id)
|
||||||
|
|
||||||
# Ensure proper exit code for automation wrappers.
|
# Ensure proper exit code for automation wrappers.
|
||||||
#
|
#
|
||||||
|
|||||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"version": "1.0.0-beta.15",
|
"version": "1.0.0-beta.18",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/core": "0.2.23",
|
"@assistant-ui/core": "0.2.23",
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
|||||||
"HERMES_REAL_HOME",
|
"HERMES_REAL_HOME",
|
||||||
"TERMINAL_HOME_MODE",
|
"TERMINAL_HOME_MODE",
|
||||||
"HERMES_INTERACTIVE",
|
"HERMES_INTERACTIVE",
|
||||||
|
"HERMES_SINGLE_QUERY_SESSION",
|
||||||
"HERMES_QUIET",
|
"HERMES_QUIET",
|
||||||
"HERMES_TOOL_PROGRESS",
|
"HERMES_TOOL_PROGRESS",
|
||||||
"HERMES_TOOL_PROGRESS_MODE",
|
"HERMES_TOOL_PROGRESS_MODE",
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
"""Protected instruction-file gate when no human can be reached.
|
||||||
|
|
||||||
|
A one-shot run (``chat -q`` / ``--query-file``) installs the CLI approval
|
||||||
|
callback but never starts the prompt UI. The gate used to treat the
|
||||||
|
registered callback as a reachable human and wait out ``approvals.timeout``
|
||||||
|
on a panel nobody could see. These tests pin the fixed contract:
|
||||||
|
|
||||||
|
* no reachable human -> BLOCKED at once, nothing written, the outcome is
|
||||||
|
recorded for the launching process;
|
||||||
|
* the gate itself is unchanged: still asks a real human every time, still
|
||||||
|
ignores yolo and ``approvals.single_query_mode``;
|
||||||
|
* an approval that arrives after the calling execute_code script stopped
|
||||||
|
waiting is not applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _gate_on(monkeypatch):
|
||||||
|
import tools.approval as A
|
||||||
|
import tools.file_tools as ft
|
||||||
|
|
||||||
|
monkeypatch.setattr(ft, "_protected_instruction_config", lambda: (True, []))
|
||||||
|
monkeypatch.delenv("HERMES_SINGLE_QUERY_SESSION", raising=False)
|
||||||
|
A.drain_human_approval_required()
|
||||||
|
A.set_approval_reply_deadline(None)
|
||||||
|
yield
|
||||||
|
A.drain_human_approval_required()
|
||||||
|
A.set_approval_reply_deadline(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def approvals():
|
||||||
|
"""CLI approval callback that records calls and gives a scripted answer."""
|
||||||
|
from tools.terminal_tool import set_approval_callback
|
||||||
|
|
||||||
|
state = {"calls": [], "answer": "deny"}
|
||||||
|
|
||||||
|
def cb(command, description, **kwargs):
|
||||||
|
state["calls"].append({"command": command, **kwargs})
|
||||||
|
answer = state["answer"]
|
||||||
|
if callable(answer):
|
||||||
|
return answer()
|
||||||
|
return answer
|
||||||
|
|
||||||
|
set_approval_callback(cb)
|
||||||
|
yield state
|
||||||
|
set_approval_callback(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def one_shot(monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path, content="injected"):
|
||||||
|
from tools.file_tools import write_file_tool
|
||||||
|
|
||||||
|
return json.loads(write_file_tool(str(path), content))
|
||||||
|
|
||||||
|
|
||||||
|
def _records():
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
return A.drain_human_approval_required()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- one-shot run: callback registered, nobody to answer ------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOneShotRun:
|
||||||
|
def test_blocks_at_once_without_prompting(self, tmp_path, approvals, one_shot):
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
target.write_text("rules\n", encoding="utf-8")
|
||||||
|
approvals["answer"] = "once" # would be honored if anyone were asked
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
res = _write(target)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert "APPROVAL REQUIRED" in res["error"]
|
||||||
|
assert target.read_text(encoding="utf-8") == "rules\n"
|
||||||
|
assert approvals["calls"] == [], "prompted on a surface nobody can see"
|
||||||
|
assert elapsed < 5
|
||||||
|
|
||||||
|
def test_outcome_is_recorded_for_the_launcher(self, tmp_path, approvals, one_shot):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
target = tmp_path / "CLAUDE.md"
|
||||||
|
sid = A._approval_session_id.set("sess-123")
|
||||||
|
tcid = A._approval_tool_call_id.set("call-9")
|
||||||
|
try:
|
||||||
|
_write(target)
|
||||||
|
finally:
|
||||||
|
A._approval_tool_call_id.reset(tcid)
|
||||||
|
A._approval_session_id.reset(sid)
|
||||||
|
|
||||||
|
records = _records()
|
||||||
|
assert len(records) == 1
|
||||||
|
rec = records[0]
|
||||||
|
assert rec["kind"] == "protected_instruction_file"
|
||||||
|
assert rec["reason"] == "no_interactive_user"
|
||||||
|
assert rec["applied"] is False
|
||||||
|
assert rec["session_id"] == "sess-123"
|
||||||
|
assert rec["tool_call_id"] == "call-9"
|
||||||
|
assert rec["request_id"]
|
||||||
|
assert any("CLAUDE.md" in t for t in rec["targets"])
|
||||||
|
assert _records() == [], "drain must clear"
|
||||||
|
|
||||||
|
def test_yolo_does_not_open_the_gate(self, tmp_path, approvals, one_shot, monkeypatch):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
monkeypatch.setattr(A, "_YOLO_MODE_FROZEN", True)
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
res = _write(target)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
def test_single_query_mode_approve_does_not_open_the_gate(
|
||||||
|
self, tmp_path, approvals, one_shot, monkeypatch
|
||||||
|
):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
real_cfg_get = A.cfg_get
|
||||||
|
|
||||||
|
def fake_cfg_get(config, *keys, default=None):
|
||||||
|
if keys == ("approvals", "single_query_mode"):
|
||||||
|
return "approve"
|
||||||
|
return real_cfg_get(config, *keys, default=default)
|
||||||
|
|
||||||
|
monkeypatch.setattr(A, "cfg_get", fake_cfg_get)
|
||||||
|
target = tmp_path / "SOUL.md"
|
||||||
|
res = _write(target)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
def test_regular_file_still_writes(self, tmp_path, approvals, one_shot):
|
||||||
|
target = tmp_path / "notes.md"
|
||||||
|
res = _write(target, "hello")
|
||||||
|
assert not res.get("error"), res
|
||||||
|
assert target.read_text(encoding="utf-8") == "hello"
|
||||||
|
assert _records() == []
|
||||||
|
|
||||||
|
def test_multi_file_patch_applies_nothing(self, tmp_path, approvals, one_shot):
|
||||||
|
from tools.file_tools import patch_tool
|
||||||
|
|
||||||
|
agents = tmp_path / "AGENTS.md"
|
||||||
|
agents.write_text("rules\n", encoding="utf-8")
|
||||||
|
plain = tmp_path / "plain.txt"
|
||||||
|
plain.write_text("hello\n", encoding="utf-8")
|
||||||
|
patch = (
|
||||||
|
"*** Begin Patch\n"
|
||||||
|
f"*** Update File: {plain}\n"
|
||||||
|
"@@\n"
|
||||||
|
"-hello\n"
|
||||||
|
"+world\n"
|
||||||
|
f"*** Update File: {agents}\n"
|
||||||
|
"@@\n"
|
||||||
|
"-rules\n"
|
||||||
|
"+injected\n"
|
||||||
|
"*** End Patch"
|
||||||
|
)
|
||||||
|
res = json.loads(patch_tool(mode="patch", patch=patch))
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert plain.read_text(encoding="utf-8") == "hello\n"
|
||||||
|
assert agents.read_text(encoding="utf-8") == "rules\n"
|
||||||
|
assert approvals["calls"] == []
|
||||||
|
|
||||||
|
def test_retry_in_same_run_blocks_again(self, tmp_path, approvals, one_shot):
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
_write(target)
|
||||||
|
res = _write(target)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
assert len(_records()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---- no channel at all ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_channel_fails_closed_and_is_recorded(tmp_path):
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
res = _write(target)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
records = _records()
|
||||||
|
assert [r["reason"] for r in records] == ["no_approval_channel"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_undeliverable_gateway_request_is_recorded(tmp_path):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
session_key = "protected-unattended-undelivered"
|
||||||
|
token = A.set_current_session_key(session_key)
|
||||||
|
try:
|
||||||
|
def notify(approval_data):
|
||||||
|
raise RuntimeError("client went away")
|
||||||
|
|
||||||
|
A.register_gateway_notify(session_key, notify)
|
||||||
|
try:
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
res = _write(target)
|
||||||
|
finally:
|
||||||
|
A.unregister_gateway_notify(session_key)
|
||||||
|
finally:
|
||||||
|
A.reset_current_session_key(token)
|
||||||
|
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
assert [r["reason"] for r in _records()] == ["approval_not_delivered"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- a reachable human: the gate behaves exactly as before -----------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReachableHuman:
|
||||||
|
def test_allow_writes(self, tmp_path, approvals):
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
approvals["answer"] = "once"
|
||||||
|
res = _write(target, "approved")
|
||||||
|
assert not res.get("error"), res
|
||||||
|
assert target.read_text(encoding="utf-8") == "approved"
|
||||||
|
assert len(approvals["calls"]) == 1
|
||||||
|
assert _records() == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("answer", ["deny", "timeout"])
|
||||||
|
def test_deny_and_silence_block(self, tmp_path, approvals, answer):
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
approvals["answer"] = answer
|
||||||
|
res = _write(target)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
assert len(approvals["calls"]) == 1
|
||||||
|
# A human was asked and did not consent: not an "unattended" outcome.
|
||||||
|
assert _records() == []
|
||||||
|
|
||||||
|
def test_prompt_that_dies_blocks(self, tmp_path, approvals):
|
||||||
|
"""Cancel / lost surface: the prompt raising is not consent."""
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("prompt surface lost")
|
||||||
|
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
approvals["answer"] = boom
|
||||||
|
res = _write(target)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- approval that arrives after the caller stopped waiting ----------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLateApproval:
|
||||||
|
def test_late_cli_approval_is_not_applied(self, tmp_path, approvals):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
target.write_text("rules\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def answer_after_deadline():
|
||||||
|
# The human says yes, but only once the execute_code script that
|
||||||
|
# asked for this write has already timed out.
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||||
|
return "once"
|
||||||
|
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() + 300)
|
||||||
|
approvals["answer"] = answer_after_deadline
|
||||||
|
res = _write(target)
|
||||||
|
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert "NOT applied" in res["error"]
|
||||||
|
assert target.read_text(encoding="utf-8") == "rules\n"
|
||||||
|
|
||||||
|
def test_late_approval_does_not_carry_to_the_next_request(self, tmp_path, approvals):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||||
|
approvals["answer"] = "once"
|
||||||
|
assert "BLOCKED" in _write(target).get("error", "")
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
# A fresh call (new deadline) asks again; the old yes is not reused.
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() + 300)
|
||||||
|
approvals["answer"] = "deny"
|
||||||
|
assert "BLOCKED" in _write(target).get("error", "")
|
||||||
|
assert not target.exists()
|
||||||
|
assert len(approvals["calls"]) == 2
|
||||||
|
|
||||||
|
def test_in_time_approval_is_applied(self, tmp_path, approvals):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() + 300)
|
||||||
|
approvals["answer"] = "once"
|
||||||
|
res = _write(target, "approved")
|
||||||
|
assert not res.get("error"), res
|
||||||
|
assert target.read_text(encoding="utf-8") == "approved"
|
||||||
|
|
||||||
|
def test_late_gateway_approval_is_not_applied(self, tmp_path):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
session_key = "protected-unattended-late"
|
||||||
|
token = A.set_current_session_key(session_key)
|
||||||
|
try:
|
||||||
|
def notify(approval_data):
|
||||||
|
A.resolve_gateway_approval(session_key, "once")
|
||||||
|
|
||||||
|
A.register_gateway_notify(session_key, notify)
|
||||||
|
try:
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
res = _write(target)
|
||||||
|
finally:
|
||||||
|
A.unregister_gateway_notify(session_key)
|
||||||
|
finally:
|
||||||
|
A.reset_current_session_key(token)
|
||||||
|
|
||||||
|
assert res.get("error") and "NOT applied" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
|
||||||
|
def test_no_deadline_outside_execute_code(self):
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
assert A.approval_reply_window_remaining() is None
|
||||||
|
assert A.approval_arrived_too_late() is False
|
||||||
|
|
||||||
|
def test_rpc_dispatch_binds_and_restores_the_deadline(self):
|
||||||
|
import tools.approval as A
|
||||||
|
import tools.code_execution_tool as cet
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
previous = cet._bind_rpc_reply_deadline(start)
|
||||||
|
try:
|
||||||
|
remaining = A.approval_reply_window_remaining()
|
||||||
|
assert remaining is not None
|
||||||
|
assert 0 < remaining <= cet._RPC_REPLY_TIMEOUT_SECONDS
|
||||||
|
finally:
|
||||||
|
cet._restore_rpc_reply_deadline(previous)
|
||||||
|
assert A.approval_reply_window_remaining() is None
|
||||||
|
|
||||||
|
def test_deadline_is_per_thread(self):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
A.set_approval_reply_deadline(time.monotonic() - 1)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def other():
|
||||||
|
seen["late"] = A.approval_arrived_too_late()
|
||||||
|
|
||||||
|
worker = threading.Thread(target=other)
|
||||||
|
worker.start()
|
||||||
|
worker.join()
|
||||||
|
assert seen["late"] is False
|
||||||
|
assert A.approval_arrived_too_late() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- what the launching process sees ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_outcomes_are_bounded():
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
for i in range(A._HUMAN_APPROVAL_REQUIRED_MAX + 20):
|
||||||
|
A.note_human_approval_required("protected_instruction_file", [f"f{i}"], "x")
|
||||||
|
records = A.drain_human_approval_required()
|
||||||
|
assert len(records) == A._HUMAN_APPROVAL_REQUIRED_MAX
|
||||||
|
assert records[-1]["targets"] == [f"f{A._HUMAN_APPROVAL_REQUIRED_MAX + 19}"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_shot_exit_reports_approval_required_on_stderr(capsys):
|
||||||
|
import cli as cli_module
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
A.note_human_approval_required(
|
||||||
|
"protected_instruction_file", ["AGENTS.md"], "no_interactive_user"
|
||||||
|
)
|
||||||
|
cli_module._emit_approval_required("sess-42")
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
lines = [l for l in err.splitlines() if l.startswith("approval_required: ")]
|
||||||
|
assert len(lines) == 1
|
||||||
|
payload = json.loads(lines[0].split(": ", 1)[1])
|
||||||
|
assert payload["session_id"] == "sess-42"
|
||||||
|
assert payload["applied"] is False
|
||||||
|
assert payload["reason"] == "no_interactive_user"
|
||||||
|
assert payload["resume"] == "interactive"
|
||||||
|
assert payload["targets"] == ["AGENTS.md"]
|
||||||
|
|
||||||
|
cli_module._emit_approval_required("sess-42")
|
||||||
|
assert "approval_required" not in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
# ---- execute_code RPC: the dispatcher really binds the window ---------------
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_rpc_once(dispatch, stop_event=None):
|
||||||
|
"""Send one tool call through the real RPC server loop; return its reply."""
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import tools.code_execution_tool as cet
|
||||||
|
|
||||||
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server.bind(("127.0.0.1", 0))
|
||||||
|
server.listen(1)
|
||||||
|
stop_event = stop_event or threading.Event()
|
||||||
|
worker = threading.Thread(
|
||||||
|
target=cet._rpc_server_loop,
|
||||||
|
args=(server, "task", [], [0], 5, frozenset({"patch"}), stop_event, "tok", dispatch),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
client = socket.create_connection(server.getsockname(), timeout=10)
|
||||||
|
try:
|
||||||
|
client.sendall(
|
||||||
|
(json.dumps({"tool": "patch", "args": {}, "token": "tok"}) + "\n").encode()
|
||||||
|
)
|
||||||
|
buf = b""
|
||||||
|
while not buf.endswith(b"\n"):
|
||||||
|
chunk = client.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
stop_event.set()
|
||||||
|
worker.join(timeout=10)
|
||||||
|
server.close()
|
||||||
|
return buf.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_server_loop_binds_reply_window_around_dispatch():
|
||||||
|
import tools.approval as A
|
||||||
|
import tools.code_execution_tool as cet
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def dispatch(tool_name, tool_args):
|
||||||
|
seen["remaining"] = A.approval_reply_window_remaining()
|
||||||
|
seen["late"] = A.approval_arrived_too_late()
|
||||||
|
return json.dumps({"ok": True})
|
||||||
|
|
||||||
|
reply = _drive_rpc_once(dispatch)
|
||||||
|
assert json.loads(reply) == {"ok": True}
|
||||||
|
assert seen["late"] is False
|
||||||
|
assert 0 < seen["remaining"] <= cet._RPC_REPLY_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_approval_after_script_ended_is_late():
|
||||||
|
"""Script killed by its own run timeout while the prompt is still open."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def dispatch(tool_name, tool_args):
|
||||||
|
seen["before"] = A.approval_arrived_too_late()
|
||||||
|
stop_event.set() # the script ended while a human was deciding
|
||||||
|
seen["after"] = A.approval_arrived_too_late()
|
||||||
|
return json.dumps({"ok": True})
|
||||||
|
|
||||||
|
_drive_rpc_once(dispatch, stop_event)
|
||||||
|
assert seen == {"before": False, "after": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_write_through_rpc_is_dropped_when_script_ended(tmp_path, approvals):
|
||||||
|
"""End to end through the RPC loop: late yes -> BLOCKED, file untouched."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from tools.file_tools import write_file_tool
|
||||||
|
from tools.terminal_tool import _get_approval_callback, set_approval_callback
|
||||||
|
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
target.write_text("rules\n", encoding="utf-8")
|
||||||
|
stop_event = threading.Event()
|
||||||
|
callback = _get_approval_callback()
|
||||||
|
|
||||||
|
def answer_after_script_died():
|
||||||
|
stop_event.set()
|
||||||
|
return "once"
|
||||||
|
|
||||||
|
approvals["answer"] = answer_after_script_died
|
||||||
|
|
||||||
|
def dispatch(tool_name, tool_args):
|
||||||
|
set_approval_callback(callback) # the RPC thread carries the cell's callback
|
||||||
|
try:
|
||||||
|
return write_file_tool(str(target), "injected")
|
||||||
|
finally:
|
||||||
|
set_approval_callback(None)
|
||||||
|
|
||||||
|
reply = json.loads(_drive_rpc_once(dispatch, stop_event))
|
||||||
|
assert "NOT applied" in reply.get("error", "")
|
||||||
|
assert target.read_text(encoding="utf-8") == "rules\n"
|
||||||
|
assert len(approvals["calls"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_retired_kernel_cell_counts_as_gone():
|
||||||
|
import tools.approval as A
|
||||||
|
|
||||||
|
cell = {"active": True}
|
||||||
|
previous = A.also_gone_when(lambda: not cell["active"])
|
||||||
|
try:
|
||||||
|
assert A.approval_arrived_too_late() is False
|
||||||
|
cell["active"] = False
|
||||||
|
assert A.approval_arrived_too_late() is True
|
||||||
|
finally:
|
||||||
|
A.restore_approval_reply_deadline(previous)
|
||||||
|
assert A.approval_arrived_too_late() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- an automatic approver is not a person ----------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_subagent_auto_approve_cannot_open_the_gate(tmp_path):
|
||||||
|
from tools.delegate_tool import _subagent_auto_approve
|
||||||
|
from tools.terminal_tool import set_approval_callback
|
||||||
|
|
||||||
|
target = tmp_path / "AGENTS.md"
|
||||||
|
set_approval_callback(_subagent_auto_approve)
|
||||||
|
try:
|
||||||
|
res = _write(target)
|
||||||
|
finally:
|
||||||
|
set_approval_callback(None)
|
||||||
|
assert res.get("error") and "BLOCKED" in res["error"]
|
||||||
|
assert not target.exists()
|
||||||
|
assert [r["reason"] for r in _records()] == ["no_interactive_user"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocked_message_survives_braces_in_target(tmp_path):
|
||||||
|
import tools.file_tools as ft
|
||||||
|
|
||||||
|
res = ft._request_protected_instruction_approval(["{be}/AGENTS.md"])
|
||||||
|
assert res.startswith("BLOCKED") and "{be}/AGENTS.md" in res
|
||||||
|
|
||||||
|
|
||||||
|
# ---- sibling gates in a one-shot run ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_computer_use_does_not_wait_in_one_shot_run(one_shot, monkeypatch):
|
||||||
|
import tools.computer_use.tool as cu
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
|
||||||
|
res = cu._request_approval("click", {}, session_id="unattended-test")
|
||||||
|
assert res is not None and "one-shot run" in json.loads(res)["error"]
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_computer_use_still_asks_when_interactive(monkeypatch):
|
||||||
|
import tools.computer_use.tool as cu
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(cu, "_approval_callback", lambda *a, **k: calls.append(a) or "approve_once")
|
||||||
|
assert cu._request_approval("click", {}, session_id="attended-test") is None
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_write_prompt_unavailable_in_one_shot_run(approvals, monkeypatch):
|
||||||
|
import tools.write_approval as wa
|
||||||
|
|
||||||
|
assert wa._interactive_approval_available() is True
|
||||||
|
monkeypatch.setenv("HERMES_SINGLE_QUERY_SESSION", "1")
|
||||||
|
assert wa._interactive_approval_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_shot_marker_is_not_inherited_by_child_processes(one_shot):
|
||||||
|
from tools.environments.local import _make_run_env
|
||||||
|
|
||||||
|
assert "HERMES_SINGLE_QUERY_SESSION" not in _make_run_env({})
|
||||||
@@ -78,6 +78,115 @@ _hermes_interactive_ctx: contextvars.ContextVar[Optional[str]] = contextvars.Con
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Absolute ``time.monotonic()`` instant after which whoever issued the
|
||||||
|
# current tool call has stopped waiting for its result. Set by the
|
||||||
|
# execute_code RPC dispatchers, whose sandbox client gives up after a fixed
|
||||||
|
# reply timeout. An approval that resolves after this instant must NOT be
|
||||||
|
# acted on: the caller already reported the call as failed, so applying it
|
||||||
|
# would be a write the agent believes never happened (and may redo).
|
||||||
|
# The reply timeout is not the only way a caller goes away: the script can
|
||||||
|
# hit its own run timeout or be interrupted first. ``gone`` is a probe the
|
||||||
|
# dispatcher supplies for that ("has my script/cell already ended?").
|
||||||
|
# Thread-local on purpose: the dispatcher and the tool handler it calls run
|
||||||
|
# on the same thread, whatever context the handler is re-bound to.
|
||||||
|
_approval_reply_deadline = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def set_approval_reply_deadline(deadline: Optional[float], gone=None):
|
||||||
|
"""Bind the caller's reply window for this thread; return the prior binding.
|
||||||
|
|
||||||
|
``deadline`` is monotonic seconds (None = unbounded); ``gone`` is an
|
||||||
|
optional zero-argument callable that turns true once the caller has
|
||||||
|
ended. Pass the returned value to :func:`restore_approval_reply_deadline`.
|
||||||
|
"""
|
||||||
|
previous = (
|
||||||
|
getattr(_approval_reply_deadline, "value", None),
|
||||||
|
getattr(_approval_reply_deadline, "gone", None),
|
||||||
|
)
|
||||||
|
_approval_reply_deadline.value = deadline
|
||||||
|
_approval_reply_deadline.gone = gone
|
||||||
|
return previous
|
||||||
|
|
||||||
|
|
||||||
|
def restore_approval_reply_deadline(previous) -> None:
|
||||||
|
"""Undo :func:`set_approval_reply_deadline` / :func:`also_gone_when`."""
|
||||||
|
deadline, gone = previous if previous else (None, None)
|
||||||
|
_approval_reply_deadline.value = deadline
|
||||||
|
_approval_reply_deadline.gone = gone
|
||||||
|
|
||||||
|
|
||||||
|
def also_gone_when(probe):
|
||||||
|
"""Add another "caller has ended" probe, keeping the current window."""
|
||||||
|
deadline = getattr(_approval_reply_deadline, "value", None)
|
||||||
|
current = getattr(_approval_reply_deadline, "gone", None)
|
||||||
|
|
||||||
|
def gone() -> bool:
|
||||||
|
return bool(probe()) or bool(current and current())
|
||||||
|
|
||||||
|
return set_approval_reply_deadline(deadline, gone)
|
||||||
|
|
||||||
|
|
||||||
|
def approval_reply_window_remaining() -> Optional[float]:
|
||||||
|
"""Seconds left before the caller stops waiting, or None when unbounded."""
|
||||||
|
deadline = getattr(_approval_reply_deadline, "value", None)
|
||||||
|
if deadline is None:
|
||||||
|
return None
|
||||||
|
return deadline - time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def approval_arrived_too_late() -> bool:
|
||||||
|
"""True when the caller of the current tool call has stopped waiting."""
|
||||||
|
gone = getattr(_approval_reply_deadline, "gone", None)
|
||||||
|
if gone is not None:
|
||||||
|
try:
|
||||||
|
if gone():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
remaining = approval_reply_window_remaining()
|
||||||
|
return remaining is not None and remaining <= 0
|
||||||
|
|
||||||
|
|
||||||
|
# Approvals that could not be put in front of a human because the run has
|
||||||
|
# no interactive channel (single-query ``chat -Q`` stage runs, scripts).
|
||||||
|
# The single-query CLI reports them on stderr as ``approval_required:``
|
||||||
|
# lines so the coordinating process gets a machine-readable outcome
|
||||||
|
# instead of inferring one from prose. Bounded: long-lived processes that
|
||||||
|
# never drain it must not grow without limit.
|
||||||
|
_human_approval_required: list[dict] = []
|
||||||
|
_HUMAN_APPROVAL_REQUIRED_MAX = 50
|
||||||
|
_human_approval_required_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def note_human_approval_required(kind: str, targets: list[str], reason: str) -> dict:
|
||||||
|
"""Record an approval the run could not present to a human.
|
||||||
|
|
||||||
|
Captures the active session/tool-call identity so the coordinator can
|
||||||
|
tie the outcome to the exact job and resume the same session.
|
||||||
|
"""
|
||||||
|
record = {
|
||||||
|
"kind": kind,
|
||||||
|
"targets": list(dict.fromkeys(str(t) for t in targets)),
|
||||||
|
"reason": reason,
|
||||||
|
"request_id": uuid.uuid4().hex,
|
||||||
|
"tool_call_id": _approval_tool_call_id.get() or "",
|
||||||
|
"session_id": _approval_session_id.get() or os.getenv("HERMES_SESSION_ID", ""),
|
||||||
|
"applied": False,
|
||||||
|
}
|
||||||
|
with _human_approval_required_lock:
|
||||||
|
_human_approval_required.append(record)
|
||||||
|
del _human_approval_required[:-_HUMAN_APPROVAL_REQUIRED_MAX]
|
||||||
|
return dict(record)
|
||||||
|
|
||||||
|
|
||||||
|
def drain_human_approval_required() -> list[dict]:
|
||||||
|
"""Return and clear the recorded approval-required outcomes."""
|
||||||
|
with _human_approval_required_lock:
|
||||||
|
records = list(_human_approval_required)
|
||||||
|
_human_approval_required.clear()
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
|
def set_hermes_interactive_context(interactive: bool) -> contextvars.Token:
|
||||||
"""Bind interactive mode for the current context (thread or asyncio task).
|
"""Bind interactive mode for the current context (thread or asyncio task).
|
||||||
|
|
||||||
|
|||||||
@@ -716,6 +716,36 @@ def _call(tool_name, args):
|
|||||||
# RPC server (runs in a thread inside the parent process)
|
# RPC server (runs in a thread inside the parent process)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# How long the generated sandbox client waits for one tool-call reply
|
||||||
|
# (``_sock.settimeout(300)`` / the file transport's 300s poll deadline).
|
||||||
|
_RPC_REPLY_TIMEOUT_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_rpc_reply_deadline(call_start: float, stop_event=None):
|
||||||
|
"""Tell the approval gates when this call's requester stops waiting.
|
||||||
|
|
||||||
|
The requester is gone at the reply timeout, or earlier when the script
|
||||||
|
itself ended (run timeout, interrupt): ``stop_event`` is set then.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from tools.approval import set_approval_reply_deadline
|
||||||
|
gone = stop_event.is_set if stop_event is not None else None
|
||||||
|
return (True, set_approval_reply_deadline(
|
||||||
|
call_start + _RPC_REPLY_TIMEOUT_SECONDS, gone))
|
||||||
|
except Exception:
|
||||||
|
return (False, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_rpc_reply_deadline(previous) -> None:
|
||||||
|
if not previous or not previous[0]:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from tools.approval import restore_approval_reply_deadline
|
||||||
|
restore_approval_reply_deadline(previous[1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Terminal parameters that must not be used from ephemeral sandbox scripts
|
# Terminal parameters that must not be used from ephemeral sandbox scripts
|
||||||
_TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
|
_TERMINAL_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
|
||||||
|
|
||||||
@@ -826,12 +856,18 @@ def _rpc_server_loop(
|
|||||||
# Dispatch through the standard tool handler.
|
# Dispatch through the standard tool handler.
|
||||||
# Suppress stdout/stderr from internal tool handlers so
|
# Suppress stdout/stderr from internal tool handlers so
|
||||||
# their status prints don't leak into the CLI spinner.
|
# their status prints don't leak into the CLI spinner.
|
||||||
|
# The sandbox client stops waiting for this reply after
|
||||||
|
# _RPC_REPLY_TIMEOUT_SECONDS; an approval granted later
|
||||||
|
# must not apply a write the script already saw fail.
|
||||||
|
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||||
try:
|
try:
|
||||||
with thread_scoped_silence():
|
with thread_scoped_silence():
|
||||||
result = dispatch(tool_name, tool_args)
|
result = dispatch(tool_name, tool_args)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
|
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
|
||||||
result = tool_error(str(exc))
|
result = tool_error(str(exc))
|
||||||
|
finally:
|
||||||
|
_restore_rpc_reply_deadline(_prev_deadline)
|
||||||
|
|
||||||
tool_call_counter[0] += 1
|
tool_call_counter[0] += 1
|
||||||
call_duration = time.monotonic() - call_start
|
call_duration = time.monotonic() - call_start
|
||||||
@@ -1101,6 +1137,7 @@ def _rpc_poll_loop(
|
|||||||
tool_args.pop(param, None)
|
tool_args.pop(param, None)
|
||||||
|
|
||||||
# Dispatch through the standard tool handler
|
# Dispatch through the standard tool handler
|
||||||
|
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||||
try:
|
try:
|
||||||
with thread_scoped_silence():
|
with thread_scoped_silence():
|
||||||
tool_result = handle_function_call(
|
tool_result = handle_function_call(
|
||||||
@@ -1110,6 +1147,8 @@ def _rpc_poll_loop(
|
|||||||
logger.error("Tool call failed in remote sandbox: %s",
|
logger.error("Tool call failed in remote sandbox: %s",
|
||||||
exc, exc_info=True)
|
exc, exc_info=True)
|
||||||
tool_result = tool_error(str(exc))
|
tool_result = tool_error(str(exc))
|
||||||
|
finally:
|
||||||
|
_restore_rpc_reply_deadline(_prev_deadline)
|
||||||
|
|
||||||
tool_call_counter[0] += 1
|
tool_call_counter[0] += 1
|
||||||
call_duration = time.monotonic() - call_start
|
call_duration = time.monotonic() - call_start
|
||||||
|
|||||||
@@ -322,9 +322,25 @@ class CellAuthority:
|
|||||||
set_sudo(self._sudo_cb)
|
set_sudo(self._sudo_cb)
|
||||||
except Exception:
|
except Exception:
|
||||||
previous = None
|
previous = None
|
||||||
|
# A cell that settled (timeout, interrupt, error) no longer waits for
|
||||||
|
# this call: an approval granted afterwards must not be applied.
|
||||||
|
reply_window = None
|
||||||
|
try:
|
||||||
|
from tools.approval import also_gone_when
|
||||||
|
|
||||||
|
reply_window = (also_gone_when(lambda: not self.active),)
|
||||||
|
except Exception:
|
||||||
|
reply_window = None
|
||||||
try:
|
try:
|
||||||
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
|
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
|
||||||
finally:
|
finally:
|
||||||
|
if reply_window is not None:
|
||||||
|
try:
|
||||||
|
from tools.approval import restore_approval_reply_deadline
|
||||||
|
|
||||||
|
restore_approval_reply_deadline(reply_window[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if previous is not None and self._callback_setters is not None:
|
if previous is not None and self._callback_setters is not None:
|
||||||
set_approval, set_sudo = self._callback_setters
|
set_approval, set_sudo = self._callback_setters
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -613,6 +613,22 @@ def _request_approval(action: str, args: Dict[str, Any],
|
|||||||
# No CLI approval wired — default allow. Gateway approval is handled
|
# No CLI approval wired — default allow. Gateway approval is handled
|
||||||
# one layer out via the normal tool-approval infra.
|
# one layer out via the normal tool-approval infra.
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
|
from tools.approval import _is_single_query_approval_context
|
||||||
|
unattended = _is_single_query_approval_context()
|
||||||
|
except Exception:
|
||||||
|
unattended = False
|
||||||
|
if unattended:
|
||||||
|
# One-shot run: the prompt callback is installed but its panel is
|
||||||
|
# never shown, so asking would only wait out approvals.timeout.
|
||||||
|
return json.dumps({
|
||||||
|
"error": (
|
||||||
|
"approval required but this is a one-shot run with no "
|
||||||
|
"interactive user to approve it. Silence is not consent; "
|
||||||
|
"do not retry without the user."
|
||||||
|
),
|
||||||
|
"action": action,
|
||||||
|
})
|
||||||
summary = _summarize_action(action, args)
|
summary = _summarize_action(action, args)
|
||||||
try:
|
try:
|
||||||
verdict = cb(action, args, summary)
|
verdict = cb(action, args, summary)
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
|
|||||||
return "once"
|
return "once"
|
||||||
|
|
||||||
|
|
||||||
|
# Not a person: gates that require real human consent every time (protected
|
||||||
|
# instruction files) must not accept this callback's answer.
|
||||||
|
_subagent_auto_approve.unattended = True
|
||||||
|
|
||||||
|
|
||||||
def _get_subagent_approval_callback():
|
def _get_subagent_approval_callback():
|
||||||
"""Return the callback to install into subagent worker threads.
|
"""Return the callback to install into subagent worker threads.
|
||||||
|
|
||||||
|
|||||||
@@ -1546,6 +1546,10 @@ def _make_run_env(env: dict) -> dict:
|
|||||||
run_env[real_key] = v
|
run_env[real_key] = v
|
||||||
elif _is_hermes_internal_secret(k):
|
elif _is_hermes_internal_secret(k):
|
||||||
continue
|
continue
|
||||||
|
elif k == "HERMES_SINGLE_QUERY_SESSION":
|
||||||
|
# Describes this process only. A child (an interactive hermes
|
||||||
|
# started from a one-shot run's terminal) sets its own.
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
first_party = _is_terminal_first_party_env(k)
|
first_party = _is_terminal_first_party_env(k)
|
||||||
passthrough = _is_passthrough(k)
|
passthrough = _is_passthrough(k)
|
||||||
|
|||||||
+79
-14
@@ -861,12 +861,18 @@ def _request_protected_instruction_approval(
|
|||||||
"required (not bypassed by auto-approve)."
|
"required (not bypassed by auto-approve)."
|
||||||
)
|
)
|
||||||
display = f"<write to {targets}>"
|
display = f"<write to {targets}>"
|
||||||
blocked = (
|
class _Blocked:
|
||||||
f"BLOCKED: write to protected agent-instruction file(s) ({targets}) "
|
# Not str.format: a target path may itself contain braces.
|
||||||
"{why} The user has NOT consented to this write. Do NOT retry it or "
|
@staticmethod
|
||||||
"attempt the same edit via another path (terminal, execute_code, "
|
def format(why: str) -> str:
|
||||||
"etc.)."
|
return (
|
||||||
)
|
"BLOCKED: write to protected agent-instruction file(s) "
|
||||||
|
f"({targets}) {why} The user has NOT consented to this "
|
||||||
|
"write. Do NOT retry it or attempt the same edit via "
|
||||||
|
"another path (terminal, execute_code, etc.)."
|
||||||
|
)
|
||||||
|
|
||||||
|
blocked = _Blocked
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import tools.approval as _approval
|
import tools.approval as _approval
|
||||||
@@ -874,6 +880,41 @@ def _request_protected_instruction_approval(
|
|||||||
return blocked.format(why="requires approval but the approval "
|
return blocked.format(why="requires approval but the approval "
|
||||||
"subsystem is unavailable.")
|
"subsystem is unavailable.")
|
||||||
|
|
||||||
|
def _needs_human(reason: str, why: str) -> str:
|
||||||
|
# No human could be shown this request. Record it so a one-shot
|
||||||
|
# run can hand its coordinator a machine-readable outcome, and tell
|
||||||
|
# the agent where a human CAN approve it instead of waiting on a
|
||||||
|
# prompt nobody sees.
|
||||||
|
try:
|
||||||
|
_approval.note_human_approval_required(
|
||||||
|
"protected_instruction_file", list(reasons), reason)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return blocked.format(
|
||||||
|
why=why + " APPROVAL REQUIRED: a human must approve this from "
|
||||||
|
"an interactive session (resume this same session in "
|
||||||
|
"the desktop app or an interactive terminal and "
|
||||||
|
"request the write again there).")
|
||||||
|
|
||||||
|
def _too_late() -> str | None:
|
||||||
|
# The approval came back after whoever issued this call stopped
|
||||||
|
# waiting (execute_code RPC reply timeout). The caller already saw
|
||||||
|
# a failure, so applying the write now would be an edit the agent
|
||||||
|
# believes never happened.
|
||||||
|
try:
|
||||||
|
late = _approval.approval_arrived_too_late()
|
||||||
|
except Exception:
|
||||||
|
late = False
|
||||||
|
if not late:
|
||||||
|
return None
|
||||||
|
logger.warning(
|
||||||
|
"Approval for protected instruction file(s) %s arrived after "
|
||||||
|
"the caller stopped waiting; write not applied.", targets)
|
||||||
|
return blocked.format(
|
||||||
|
why="was approved only after the calling script had stopped "
|
||||||
|
"waiting for the result, so the write was NOT applied. "
|
||||||
|
"The approval is spent; a new request needs a new approval.")
|
||||||
|
|
||||||
# Gateway surface: block on the button round-trip when a notify callback
|
# Gateway surface: block on the button round-trip when a notify callback
|
||||||
# is registered for this session (Telegram/Discord/Slack). One-operation
|
# is registered for this session (Telegram/Discord/Slack). One-operation
|
||||||
# only — no session/permanent buttons are offered.
|
# only — no session/permanent buttons are offered.
|
||||||
@@ -898,14 +939,15 @@ def _request_protected_instruction_approval(
|
|||||||
session_key, notify_cb, approval_data, surface="gateway",
|
session_key, notify_cb, approval_data, surface="gateway",
|
||||||
)
|
)
|
||||||
if decision.get("notify_failed"):
|
if decision.get("notify_failed"):
|
||||||
return blocked.format(
|
return _needs_human(
|
||||||
why="requires approval but the approval request could not "
|
"approval_not_delivered",
|
||||||
"be delivered.")
|
"requires approval but the approval request could not "
|
||||||
|
"be delivered.")
|
||||||
choice = decision.get("choice")
|
choice = decision.get("choice")
|
||||||
if decision.get("resolved") and choice in {"once", "session", "always"}:
|
if decision.get("resolved") and choice in {"once", "session", "always"}:
|
||||||
# One-operation grant regardless of the tapped scope — nothing
|
# One-operation grant regardless of the tapped scope — nothing
|
||||||
# is persisted for this gate.
|
# is persisted for this gate.
|
||||||
return None
|
return _too_late()
|
||||||
if not decision.get("resolved"):
|
if not decision.get("resolved"):
|
||||||
return blocked.format(
|
return blocked.format(
|
||||||
why="approval prompt timed out without a user response. "
|
why="approval prompt timed out without a user response. "
|
||||||
@@ -921,6 +963,28 @@ def _request_protected_instruction_approval(
|
|||||||
callback = None
|
callback = None
|
||||||
|
|
||||||
if callback is not None:
|
if callback is not None:
|
||||||
|
# A one-shot run (``chat -q`` / ``--query-file``) installs the
|
||||||
|
# prompt callback but never starts the prompt UI: the panel would
|
||||||
|
# be painted nowhere and the wait would last the whole
|
||||||
|
# approvals.timeout with nobody able to answer. A registered
|
||||||
|
# callback is not a reachable human. Fail closed NOW;
|
||||||
|
# approvals.single_query_mode does not apply to this gate.
|
||||||
|
try:
|
||||||
|
unattended = _approval._is_single_query_approval_context()
|
||||||
|
except Exception:
|
||||||
|
unattended = False
|
||||||
|
if unattended:
|
||||||
|
return _needs_human(
|
||||||
|
"no_interactive_user",
|
||||||
|
"requires approval but this is a one-shot run with no "
|
||||||
|
"interactive user to approve it.")
|
||||||
|
if getattr(callback, "unattended", False):
|
||||||
|
# An auto-answering callback (subagent auto-approve) is not a
|
||||||
|
# person; this gate takes consent from a human only.
|
||||||
|
return _needs_human(
|
||||||
|
"no_interactive_user",
|
||||||
|
"requires approval but only an automatic approver is "
|
||||||
|
"attached to this run, not a person.")
|
||||||
choice = _approval.prompt_dangerous_approval(
|
choice = _approval.prompt_dangerous_approval(
|
||||||
display, description,
|
display, description,
|
||||||
allow_permanent=False,
|
allow_permanent=False,
|
||||||
@@ -929,7 +993,7 @@ def _request_protected_instruction_approval(
|
|||||||
)
|
)
|
||||||
if choice in {"once", "session", "always"}:
|
if choice in {"once", "session", "always"}:
|
||||||
# One-operation grant; never persisted (see docstring).
|
# One-operation grant; never persisted (see docstring).
|
||||||
return None
|
return _too_late()
|
||||||
if choice == "timeout":
|
if choice == "timeout":
|
||||||
return blocked.format(
|
return blocked.format(
|
||||||
why="approval prompt timed out without a user response. "
|
why="approval prompt timed out without a user response. "
|
||||||
@@ -938,9 +1002,10 @@ def _request_protected_instruction_approval(
|
|||||||
|
|
||||||
# No human channel at all (script, cron, background thread): fail
|
# No human channel at all (script, cron, background thread): fail
|
||||||
# closed. Auto-approving here would recreate the persistence vector.
|
# closed. Auto-approving here would recreate the persistence vector.
|
||||||
return blocked.format(
|
return _needs_human(
|
||||||
why="requires approval but no interactive user or gateway is "
|
"no_approval_channel",
|
||||||
"present to approve it.")
|
"requires approval but no interactive user or gateway is "
|
||||||
|
"present to approve it.")
|
||||||
|
|
||||||
|
|
||||||
def _check_protected_instruction_write(paths: list[str],
|
def _check_protected_instruction_write(paths: list[str],
|
||||||
|
|||||||
@@ -327,6 +327,14 @@ def _interactive_approval_available() -> bool:
|
|||||||
review affordance (``/memory pending``) instead.
|
review affordance (``/memory pending``) instead.
|
||||||
* Scripts, cron, and background threads — no user present.
|
* Scripts, cron, and background threads — no user present.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
from tools.approval import _is_single_query_approval_context
|
||||||
|
if _is_single_query_approval_context():
|
||||||
|
# One-shot run: the callback exists but its panel is never
|
||||||
|
# shown, so an inline prompt would wait with nobody to answer.
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
from tools.terminal_tool import _get_approval_callback
|
from tools.terminal_tool import _get_approval_callback
|
||||||
return _get_approval_callback() is not None
|
return _get_approval_callback() is not None
|
||||||
|
|||||||
Reference in new issue
Block a user