While a tool-permission gate is held on the active tab, spoken confirm phrases resolve it via the existing approve/reject WS channel — no server change (byte-shuttle intact). Everything else stays ordinary dictation. Safety: whole-utterance-exact matching + leading-negation guard + reject precedence + confidence gate (approve only) + a cancellable 1.5s confirm window whose commit re-validates gate identity/epoch/connectivity (TOCTOU guard) + stale-gate epoch bound at PTT-start. New pure modules public/voice-commands.ts and public/voice-confirm.ts at 100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass, typecheck clean, build:web OK. Independent code + security reviews flagged a confirm-window TOCTOU and an unwired cancel path — both fixed and covered. Plan: docs/PLAN_VOICE_COMMANDS.md.
66 lines
2.3 KiB
TypeScript
66 lines
2.3 KiB
TypeScript
/**
|
|
* public/voice-confirm.ts — Approve confirm window controller (VC / V1b, decision B).
|
|
*
|
|
* PLAN_VOICE_COMMANDS.md §4: a spoken "approve" is irreversible (grants shell
|
|
* command execution via the held permission gate), so instead of committing
|
|
* immediately it arms a short cancellable window. `arm()` fires `onHeard`
|
|
* synchronously (so the caller can show a "听到:批准" style affordance) and
|
|
* starts a `windowMs` timer; if `cancel()` is not called first, `onCommit`
|
|
* fires when the timer elapses.
|
|
*
|
|
* Pure timing logic — NO DOM here. The caller (tabs.ts, lane V2) owns the
|
|
* overlay UI and calls `session.approve()` inside `onCommit`. Kept dependency-
|
|
* free (setTimeout/clearTimeout only) so tests can use vi.useFakeTimers
|
|
* without any DOM/WS coupling.
|
|
*/
|
|
|
|
/** Default confirm-window duration in milliseconds (PLAN_VOICE_COMMANDS §4). */
|
|
const DEFAULT_WINDOW_MS = 1500
|
|
|
|
/** A cancellable approve confirm window. Obtain via createApproveConfirm(). */
|
|
export interface ApproveConfirm {
|
|
/**
|
|
* Arm the confirm window: calls `onHeard` immediately, then starts a
|
|
* `windowMs` timer that calls `onCommit` unless `cancel()` runs first.
|
|
* Re-arming (calling `arm` again while already armed) clears the prior
|
|
* timer, so only the latest arm's `onCommit` can ever fire.
|
|
*/
|
|
arm(onHeard: () => void, onCommit: () => void): void
|
|
/** Cancel the pending window, if any. No-op (does not throw) if not armed. */
|
|
cancel(): void
|
|
/** True while a confirm window is pending (armed and not yet committed/cancelled). */
|
|
isArmed(): boolean
|
|
}
|
|
|
|
/**
|
|
* Create an approve confirm window controller.
|
|
*
|
|
* @param windowMs Duration of the cancellable window in ms. Default: 1500 (B).
|
|
*/
|
|
export function createApproveConfirm(windowMs: number = DEFAULT_WINDOW_MS): ApproveConfirm {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
return {
|
|
arm(onHeard: () => void, onCommit: () => void): void {
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer)
|
|
}
|
|
onHeard()
|
|
timer = setTimeout(() => {
|
|
timer = undefined
|
|
onCommit()
|
|
}, windowMs)
|
|
},
|
|
|
|
cancel(): void {
|
|
if (timer === undefined) return
|
|
clearTimeout(timer)
|
|
timer = undefined
|
|
},
|
|
|
|
isArmed(): boolean {
|
|
return timer !== undefined
|
|
},
|
|
}
|
|
}
|