feat: voice command mapping — context-gated approve/reject over voice

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.
This commit is contained in:
Yaojia Wang
2026-07-01 10:34:51 +02:00
parent 03612323c0
commit e4c327e25e
13 changed files with 1193 additions and 19 deletions

View File

@@ -32,6 +32,8 @@ import { mountPushToggle } from './push.js'
import { mountQuickReply } from './quick-reply.js'
import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
@@ -42,6 +44,9 @@ const PERMISSION_MODE_KEY = 'web-terminal:permission-mode'
* (class `tg-stale`) once telemetry is older than this (B2). */
const STATUSLINE_TTL_MS = 30_000
/** VC decision C: how long the "未连接" not-connected flash stays visible. */
const VOICE_NOT_CONNECTED_FLASH_MS = 1500
/** All `--permission-mode` values (B4). 'auto' is high-risk and only offered
* when the server's ALLOW_AUTO_MODE allows it (SEC-M5, gated via /config/ui). */
const ALL_PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
@@ -102,6 +107,11 @@ export class TabApp {
private timelineBtn: HTMLButtonElement | null = null
private voice: VoiceInput | null = null // A2: push-to-talk recognizer
private voiceOverlay: HTMLElement | null = null // A2: interim-transcript overlay
// VC: context-gated confirm/reject command mapping (PLAN_VOICE_COMMANDS.md).
private readonly approveConfirm: ApproveConfirm = createApproveConfirm() // decision B
private voiceConfirmOverlay: HTMLElement | null = null // "听到:批准" / "未连接" (B, C)
private voicePttSession: TerminalSession | null = null // session captured at PTT-start (safeguard 5)
private voicePttEpoch = 0 // session.pendingEpoch captured at PTT-start (safeguard 5)
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
this.paneHost = paneHost
@@ -173,13 +183,23 @@ export class TabApp {
document.body.appendChild(this.timelinePanel)
}
/** A2: overlay that shows the live interim transcript while dictating. */
/** A2: overlay that shows the live interim transcript while dictating. VC: a
* SEPARATE overlay carries "听到:批准" / "未连接" so hideInterim() (which
* fires on PTT release) never clobbers it mid-confirm-window (§6). */
private setupVoiceOverlay(): void {
const overlay = document.createElement('div')
overlay.id = 'voice-interim'
overlay.style.display = 'none'
document.body.appendChild(overlay)
this.voiceOverlay = overlay
const confirmOverlay = document.createElement('div')
confirmOverlay.id = 'voice-confirm'
confirmOverlay.style.display = 'none'
// Tap-to-cancel an armed approve confirm window (§4 cancel trigger).
confirmOverlay.addEventListener('click', () => this.cancelArmedApprove())
document.body.appendChild(confirmOverlay)
this.voiceConfirmOverlay = confirmOverlay
}
/** B4: fetch the server UI config (allowAutoMode). Best-effort, never throws. */
@@ -351,11 +371,17 @@ export class TabApp {
private startVoice(): void {
if (!this.voice) {
this.voice = createVoiceInput((text) => this.sendToActive(text), {
onInterim: (t) => this.showInterim(t),
})
this.voice = createVoiceInput(
(text, confidence) => this.handleVoiceTranscript(text, confidence),
{ onInterim: (t) => this.showInterim(t) },
)
}
if (!this.voice) return // SpeechRecognition unsupported — no-op (AC-A2.3)
// VC safeguard 5: bind this utterance to the CURRENTLY active session/epoch
// so a slow transcript can't approve a newer gate or a different tab.
const active = this.tabs[this.activeIndex]?.session ?? null
this.voicePttSession = active
this.voicePttEpoch = active?.pendingEpoch ?? 0
this.showInterim('')
this.voice.start()
}
@@ -375,6 +401,102 @@ export class TabApp {
if (this.voiceOverlay) this.voiceOverlay.style.display = 'none'
}
/**
* VC — dispatch a final voice transcript (PLAN_VOICE_COMMANDS.md §6):
* context-gated confirm/reject resolve the held permission via the existing
* approve()/reject() WS channel; everything else (including a stale gate or
* a tab switch mid-utterance) falls through to ordinary dictation, byte-for-
* byte unchanged. Raw bytes are NEVER synthesized for approve/reject.
*/
private handleVoiceTranscript(text: string, confidence?: number): void {
// A NEW utterance supersedes any pending approve confirm window: the user
// has spoken again, so never let a stale timer commit behind a fresh
// command (this is the "say another word" cancel trigger, §4). Combined
// with tap-to-cancel and the commit-time re-check below, it closes the
// TOCTOU race where a delayed approve could resolve a different gate.
this.cancelArmedApprove()
const session = this.tabs[this.activeIndex]?.session
if (!session) {
this.sendToActive(text)
return
}
const ctx: VoiceMatchContext = {
pendingApproval: session.pendingApproval,
gate: session.pendingGate,
confidence,
}
const action = matchCommand(text, ctx)
// Stale-gate guard: a command can only resolve the SAME session+gate it
// was spoken against; anything else (tab switch, a new pending flip) is
// downgraded to plain dictation rather than silently dropped.
const stale = session !== this.voicePttSession || session.pendingEpoch !== this.voicePttEpoch
if (action !== 'text' && stale) {
this.sendToActive(text)
return
}
if (action === 'reject') {
if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
session.reject()
this.updateApprovalBar()
return
}
if (action === 'approve') {
if (!this.isVoiceConnected(session)) return this.flashVoiceStatus('未连接', VOICE_NOT_CONNECTED_FLASH_MS)
// Decision B: arm a cancellable window instead of committing immediately.
// The commit RE-VALIDATES the gate identity (TOCTOU fix): if the held
// permission resolved and a NEW gate was raised during the ~1.5s window
// (pendingEpoch bumps), or the tab/WS changed, drop the approve — never
// silently approve a different, unseen/unheard gate.
const armedSession = session
const armedEpoch = session.pendingEpoch
this.approveConfirm.arm(
() => this.flashVoiceStatus('听到:批准(点此取消)'),
() => {
this.hideVoiceStatus()
const fresh =
armedSession.pendingApproval &&
armedSession.pendingGate === 'tool' &&
armedSession.pendingEpoch === armedEpoch &&
this.isVoiceConnected(armedSession)
if (!fresh) return // gate changed / WS dropped during the window → drop
armedSession.approve()
this.updateApprovalBar()
},
)
return
}
this.sendToActive(text) // UNCHANGED dictation path
}
/** Cancel a still-armed approve confirm window and clear its overlay. Invoked
* on a new utterance, a reject, and a tap on the overlay (§4 cancel triggers). */
private cancelArmedApprove(): void {
if (!this.approveConfirm.isArmed()) return
this.approveConfirm.cancel()
this.hideVoiceStatus()
}
/** Decision C: approve()/reject() are silently dropped by sendMsg when the WS
* isn't open — flash feedback instead of a silent no-op. */
private isVoiceConnected(session: TerminalSession): boolean {
return session.status === 'connected'
}
/** Transient voice-status text ("听到:批准" / "未连接") on a DIFFERENT overlay
* than the interim transcript, so hideInterim() (PTT release) never clears
* it mid-confirm-window. XSS-safe: textContent only. */
private flashVoiceStatus(text: string, autoHideMs?: number): void {
if (!this.voiceConfirmOverlay) return
this.voiceConfirmOverlay.textContent = text
this.voiceConfirmOverlay.style.display = 'block'
if (autoHideMs !== undefined) setTimeout(() => this.hideVoiceStatus(), autoHideMs)
}
private hideVoiceStatus(): void {
if (this.voiceConfirmOverlay) this.voiceConfirmOverlay.style.display = 'none'
}
/** Toggle the activity-timeline panel for the active session (A4). */
private toggleTimeline(): void {
this.timelineOpen = !this.timelineOpen