@goodandready/dsh-voice 0.9.41 → 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 +6 -3
- package/lib/client.js +1 -0
- package/lib/config-routes.js +3 -2
- package/lib/host-routes.js +43 -13
- package/lib/http-util.js +80 -28
- package/package.json +1 -1
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
|
@@ -1498,6 +1498,7 @@ voice._chainsPromise = null
|
|
|
1498
1498
|
voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
1499
1499
|
.then((res) => res.json())
|
|
1500
1500
|
.then((data) => {
|
|
1501
|
+
if (data?.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
|
|
1501
1502
|
if (data && typeof data.localOnly === 'boolean') {
|
|
1502
1503
|
voice.settings.localOnly = data.localOnly
|
|
1503
1504
|
}
|
package/lib/config-routes.js
CHANGED
|
@@ -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
|
}
|
package/lib/host-routes.js
CHANGED
|
@@ -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 (
|
|
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
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
}
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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.
|
|
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 (
|
|
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.
|
|
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",
|