Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4cc0915c | ||
|
|
58dbb4523b |
No files matched your search
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AITURK_CLI_KEY_HOLD_MS,
|
||||
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||
type AiturkCliExit,
|
||||
AiturkLoginError,
|
||||
createAiturkCliRunner,
|
||||
@@ -38,6 +39,8 @@ const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'a
|
||||
function harness(
|
||||
options: {
|
||||
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)[]
|
||||
cliExits?: AiturkCliExit[]
|
||||
validate?: unknown
|
||||
@@ -47,6 +50,7 @@ function harness(
|
||||
) {
|
||||
const calls: { url: string; init: any }[] = []
|
||||
const polls = [...(options.polls ?? [])]
|
||||
const startErrors = [...(options.startErrors ?? [])]
|
||||
const cliExits = [...(options.cliExits ?? [0])]
|
||||
const clock = { now: 1_000_000 }
|
||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||
@@ -55,6 +59,12 @@ function harness(
|
||||
calls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/start')) {
|
||||
const failure = startErrors.shift()
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
|
||||
const r = options.start ?? { status: 201, body: startBody() }
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
await expect(
|
||||
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_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_KEY_HOLD_MS = 5 * 60_000
|
||||
|
||||
@@ -237,16 +239,29 @@ export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||
await cancelOnServer(previous.deviceCode)
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
let response: AiturkFetchResponse | undefined
|
||||
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||
client: 'ide',
|
||||
device_name: deviceName(deps.hostname()),
|
||||
also_cli: true
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
// A long-idle process can fail its first request on a dead pooled
|
||||
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||
// one start cannot knowingly leave two codes behind. A timeout already
|
||||
// used its whole budget and is not retried.
|
||||
for (let attempt = 1; response === undefined; attempt++) {
|
||||
try {
|
||||
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) {
|
||||
|
||||
@@ -17893,7 +17893,7 @@ function getMobileLink(): MobileLink {
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/^relay /.test(line) || / \/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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const TEXT = {
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
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',
|
||||
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.',
|
||||
@@ -56,7 +56,7 @@ const TEXT = {
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
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',
|
||||
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.',
|
||||
@@ -84,7 +84,7 @@ 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'],
|
||||
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||
@@ -121,6 +121,8 @@ function render(state) {
|
||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||
$('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]
|
||||
// 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()
|
||||
@@ -155,6 +157,7 @@ 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')) }) }
|
||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||
$('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) }) }
|
||||
|
||||
@@ -24,3 +24,4 @@ button.link { background:none; color:var(--accent); min-height:0; padding:0; mar
|
||||
.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 }
|
||||
@@ -20,6 +20,7 @@
|
||||
<p id="t-or-code"></p>
|
||||
<p id="pair-code" class="code" aria-live="off"></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-security" class="security"></p>
|
||||
<p id="t-same-wifi" class="hint"></p>
|
||||
|
||||
@@ -20,6 +20,8 @@ export function createMobileWindow({ assets, link, show = true }) {
|
||||
disable: () => link.disable(),
|
||||
'pair.begin': () => link.pairBegin(),
|
||||
'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(),
|
||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { WebSocket, WebSocketServer } from 'ws'
|
||||
import { createSelfSignedCertificate } from './mobile-lan-cert'
|
||||
import {
|
||||
buildPairQrPayload,
|
||||
cancelPairOffer,
|
||||
createLanProxy,
|
||||
createMobileLink,
|
||||
createWindowsFirewall,
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
type MobileLinkEvent,
|
||||
normalizePairCode,
|
||||
PAIR_CODE_ALPHABET,
|
||||
PAIR_OFFER_MAX_TRIES,
|
||||
PAIR_OFFER_RETRY_DELAYS_MS,
|
||||
pairLookup,
|
||||
parseFirewallRuleOutput,
|
||||
parseMobileLinkStored,
|
||||
@@ -745,10 +748,19 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
const events: MobileLinkEvent[] = []
|
||||
const logs: string[] = []
|
||||
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,
|
||||
@@ -809,13 +821,20 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) }
|
||||
}
|
||||
|
||||
if (state.offerStatus === 'throw') {
|
||||
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')
|
||||
}
|
||||
|
||||
if (url.endsWith('/offers')) {
|
||||
if (isOffer) {
|
||||
return {
|
||||
status: state.offerStatus,
|
||||
status: offerOutcome,
|
||||
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
|
||||
}
|
||||
}
|
||||
@@ -835,6 +854,23 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
platform: 'win32',
|
||||
listenHost: LAN_HOST,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -850,7 +886,7 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
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 }
|
||||
return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries }
|
||||
}
|
||||
|
||||
describe('mobile link controller', () => {
|
||||
@@ -1278,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', () => {
|
||||
it('pairing code: alphabet, normalisation, display form, lookup digest', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
|
||||
@@ -247,9 +247,37 @@ export interface PairFetchResponse {
|
||||
|
||||
export type PairFetch = (url: string, init: Record<string, unknown>) => Promise<PairFetchResponse>
|
||||
|
||||
/**
|
||||
* A published rendezvous record. Both fields are null when the record is known
|
||||
* to exist but its answer never reached us (an earlier try failed on the wire
|
||||
* and the next one got 409): the code path works, there is just nothing to
|
||||
* cancel — the record expires on its own after five minutes.
|
||||
*/
|
||||
export interface PairOffer {
|
||||
offerId: string
|
||||
cancelToken: string
|
||||
offerId: null | string
|
||||
cancelToken: null | string
|
||||
}
|
||||
|
||||
export const PAIR_OFFER_MAX_TRIES = 4
|
||||
/** Waits between the tries: ~0.5 s, 1.5 s, 3 s. */
|
||||
export const PAIR_OFFER_RETRY_DELAYS_MS: readonly number[] = [500, 1500, 3000]
|
||||
|
||||
export interface PairOfferOptions {
|
||||
/** Injected wait (the controller passes its own timer). Default: a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
delaysMs?: readonly number[]
|
||||
maxTries?: number
|
||||
/** true = the pairing this record belongs to is over: stop trying. */
|
||||
cancelled?: () => boolean
|
||||
/** "offer published" / "offer failed after N tries" only — never the code, the lookup or a token. */
|
||||
log?: (line: string) => void
|
||||
/**
|
||||
* An earlier publish for the SAME code may have reached the gateway (its
|
||||
* answer was lost), so a 409 on the very first try also means "it is there".
|
||||
*/
|
||||
earlierTryMayHaveLanded?: boolean
|
||||
/** Called when a try ended without a usable answer (thrown error / 5xx), i.e. it may have been stored. */
|
||||
onMayHaveLanded?: () => void
|
||||
}
|
||||
|
||||
function pairFetchInit(body: unknown, headers: Record<string, string> = {}) {
|
||||
@@ -263,12 +291,22 @@ function pairFetchInit(body: unknown, headers: Record<string, string> = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the rendezvous record. Returns null on any failure — the QR path
|
||||
* keeps working and the UI shows "kodla bağlanma kullanılamıyor".
|
||||
* Publish the rendezvous record. Returns null when it could not be published —
|
||||
* the QR path keeps working and the UI shows "kodla bağlanma kullanılamıyor".
|
||||
*
|
||||
* Up to four tries. A long-idle process can lose its first request on a dead
|
||||
* pooled keep-alive connection, and one lost request must not close the code
|
||||
* path for the whole five minutes:
|
||||
* - a thrown network error, 5xx and 429 are tried again;
|
||||
* - 422 and every other answer are final (the request itself is wrong);
|
||||
* - 409 after a try that failed on the wire / with 5xx means that try DID
|
||||
* land and only its answer was lost: the record exists, the code path is
|
||||
* ready (no cancel token is known for it).
|
||||
*/
|
||||
export async function publishPairOffer(
|
||||
fetcher: PairFetch,
|
||||
input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string }
|
||||
input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string },
|
||||
options: PairOfferOptions = {}
|
||||
): Promise<null | PairOffer> {
|
||||
const relay = isRelayId(input.relay) ? input.relay : null
|
||||
|
||||
@@ -282,34 +320,93 @@ export async function publishPairOffer(
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`,
|
||||
pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) })
|
||||
)
|
||||
const sleep = options.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
|
||||
const delays = options.delaysMs ?? PAIR_OFFER_RETRY_DELAYS_MS
|
||||
const maxTries = Math.max(1, options.maxTries ?? PAIR_OFFER_MAX_TRIES)
|
||||
const cancelled = options.cancelled ?? (() => false)
|
||||
const body = { lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) }
|
||||
let mayHaveLanded = options.earlierTryMayHaveLanded === true
|
||||
let tries = 0
|
||||
|
||||
if (response.status !== 201) {
|
||||
return null
|
||||
}
|
||||
const failed = () => {
|
||||
options.log?.(`offer failed after ${tries} ${tries === 1 ? 'try' : 'tries'}`)
|
||||
|
||||
const data = (await response.json()) as null | Record<string, unknown>
|
||||
|
||||
if (!data || typeof data.offerId !== 'string' || !UUID_RE.test(data.offerId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof data.cancelToken !== 'string' || !CANCEL_TOKEN_RE.test(data.cancelToken)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { offerId: data.offerId, cancelToken: data.cancelToken }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const published = (offer: PairOffer) => {
|
||||
options.log?.('offer published')
|
||||
|
||||
return offer
|
||||
}
|
||||
|
||||
while (tries < maxTries) {
|
||||
if (cancelled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
tries++
|
||||
|
||||
let status: number
|
||||
let data: null | Record<string, unknown> = null
|
||||
|
||||
try {
|
||||
// A fresh init per try: the timeout signal must not be shared between tries.
|
||||
const response = await fetcher(`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, pairFetchInit(body))
|
||||
|
||||
status = response.status
|
||||
|
||||
if (status === 201) {
|
||||
data = (await response.json()) as null | Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// No (complete) answer: the request may or may not have been stored.
|
||||
status = 0
|
||||
}
|
||||
|
||||
if (status === 201) {
|
||||
if (
|
||||
!data ||
|
||||
typeof data.offerId !== 'string' ||
|
||||
!UUID_RE.test(data.offerId) ||
|
||||
typeof data.cancelToken !== 'string' ||
|
||||
!CANCEL_TOKEN_RE.test(data.cancelToken)
|
||||
) {
|
||||
return failed()
|
||||
}
|
||||
|
||||
return published({ offerId: data.offerId, cancelToken: data.cancelToken })
|
||||
}
|
||||
|
||||
if (status === 409 && mayHaveLanded) {
|
||||
return published({ offerId: null, cancelToken: null })
|
||||
}
|
||||
|
||||
const transient = status === 0 || status === 429 || (status >= 500 && status <= 599)
|
||||
|
||||
if (!transient) {
|
||||
return failed()
|
||||
}
|
||||
|
||||
if (status !== 429) {
|
||||
mayHaveLanded = true
|
||||
options.onMayHaveLanded?.()
|
||||
}
|
||||
|
||||
if (tries < maxTries) {
|
||||
await sleep(delays[Math.min(tries - 1, delays.length - 1)] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return cancelled() ? null : failed()
|
||||
}
|
||||
|
||||
/** Best effort; the record expires on its own after five minutes anyway. */
|
||||
/** Best effort; the record expires on its own after five minutes anyway. No token = nothing to cancel. */
|
||||
export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise<void> {
|
||||
if (!offer.cancelToken) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetcher(
|
||||
`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/cancel`,
|
||||
@@ -1380,6 +1477,8 @@ interface PairSession {
|
||||
addresses: string[]
|
||||
port: number
|
||||
fingerprint: string
|
||||
/** A publish got no answer at least once: a later 409 for this code is our own record. */
|
||||
offerMayHaveLanded: boolean
|
||||
}
|
||||
|
||||
export function createMobileLink(deps: MobileLinkDeps) {
|
||||
@@ -1526,6 +1625,42 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||
|
||||
/**
|
||||
* Publishes (or publishes again) the rendezvous record of `current` in the
|
||||
* background; the code path stays `pending` until every try is over. The
|
||||
* code and the QR are never touched here.
|
||||
*/
|
||||
function publishOffer(current: PairSession, relayId: null | string) {
|
||||
current.codePath = 'pending'
|
||||
|
||||
const input = { code: current.code, addresses: current.addresses, port: current.port, fingerprint: current.fingerprint, relay: relayId }
|
||||
|
||||
void publishPairOffer(deps.fetch, input, {
|
||||
sleep,
|
||||
cancelled: () => session !== current,
|
||||
log: line => deps.log?.(`pair ${line}`),
|
||||
earlierTryMayHaveLanded: current.offerMayHaveLanded,
|
||||
onMayHaveLanded: () => {
|
||||
current.offerMayHaveLanded = true
|
||||
}
|
||||
}).then(offer => {
|
||||
if (session !== current) {
|
||||
// Pairing ended meanwhile; do not leave the record behind.
|
||||
if (offer) {
|
||||
void cancelPairOffer(deps.fetch, offer)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
current.offer = offer
|
||||
current.codePath = offer ? 'ready' : 'unavailable'
|
||||
emit()
|
||||
})
|
||||
}
|
||||
|
||||
/** The relay registration changed while a QR is on screen: show the current address. */
|
||||
function refreshSessionQr(data: MobileLinkStored) {
|
||||
if (!session) {
|
||||
@@ -1892,27 +2027,31 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}, Math.max(0, expiresAt - now()) + 50),
|
||||
addresses,
|
||||
port,
|
||||
fingerprint
|
||||
fingerprint,
|
||||
offerMayHaveLanded: false
|
||||
}
|
||||
|
||||
session = current
|
||||
ensureRelay(data)?.start()
|
||||
|
||||
// Not awaited: the QR must not wait for the gateway.
|
||||
void publishPairOffer(deps.fetch, { code, addresses, port, fingerprint, relay: relayUrl }).then(offer => {
|
||||
if (session !== current) {
|
||||
// Pairing ended meanwhile; do not leave the record behind.
|
||||
if (offer) {
|
||||
void cancelPairOffer(deps.fetch, offer)
|
||||
}
|
||||
publishOffer(current, relayUrl)
|
||||
|
||||
return
|
||||
}
|
||||
return snapshot(data)
|
||||
}),
|
||||
|
||||
current.offer = offer
|
||||
current.codePath = offer ? 'ready' : 'unavailable'
|
||||
emit()
|
||||
})
|
||||
/**
|
||||
* "Yeniden dene" next to "Kodla bağlanma şu an kullanılamıyor": publishes
|
||||
* the rendezvous record of the RUNNING pairing again. Same code, same QR,
|
||||
* same expiry; nothing happens unless the code path is `unavailable`.
|
||||
*/
|
||||
pairRepublish: () =>
|
||||
serial(async () => {
|
||||
const data = await load()
|
||||
|
||||
if (session?.codePath === 'unavailable' && proxy?.pairingOpen() && session) {
|
||||
publishOffer(session, data.relayId)
|
||||
}
|
||||
|
||||
return snapshot(data)
|
||||
}),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
createRelayClient,
|
||||
encodeRelayFrame,
|
||||
isRelayId,
|
||||
MOBILE_NETWORK_RETRY_DELAY_MS,
|
||||
parseRelaySession,
|
||||
registerRelaySession,
|
||||
RELAY_BACKOFF_MS,
|
||||
@@ -925,10 +926,15 @@ describe('relay client', () => {
|
||||
})
|
||||
|
||||
it('a failing registration is a fixed code (no upstream text) and is retried; renew() swaps the registration only when the new one is stored', async () => {
|
||||
const h = clientHarness({ sessions: ['throw', 429, 500, 201, 500, 201] })
|
||||
const h = clientHarness({ sessions: ['throw', 'throw', 429, 500, 201, 500, 201] })
|
||||
|
||||
h.client.start()
|
||||
await h.settle()
|
||||
// A thrown network error gets one more try 0.5 s later; until then nothing is reported as failed.
|
||||
expect(h.client.state()).toEqual({ status: 'connecting', code: null })
|
||||
h.fire(500)
|
||||
await h.settle()
|
||||
expect(h.fetches).toHaveLength(2)
|
||||
expect(h.client.state()).toEqual({ status: 'failed', code: 'register_failed' })
|
||||
expect(h.sockets).toHaveLength(0)
|
||||
h.fire(2000)
|
||||
@@ -982,6 +988,60 @@ describe('relay client', () => {
|
||||
broken.stop()
|
||||
})
|
||||
|
||||
it('registration: a thrown network error is tried once more after 0.5 s; an HTTP answer or a timeout is not', async () => {
|
||||
const h = clientHarness({ sessions: ['throw', 201] })
|
||||
const pending = h.client.ensureRegistered()
|
||||
|
||||
await h.settle()
|
||||
expect(h.fetches).toHaveLength(1)
|
||||
expect(h.timers.map(timer => timer.ms)).toEqual([MOBILE_NETWORK_RETRY_DELAY_MS])
|
||||
expect(MOBILE_NETWORK_RETRY_DELAY_MS).toBe(500)
|
||||
h.fire(500)
|
||||
expect(await pending).toBe(RELAY_IDS[0])
|
||||
expect(h.fetches).toHaveLength(2)
|
||||
expect(h.fetches[1].init.signal).not.toBe(h.fetches[0].init.signal)
|
||||
expect(h.saved).toEqual([{ credentials: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, reissued: false }])
|
||||
|
||||
// The function itself: exactly one extra try, only for a thrown error.
|
||||
const sleeps: number[] = []
|
||||
|
||||
const run = async (outcomes: (number | 'throw' | 'timeout')[]) => {
|
||||
let calls = 0
|
||||
|
||||
const result = await registerRelaySession(
|
||||
async () => {
|
||||
const outcome = outcomes[calls++] ?? 201
|
||||
|
||||
if (outcome === 'throw') {
|
||||
throw new TypeError('fetch failed')
|
||||
}
|
||||
|
||||
if (outcome === 'timeout') {
|
||||
throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
|
||||
}
|
||||
|
||||
return { status: outcome, json: async () => ({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) }
|
||||
},
|
||||
{
|
||||
sleep: async ms => {
|
||||
sleeps.push(ms)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { result, calls }
|
||||
}
|
||||
|
||||
expect(await run(['throw', 201])).toEqual({ result: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, calls: 2 })
|
||||
expect(await run(['throw', 'throw', 201])).toEqual({ result: { error: 'register_failed' }, calls: 2 })
|
||||
expect(await run(['throw', 429])).toEqual({ result: { error: 'rate_limited' }, calls: 2 })
|
||||
expect(sleeps).toEqual([500, 500, 500])
|
||||
expect(await run([500, 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 })
|
||||
expect(await run([429, 201])).toEqual({ result: { error: 'rate_limited' }, calls: 1 })
|
||||
expect(await run(['timeout', 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 })
|
||||
expect(sleeps).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('streams: opened / close, bytes copied untouched in ≤ 64 KiB frames, unknown ids ignored, stream and buffer limits', async () => {
|
||||
const h = clientHarness({ stored: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } })
|
||||
|
||||
|
||||
@@ -92,24 +92,67 @@ export type RelayFailureCode =
|
||||
| 'busy'
|
||||
| 'timeout'
|
||||
|
||||
/** `POST /sessions`; a fixed failure code instead of any upstream text. */
|
||||
export async function registerRelaySession(fetcher: RelayFetch): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
try {
|
||||
const 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)
|
||||
})
|
||||
/** Wait before the single second try of a request whose first try never got an answer. */
|
||||
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
|
||||
|
||||
if (response.status !== 201) {
|
||||
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||
/** 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
|
||||
}
|
||||
|
||||
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||
} catch {
|
||||
return { error: 'register_failed' }
|
||||
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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +264,8 @@ export function createRelayClient(deps: RelayClientDeps) {
|
||||
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) {
|
||||
@@ -270,7 +315,7 @@ export function createRelayClient(deps: RelayClientDeps) {
|
||||
return stored
|
||||
}
|
||||
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return fresh
|
||||
@@ -626,7 +671,7 @@ export function createRelayClient(deps: RelayClientDeps) {
|
||||
* only once the new one is stored; false = nothing changed.
|
||||
*/
|
||||
async renew(): Promise<boolean> {
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.16",
|
||||
"version": "1.0.0-beta.18",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"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:
|
||||
"""Close one-shot CLI resources before releasing the active session lease."""
|
||||
try:
|
||||
@@ -22360,6 +22388,7 @@ def main(
|
||||
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
_emit_approval_required(cli.session_id)
|
||||
|
||||
# Ensure proper exit code for automation wrappers.
|
||||
#
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.16",
|
||||
"version": "1.0.0-beta.18",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
@@ -272,6 +272,7 @@ _HERMES_BEHAVIORAL_VARS = frozenset({
|
||||
"HERMES_REAL_HOME",
|
||||
"TERMINAL_HOME_MODE",
|
||||
"HERMES_INTERACTIVE",
|
||||
"HERMES_SINGLE_QUERY_SESSION",
|
||||
"HERMES_QUIET",
|
||||
"HERMES_TOOL_PROGRESS",
|
||||
"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:
|
||||
"""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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 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_BLOCKED_PARAMS = {"background", "pty", "notify", "notify_on_complete", "watch_patterns"}
|
||||
|
||||
@@ -826,12 +856,18 @@ def _rpc_server_loop(
|
||||
# Dispatch through the standard tool handler.
|
||||
# Suppress stdout/stderr from internal tool handlers so
|
||||
# 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:
|
||||
with thread_scoped_silence():
|
||||
result = dispatch(tool_name, tool_args)
|
||||
except Exception as exc:
|
||||
logger.error("Tool call failed in sandbox: %s", exc, exc_info=True)
|
||||
result = tool_error(str(exc))
|
||||
finally:
|
||||
_restore_rpc_reply_deadline(_prev_deadline)
|
||||
|
||||
tool_call_counter[0] += 1
|
||||
call_duration = time.monotonic() - call_start
|
||||
@@ -1101,6 +1137,7 @@ def _rpc_poll_loop(
|
||||
tool_args.pop(param, None)
|
||||
|
||||
# Dispatch through the standard tool handler
|
||||
_prev_deadline = _bind_rpc_reply_deadline(call_start, stop_event)
|
||||
try:
|
||||
with thread_scoped_silence():
|
||||
tool_result = handle_function_call(
|
||||
@@ -1110,6 +1147,8 @@ def _rpc_poll_loop(
|
||||
logger.error("Tool call failed in remote sandbox: %s",
|
||||
exc, exc_info=True)
|
||||
tool_result = tool_error(str(exc))
|
||||
finally:
|
||||
_restore_rpc_reply_deadline(_prev_deadline)
|
||||
|
||||
tool_call_counter[0] += 1
|
||||
call_duration = time.monotonic() - call_start
|
||||
|
||||
@@ -322,9 +322,25 @@ class CellAuthority:
|
||||
set_sudo(self._sudo_cb)
|
||||
except Exception:
|
||||
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:
|
||||
return handle_function_call(tool_name, tool_args, task_id=self.task_id)
|
||||
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:
|
||||
set_approval, set_sudo = self._callback_setters
|
||||
try:
|
||||
|
||||
@@ -613,6 +613,22 @@ def _request_approval(action: str, args: Dict[str, Any],
|
||||
# No CLI approval wired — default allow. Gateway approval is handled
|
||||
# one layer out via the normal tool-approval infra.
|
||||
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)
|
||||
try:
|
||||
verdict = cb(action, args, summary)
|
||||
|
||||
@@ -102,6 +102,11 @@ def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
|
||||
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():
|
||||
"""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
|
||||
elif _is_hermes_internal_secret(k):
|
||||
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:
|
||||
first_party = _is_terminal_first_party_env(k)
|
||||
passthrough = _is_passthrough(k)
|
||||
|
||||
+79
-14
@@ -861,12 +861,18 @@ def _request_protected_instruction_approval(
|
||||
"required (not bypassed by auto-approve)."
|
||||
)
|
||||
display = f"<write to {targets}>"
|
||||
blocked = (
|
||||
f"BLOCKED: write to protected agent-instruction file(s) ({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.)."
|
||||
)
|
||||
class _Blocked:
|
||||
# Not str.format: a target path may itself contain braces.
|
||||
@staticmethod
|
||||
def format(why: str) -> str:
|
||||
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:
|
||||
import tools.approval as _approval
|
||||
@@ -874,6 +880,41 @@ def _request_protected_instruction_approval(
|
||||
return blocked.format(why="requires approval but the approval "
|
||||
"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
|
||||
# is registered for this session (Telegram/Discord/Slack). One-operation
|
||||
# only — no session/permanent buttons are offered.
|
||||
@@ -898,14 +939,15 @@ def _request_protected_instruction_approval(
|
||||
session_key, notify_cb, approval_data, surface="gateway",
|
||||
)
|
||||
if decision.get("notify_failed"):
|
||||
return blocked.format(
|
||||
why="requires approval but the approval request could not "
|
||||
"be delivered.")
|
||||
return _needs_human(
|
||||
"approval_not_delivered",
|
||||
"requires approval but the approval request could not "
|
||||
"be delivered.")
|
||||
choice = decision.get("choice")
|
||||
if decision.get("resolved") and choice in {"once", "session", "always"}:
|
||||
# One-operation grant regardless of the tapped scope — nothing
|
||||
# is persisted for this gate.
|
||||
return None
|
||||
return _too_late()
|
||||
if not decision.get("resolved"):
|
||||
return blocked.format(
|
||||
why="approval prompt timed out without a user response. "
|
||||
@@ -921,6 +963,28 @@ def _request_protected_instruction_approval(
|
||||
callback = 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(
|
||||
display, description,
|
||||
allow_permanent=False,
|
||||
@@ -929,7 +993,7 @@ def _request_protected_instruction_approval(
|
||||
)
|
||||
if choice in {"once", "session", "always"}:
|
||||
# One-operation grant; never persisted (see docstring).
|
||||
return None
|
||||
return _too_late()
|
||||
if choice == "timeout":
|
||||
return blocked.format(
|
||||
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
|
||||
# closed. Auto-approving here would recreate the persistence vector.
|
||||
return blocked.format(
|
||||
why="requires approval but no interactive user or gateway is "
|
||||
"present to approve it.")
|
||||
return _needs_human(
|
||||
"no_approval_channel",
|
||||
"requires approval but no interactive user or gateway is "
|
||||
"present to approve it.")
|
||||
|
||||
|
||||
def _check_protected_instruction_write(paths: list[str],
|
||||
|
||||
@@ -327,6 +327,14 @@ def _interactive_approval_available() -> bool:
|
||||
review affordance (``/memory pending``) instead.
|
||||
* 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:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
return _get_approval_callback() is not None
|
||||
|
||||
Reference in new issue
Block a user