@goodandready/dsh-voice 0.9.40 → 0.9.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -222,16 +222,19 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
222
222
  ### Agent Tool (`transcribe_audio`)
223
223
  Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk. Path containment is strictly enforced against allowed directories (`allowedAudioDirs`, `~/.dsh`, `tmpdir`, `cwd`) with symlink traversal escape prevention and magic-byte audio format validation.
224
224
 
225
- ### Internal HTTP Endpoints
225
+ ### Internal HTTP & WebSocket Endpoints
226
+ * `GET /dsh-voice/ws/stream` — RFC 6455 WebSocket streaming channel for real-time dictation with interim transcription previews (`{ type: 'interim', text }`) and final transcript dispatch (`{ type: 'final', text, action, provider }`). Authenticated via `hasSameOrigin` or `x-dsh-voice-token` (#282, #292).
226
227
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
227
228
  * `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
228
- * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and `effectiveSensevoiceProvider`.
229
+ * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and `effectiveSensevoiceProvider`. Emits `sessionToken` exclusively to verified same-origin callers (#292).
229
230
  * `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
230
- * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
231
+ * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network with automatic session token rotation (#292).
231
232
  * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 1-click model installation status and trigger (protected by `isTrustedCaller`, model paths redacted).
232
233
  * `POST /api/dsh-voice/update` — Triggers plugin self-update to latest compatible npm version (protected by local caller verification and profile lock check).
233
234
  * `GET /api/dsh-voice/update` — Returns update check status (current version, latest version, updateAvailable).
234
235
 
236
+ > **Security & Quota Protections (#292, #293)**: State-changing HTTP routes and WebSocket upgrades enforce strict same-origin verification and token authentication against reverse LAN bridges (such as `dsh-lanmode`), preventing unauthorized configuration changes or quota depletion. Real-time interim WebSocket streaming processes only the latest audio chunk ($O(N)$ linear byte consumption) and restricts recognition strictly to local engines (`local-whisper`, `sensevoice`, `browser-webgpu`), ensuring zero cloud quota consumption during interim speech preview.
237
+
235
238
  ### 🔒 Profile Lock & Controlled Operator Recovery Policy
236
239
  When updating or installing plugins, DeepSeek Harness profiles coordinate concurrent operations using `<profile-dir>/package.json.lock` (for example, `~/.dsh/profiles/<profile>/package.json.lock`).
237
240
  * **Canonical Contender Policy**: The contender process never deletes or unlinks an existing lockfile. Automatic stale lock removal by contenders is prohibited to eliminate TOCTOU (Time-of-Check to Time-of-Use) race conditions and prevent unintended lockfile clobbering.
package/lib/client.js CHANGED
@@ -786,6 +786,12 @@ window.__ModuleLoader__.load({
786
786
  const undoIcon = () => React.createElement('svg', ic,
787
787
  React.createElement('path', { d: 'M9 14L4 9l5-5' }),
788
788
  React.createElement('path', { d: 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v.5' }))
789
+ const editIcon = () => React.createElement('svg', ic,
790
+ React.createElement('path', { d: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7' }),
791
+ React.createElement('path', { d: 'M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z' }))
792
+ const sendIcon = () => React.createElement('svg', ic,
793
+ React.createElement('line', { x1: 22, y1: 2, x2: 11, y2: 13 }),
794
+ React.createElement('polygon', { points: '22 2 15 22 11 13 2 9 22 2' }))
789
795
  const chevronIcon = () => (
790
796
  ChevronIcon
791
797
  ? React.createElement(ChevronIcon, { className: 'dvo-chev-icon' })
@@ -1492,6 +1498,7 @@ voice._chainsPromise = null
1492
1498
  voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
1493
1499
  .then((res) => res.json())
1494
1500
  .then((data) => {
1501
+ if (data?.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
1495
1502
  if (data && typeof data.localOnly === 'boolean') {
1496
1503
  voice.settings.localOnly = data.localOnly
1497
1504
  }
@@ -5529,6 +5536,9 @@ function MacrosSection(props) {
5529
5536
  cancelPendingPreview,
5530
5537
  keepPendingPreview,
5531
5538
  submitPendingPreview,
5539
+ renderEditablePreviewPill,
5540
+ editIcon,
5541
+ sendIcon,
5532
5542
  loadWebGpuWhisper,
5533
5543
  isWebGpuSupported,
5534
5544
  isWasmSupported,
@@ -1,7 +1,7 @@
1
1
  // lib/config-routes.js — Host route for plugin configuration persistence over network.
2
2
  // Solves DSH network limitation where client-side persistence is blocked in non-loopback environments (Gitea #149).
3
3
 
4
- import { writeJson, readBody, isTrustedCaller } from './http-util.js'
4
+ import { writeJson, readBody, isTrustedCaller, rotateVoiceSessionToken, getVoiceSessionToken } from './http-util.js'
5
5
 
6
6
  export function registerConfigRoutes(ctx, {
7
7
  NS,
@@ -153,11 +153,12 @@ export function registerConfigRoutes(ctx, {
153
153
  triggerAutostart()
154
154
  }
155
155
 
156
+ rotateVoiceSessionToken()
156
157
  let newRev
157
158
  try {
158
159
  newRev = settingsService?.describe?.()?.find?.((r) => r.ns === NS)?.revision
159
160
  } catch {}
160
- writeJson(res, 200, { ok: true, config: live(), revision: newRev })
161
+ writeJson(res, 200, { ok: true, config: live(), revision: newRev, sessionToken: getVoiceSessionToken() })
161
162
  } catch (err) {
162
163
  writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
163
164
  }
@@ -1,6 +1,6 @@
1
1
  // HTTP host routes registered on ctx.webServer.
2
2
  import os from 'node:os'
3
- import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken } from './http-util.js'
3
+ import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken, verifyVoiceSessionToken, hasSameOrigin } from './http-util.js'
4
4
  import { resolveSensevoiceProvider } from './sensevoice-installer.js'
5
5
  import { applyVoiceMacros } from './macros.js'
6
6
  import { createSyntheticWav } from './wav.js'
@@ -46,7 +46,7 @@ export function registerHostRoutes(ctx, deps) {
46
46
  const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
47
47
  writeJson(res, 200, {
48
48
  ok: true,
49
- sessionToken: getVoiceSessionToken(),
49
+ sessionToken: hasSameOrigin(req) ? getVoiceSessionToken() : null,
50
50
  whisperRunning: whisperOk,
51
51
  whisperError: whisperOk ? null : whisperError,
52
52
  sensevoiceRunning: sensevoiceOk,
@@ -385,7 +385,12 @@ export function registerHostRoutes(ctx, deps) {
385
385
  const url = new URL(req.url || '/', 'http://127.0.0.1')
386
386
  const token = req.headers['x-dsh-voice-token'] || url.searchParams.get('token')
387
387
  const expected = getVoiceSessionToken()
388
- if (expected && token !== expected) {
388
+ if (!hasSameOrigin(req) && (!token || !verifyVoiceSessionToken(token))) {
389
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
390
+ socket.destroy()
391
+ return
392
+ }
393
+ if (expected && token && !verifyVoiceSessionToken(token)) {
389
394
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
390
395
  socket.destroy()
391
396
  return
@@ -409,14 +414,33 @@ export function registerHostRoutes(ctx, deps) {
409
414
  streamChunks.push(Buffer.from(msg.dataBase64, 'base64'))
410
415
  } else if (msg.type === 'interim') {
411
416
  if (streamChunks.length > 0) {
412
- const buf = Buffer.concat(streamChunks)
413
- if (buf.length > 3000) {
414
- try {
415
- const interimOut = await transcribe(buf, streamMime, streamMode)
416
- if (interimOut && interimOut.text) {
417
- ws.send(JSON.stringify({ type: 'interim', text: interimOut.text }))
417
+ const latestChunk = streamChunks[streamChunks.length - 1]
418
+ if (latestChunk && latestChunk.length > 3000) {
419
+ const cfg = live()
420
+ const baseModeCfg = streamMode === 'message' ? cfg.message : cfg.dictation
421
+ const LOCAL_ENGINES = new Set(['local-whisper', 'sensevoice', 'browser-webgpu'])
422
+ const interimChain = (baseModeCfg.chain || []).filter((e) => LOCAL_ENGINES.has(e.provider))
423
+
424
+ let canRunLocal = false
425
+ for (const entry of interimChain) {
426
+ if (entry.provider === 'local-whisper' && (await whisperAlive())) { canRunLocal = true; break }
427
+ if (entry.provider === 'sensevoice' && (await sensevoiceAlive())) { canRunLocal = true; break }
428
+ if (entry.provider === 'browser-webgpu') { canRunLocal = true; break }
429
+ }
430
+
431
+ if (canRunLocal && interimChain.length > 0) {
432
+ const interimModeCfg = { ...baseModeCfg, chain: interimChain, polish: false }
433
+ const controller = new AbortController()
434
+ const timer = setTimeout(() => controller.abort(), 5000)
435
+ try {
436
+ const interimOut = await transcribe(interimModeCfg, latestChunk, streamMime, controller.signal)
437
+ if (interimOut && interimOut.text) {
438
+ ws.send(JSON.stringify({ type: 'interim', text: interimOut.text }))
439
+ }
440
+ } catch (err) { /* ignore interim failure */ } finally {
441
+ clearTimeout(timer)
418
442
  }
419
- } catch (_) {}
443
+ }
420
444
  }
421
445
  }
422
446
  } else if (msg.type === 'finish') {
@@ -426,11 +450,15 @@ export function registerHostRoutes(ctx, deps) {
426
450
  ws.send(JSON.stringify({ type: 'final', text: '', ok: true }))
427
451
  return
428
452
  }
453
+ const cfg = live()
454
+ const modeCfg = streamMode === 'message' ? cfg.message : cfg.dictation
455
+ const controller = new AbortController()
456
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 25000)
429
457
  try {
430
- const finalOut = await transcribe(buf, streamMime, streamMode)
458
+ const finalOut = await transcribe(modeCfg, buf, streamMime, controller.signal)
431
459
  let text = finalOut && finalOut.text ? finalOut.text : ''
432
460
  let action = null
433
- if (extractVoiceActions) {
461
+ if (extractVoiceActions && cfg.voiceActions !== false) {
434
462
  const act = extractVoiceActions(text)
435
463
  if (act.action) { action = act.action; text = act.text }
436
464
  }
@@ -443,9 +471,11 @@ export function registerHostRoutes(ctx, deps) {
443
471
  type: 'error',
444
472
  error: String(err && err.message ? err.message : err), ok: false,
445
473
  }))
474
+ } finally {
475
+ clearTimeout(timer)
446
476
  }
447
477
  }
448
- } catch (_) {}
478
+ } catch (err) { /* ignore parse error */ }
449
479
  }
450
480
  })
451
481
  })
package/lib/http-util.js CHANGED
@@ -22,6 +22,11 @@ export function setVoiceSessionToken(t) {
22
22
  sessionSecret = String(t || '')
23
23
  }
24
24
 
25
+ export function rotateVoiceSessionToken() {
26
+ sessionSecret = randomBytes(24).toString('hex')
27
+ return sessionSecret
28
+ }
29
+
25
30
  export function verifyVoiceSessionToken(token) {
26
31
  if (!token || typeof token !== 'string') return false
27
32
  const secret = getVoiceSessionToken()
@@ -100,12 +105,52 @@ export function isLoopback(addr) {
100
105
  return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
101
106
  }
102
107
 
108
+ /**
109
+ * Check whether a request originates from the same origin as the Host header (#292).
110
+ * @param {import('node:http').IncomingMessage} req
111
+ * @returns {boolean}
112
+ */
113
+ export function hasSameOrigin(req) {
114
+ if (!req || !req.headers) return false
115
+ const secSite = String(req.headers['sec-fetch-site'] || '').toLowerCase()
116
+ if (secSite === 'cross-site' || secSite === 'same-site') return false
117
+
118
+ const host = String(req.headers.host || '').trim().toLowerCase()
119
+ if (!host) return false
120
+
121
+ const origin = String(req.headers.origin || '').trim().toLowerCase()
122
+ if (origin) {
123
+ try {
124
+ const u = new URL(origin)
125
+ return u.host.toLowerCase() === host
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+
131
+ const referer = String(req.headers.referer || '').trim().toLowerCase()
132
+ if (referer) {
133
+ try {
134
+ const u = new URL(referer)
135
+ return u.host.toLowerCase() === host
136
+ } catch {
137
+ return false
138
+ }
139
+ }
140
+
141
+ if (secSite === 'same-origin') return true
142
+
143
+ return false
144
+ }
145
+
103
146
  /**
104
147
  * Verify that the HTTP request originates from a trusted caller.
105
- * Fail-closed security guard for state-changing or quota-consuming POST routes (#117, #173, #277).
106
- * - Loopback callers (127.0.0.1, ::1) are trusted unless rejected by browser security headers.
107
- * - Non-loopback LAN mutating/quota-consuming POST/PUT requests require valid session token (#277).
148
+ * Fail-closed security guard for state-changing or quota-consuming routes (#117, #173, #277, #292).
108
149
  * - Routes marked loopbackOnly strictly reject non-loopback callers.
150
+ * - Mutating requests (POST, PUT, PATCH) or WS upgrades require validated same-origin or valid session token (#292).
151
+ * Loopback callers without Origin/Referer through dsh-lanmode bridge are rejected without token.
152
+ * - Non-loopback LAN requests require valid session token for mutating operations (#277).
153
+ * - Non-mutating GET requests require loopback or same-origin.
109
154
  * @param {import('node:http').IncomingMessage} req
110
155
  * @param {{ loopbackOnly?: boolean }} [options]
111
156
  * @returns {boolean}
@@ -142,7 +187,8 @@ export function isTrustedCaller(req, options = {}) {
142
187
 
143
188
  // 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
144
189
  const method = String(req.method || 'GET').toUpperCase()
145
- if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
190
+ const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
191
+ if (isMutating) {
146
192
  const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
147
193
  const isJson = contentType.startsWith('application/json')
148
194
  const isOctet = contentType.startsWith('application/octet-stream')
@@ -151,39 +197,45 @@ export function isTrustedCaller(req, options = {}) {
151
197
  }
152
198
  }
153
199
 
154
- // 5. Loopback remote address is trusted when not rejected by browser headers above
155
200
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
156
- if (isLoopback(remote)) return true
157
-
158
- // If route strictly requires loopback (e.g. host model installer or hardware benchmark)
159
- if (options && options.loopbackOnly) return false
201
+ const remoteIsLoopback = isLoopback(remote)
202
+ const hostName = host.replace(/:\d+$/, '')
203
+ const hostIsLoopback = hostName === '127.0.0.1' || hostName === 'localhost' || hostName === '::1' || hostName === '[::1]'
204
+ const isPureLocal = remoteIsLoopback && hostIsLoopback
205
+
206
+ // 5. If route strictly requires loopback (e.g. host model installer or hardware benchmark)
207
+ if (options && options.loopbackOnly) {
208
+ if (!isPureLocal) return false
209
+ return true
210
+ }
160
211
 
161
- // 6. Non-loopback LAN requests: POST/PUT/PATCH quota-burning operations require session auth token (#277)
162
- const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
212
+ // 6. Mutating requests (POST/PUT/PATCH):
213
+ // Even from loopback (which may be proxied via dsh-lanmode bridge), requests without Origin/Referer
214
+ // must present a valid x-dsh-voice-token (#292).
163
215
  if (isMutating) {
164
216
  const incomingToken = String(req.headers?.['x-dsh-voice-token'] || req.headers?.['x-voice-token'] || '')
165
- if (incomingToken && verifyVoiceSessionToken(incomingToken)) {
166
- return true
217
+ const hasValidToken = Boolean(incomingToken && verifyVoiceSessionToken(incomingToken))
218
+
219
+ if (!remoteIsLoopback) {
220
+ // Non-loopback LAN callers: always require valid token (#277)
221
+ return hasValidToken
167
222
  }
223
+
224
+ // Loopback callers (or reverse bridge dsh-lanmode):
225
+ // If request has verified same-origin (browser UI), allow it.
226
+ if (hasSameOrigin(req)) return true
227
+ // If request has valid session token, allow it.
228
+ if (hasValidToken) return true
229
+ // Requests without Origin/Referer and without token: strictly reject (#292)
168
230
  return false
169
231
  }
170
232
 
171
- // 7. Non-state-changing GET requests from LAN: verify same-origin
233
+ // 7. Loopback remote address for non-mutating GET requests
234
+ if (remoteIsLoopback) return true
235
+
236
+ // 8. Non-loopback LAN GET requests: require same-origin
172
237
  if (secSite === 'same-origin') return true
173
- if (origin) {
174
- try {
175
- const u = new URL(origin)
176
- if (u.host === host) return true
177
- } catch { /* invalid url */ }
178
- return false
179
- }
180
- if (referer) {
181
- try {
182
- const u = new URL(referer)
183
- if (u.host === host) return true
184
- } catch { /* invalid url */ }
185
- return false
186
- }
238
+ if (hasSameOrigin(req)) return true
187
239
 
188
240
  return false
189
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.40",
3
+ "version": "0.9.42",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",