@goodandready/dsh-voice 0.8.38 → 0.9.1

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
@@ -211,12 +211,15 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
211
211
  ## 🤖 Agent Tool & HTTP API
212
212
 
213
213
  ### Agent Tool (`transcribe_audio`)
214
- Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk.
214
+ 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.
215
215
 
216
216
  ### Internal HTTP Endpoints
217
217
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
218
218
  * `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
219
219
  * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice.
220
+ * `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
221
+ * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
222
+ * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 1-click model installation status and trigger (protected by `isTrustedCaller`, model paths redacted).
220
223
 
221
224
  ---
222
225
 
package/README.ru.md CHANGED
@@ -211,7 +211,7 @@ dsh plugin --profile web add @goodandready/dsh-voice
211
211
  ## 🤖 Инструмент агента и HTTP API
212
212
 
213
213
  ### Инструмент агента (`transcribe_audio`)
214
- Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools`, позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
214
+ Регистрирует `transcribe_audio(file_path, language?)` в `ctx.tools` (с проверкой границ разрешённых каталогов `allowedAudioDirs` / `~/.dsh` / `tmpdir` / `cwd`, защитой от симлинков и валидацией сигнатур аудиофайлов), позволяя агентам распознавать аудиофайлы, интервью и записи с диска.
215
215
 
216
216
  ### Внутренние HTTP эндпоинты
217
217
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
package/README.zh.md CHANGED
@@ -129,6 +129,22 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
129
129
 
130
130
  ---
131
131
 
132
+ ## 🤖 智能体工具与 HTTP 端点
133
+
134
+ ### 智能体工具 (`transcribe_audio`)
135
+ 在 `ctx.tools` 中注册 `transcribe_audio(file_path, language?)`,允许智能体直接读取和转写本地音频文件。包含严格的目录边界检查(`allowedAudioDirs`、`~/.dsh`、`tmpdir`、`cwd`)、符号链接越界防护以及音频特征码(magic bytes)校验。
136
+
137
+ ### 内部 HTTP 端点
138
+
139
+ * `POST /dsh-voice/transcribe` — 音频转写:`{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
140
+ * `POST /dsh-voice/polish` — 文本模型润色:`{ text }` → `{ ok, text }`
141
+ * `GET /dsh-voice/status` — 查询后端状态、服务商健康度与 SenseVoice
142
+ * `GET /dsh-voice/config` — 获取插件运行时配置快照
143
+ * `PUT /dsh-voice/config` — 跨网络安全持久化更新插件配置
144
+ * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 一键模型安装状态与触发接口(受 `isTrustedCaller` 保护,绝对路径脱敏)
145
+
146
+ ---
147
+
132
148
  ## 📦 安装指南
133
149
 
134
150
  ```bash
@@ -0,0 +1,133 @@
1
+ import path from 'node:path'
2
+ import os from 'node:os'
3
+ import { realpath } from 'node:fs/promises'
4
+
5
+ /**
6
+ * Check whether a target path is strictly located within any of the allowed root directories.
7
+ * Boundary-aware prefix match: /tmp does not allow /tmp-other.
8
+ * @param {string} targetPath
9
+ * @param {string[]} allowedRoots
10
+ * @returns {boolean}
11
+ */
12
+ export function isPathUnderRoots(targetPath, allowedRoots) {
13
+ if (!targetPath || !Array.isArray(allowedRoots) || allowedRoots.length === 0) return false
14
+ const normalized = path.resolve(targetPath)
15
+
16
+ return allowedRoots.some((root) => {
17
+ if (!root) return false
18
+ const normRoot = path.resolve(root)
19
+ if (normalized === normRoot) return true
20
+ const prefix = normRoot.endsWith(path.sep) ? normRoot : normRoot + path.sep
21
+ return normalized.startsWith(prefix)
22
+ })
23
+ }
24
+
25
+ /**
26
+ * Return allowed root directories for the transcribe_audio tool.
27
+ * Combines configured audio directories with standard safe locations (.dsh, tmpdir, cwd).
28
+ * @param {object} [cfg]
29
+ * @returns {string[]}
30
+ */
31
+ export function getAllowedAudioRoots(cfg) {
32
+ const custom = Array.isArray(cfg?.allowedAudioDirs) ? cfg.allowedAudioDirs : []
33
+ const defaults = [
34
+ os.tmpdir(),
35
+ path.join(os.homedir(), '.dsh'),
36
+ process.cwd(),
37
+ ]
38
+ return [...custom, ...defaults].map((d) => path.resolve(String(d)))
39
+ }
40
+
41
+ /**
42
+ * Validate audio path before access:
43
+ * 1. Checks lexical path boundary before calling stat or read.
44
+ * 2. Checks realpath to prevent symlink traversal escapes.
45
+ * @param {string} filePath
46
+ * @param {string[]} allowedRoots
47
+ * @returns {Promise<{ resolved: string, real: string }>}
48
+ */
49
+ export async function validateAudioPath(filePath, allowedRoots) {
50
+ const raw = String(filePath || '').trim()
51
+ if (!raw) {
52
+ throw new Error('transcribe_audio: file_path is required')
53
+ }
54
+
55
+ const resolved = path.resolve(raw)
56
+ if (!isPathUnderRoots(resolved, allowedRoots)) {
57
+ throw new Error(`transcribe_audio: path is outside allowed audio directories: ${raw}`)
58
+ }
59
+
60
+ let real = ''
61
+ try {
62
+ real = await realpath(resolved)
63
+ } catch (err) {
64
+ throw new Error(`transcribe_audio: file not found: ${raw}`)
65
+ }
66
+
67
+ if (!isPathUnderRoots(real, allowedRoots)) {
68
+ throw new Error(`transcribe_audio: resolved symlink is outside allowed audio directories: ${raw}`)
69
+ }
70
+
71
+ return { resolved, real }
72
+ }
73
+
74
+ /**
75
+ * Sniff audio format from magic bytes.
76
+ * Supports WAV, MP3, OGG, FLAC, WebM, and M4A/MP4.
77
+ * @param {Buffer|Uint8Array} buffer
78
+ * @returns {string|null} Detected MIME type or null
79
+ */
80
+ export function sniffAudioFormat(buffer) {
81
+ if (!buffer || buffer.length < 4) return null
82
+
83
+ // WAV: 'RIFF' .... 'WAVE'
84
+ if (buffer.length >= 12) {
85
+ const riff = String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3])
86
+ const wave = String.fromCharCode(buffer[8], buffer[9], buffer[10], buffer[11])
87
+ if (riff === 'RIFF' && wave === 'WAVE') return 'audio/wav'
88
+ }
89
+
90
+ // OGG: 'OggS'
91
+ if (String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3]) === 'OggS') {
92
+ return 'audio/ogg'
93
+ }
94
+
95
+ // FLAC: 'fLaC'
96
+ if (String.fromCharCode(buffer[0], buffer[1], buffer[2], buffer[3]) === 'fLaC') {
97
+ return 'audio/flac'
98
+ }
99
+
100
+ // WebM / Matroska: 0x1A 0x45 0xDF 0xA3 (EBML)
101
+ if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
102
+ return 'audio/webm'
103
+ }
104
+
105
+ // MP3: ID3 header or frame sync 0xFF 0xFB/F3/F2
106
+ if (buffer.length >= 3 && String.fromCharCode(buffer[0], buffer[1], buffer[2]) === 'ID3') {
107
+ return 'audio/mpeg'
108
+ }
109
+ if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0) {
110
+ return 'audio/mpeg'
111
+ }
112
+
113
+ // M4A / MP4: '....ftyp'
114
+ if (buffer.length >= 8) {
115
+ const ftyp = String.fromCharCode(buffer[4], buffer[5], buffer[6], buffer[7])
116
+ if (ftyp === 'ftyp') return 'audio/mp4'
117
+ }
118
+
119
+ return null
120
+ }
121
+
122
+ /**
123
+ * Enforce audio MIME/content validation independently of file extension.
124
+ * @param {Buffer|Uint8Array} buffer
125
+ * @returns {string} Detected audio MIME type
126
+ */
127
+ export function validateAudioContent(buffer) {
128
+ const mime = sniffAudioFormat(buffer)
129
+ if (!mime) {
130
+ throw new Error('transcribe_audio: unsupported or invalid audio file format (magic bytes check failed)')
131
+ }
132
+ return mime
133
+ }
package/lib/client.js CHANGED
@@ -225,6 +225,7 @@ window.__ModuleLoader__.load({
225
225
  'jargonAdd': 'Add replacement',
226
226
  'jargonFrom': 'Spoken slang',
227
227
  'jargonTo': 'Correct spelling',
228
+ 'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
228
229
  }
229
230
 
230
231
  const zh = {
@@ -418,6 +419,7 @@ window.__ModuleLoader__.load({
418
419
  'jargonAdd': '添加替换项',
419
420
  'jargonFrom': '口语词汇',
420
421
  'jargonTo': '标准拼写',
422
+ 'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
421
423
  }
422
424
 
423
425
  // Strings are also needed outside components — in recording handlers and
@@ -1222,6 +1224,15 @@ window.__ModuleLoader__.load({
1222
1224
  if (event.repeat) return
1223
1225
  // A modifier hotkey does not fight the text field: modifiers do not
1224
1226
  // type. A plain letter key must not be hijacked while typing.
1227
+ const target = event.target
1228
+ const isEditable = target && (
1229
+ target.tagName === 'INPUT' ||
1230
+ target.tagName === 'TEXTAREA' ||
1231
+ target.isContentEditable ||
1232
+ (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
1233
+ )
1234
+ if (isEditable && !['Control', 'Alt', 'Shift'].includes(keyName)) return
1235
+
1225
1236
  if (hotkeyMatches(event, keyName)) beginHold(mode)
1226
1237
  }
1227
1238
  const up = (event) => {
@@ -2174,6 +2185,25 @@ window.__ModuleLoader__.load({
2174
2185
  const [draft, setDraft] = React.useState(null)
2175
2186
  const [saved, setSaved] = React.useState(false)
2176
2187
  const [err, setErr] = React.useState('')
2188
+ const [serverConfig, setServerConfig] = React.useState(null)
2189
+ const [configLoading, setConfigLoading] = React.useState(true)
2190
+
2191
+ React.useEffect(() => {
2192
+ let alive = true
2193
+ fetch('/dsh-voice/config', { cache: 'no-store' })
2194
+ .then((r) => r.json())
2195
+ .then((data) => {
2196
+ if (alive && data && data.ok && data.config) {
2197
+ setServerConfig(data.config)
2198
+ setDraft((curr) => curr || deepClone(data.config))
2199
+ }
2200
+ })
2201
+ .catch(() => {})
2202
+ .finally(() => {
2203
+ if (alive) setConfigLoading(false)
2204
+ })
2205
+ return () => { alive = false }
2206
+ }, [])
2177
2207
 
2178
2208
  // Hotkey picker: not a name field — press the key you want.
2179
2209
  const [catching, setCatching] = React.useState(false)
@@ -2207,9 +2237,9 @@ window.__ModuleLoader__.load({
2207
2237
  .catch(() => {})
2208
2238
  }, [])
2209
2239
 
2210
- const ready = !!snap && snap.status === 'ready'
2211
- const value = ready && snap.value ? snap.value : {}
2212
- const writable = ready && snap.writable !== false
2240
+ const ready = !!serverConfig || (!!snap && snap.status === 'ready')
2241
+ const value = (snap && snap.status === 'ready' && snap.value) ? snap.value : (serverConfig || {})
2242
+ const writable = ready
2213
2243
 
2214
2244
  React.useEffect(() => {
2215
2245
  if (ready) return undefined
@@ -2455,7 +2485,7 @@ window.__ModuleLoader__.load({
2455
2485
  }, [checkUpdater])
2456
2486
 
2457
2487
  if (!ready) {
2458
- const waiting = !snap || snap.status === 'loading'
2488
+ const waiting = configLoading || (!snap || snap.status === 'loading')
2459
2489
  return React.createElement('div', { className: 'cb-page' },
2460
2490
  React.createElement('div', { className: 'dvs-wait' }, waiting
2461
2491
  ? t('loadingSettings')
@@ -2476,13 +2506,40 @@ window.__ModuleLoader__.load({
2476
2506
  setErr(''); setSaved(false)
2477
2507
  if (!draft) return
2478
2508
 
2479
- const failed = []
2480
- for (const k of Object.keys(draft)) {
2481
- try {
2482
- await scope.set(k, draft[k])
2483
- } catch (e) {
2484
- failed.push(k + ': ' + String(e && e.message ? e.message : e))
2509
+ let httpOk = false
2510
+ let httpError = null
2511
+ try {
2512
+ const res = await fetch('/dsh-voice/config', {
2513
+ method: 'PUT',
2514
+ headers: { 'Content-Type': 'application/json' },
2515
+ body: JSON.stringify({ config: draft }),
2516
+ })
2517
+ const data = await res.json()
2518
+ if (res.ok && data && data.ok) {
2519
+ httpOk = true
2520
+ if (data.config) {
2521
+ setServerConfig(data.config)
2522
+ }
2523
+ } else {
2524
+ httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
2485
2525
  }
2526
+ } catch (e) {
2527
+ httpError = e?.message || String(e)
2528
+ }
2529
+
2530
+ if (scope && typeof scope.set === 'function') {
2531
+ for (const k of Object.keys(draft)) {
2532
+ try {
2533
+ await scope.set(k, draft[k])
2534
+ } catch (e) {
2535
+ /* best effort on loopback */
2536
+ }
2537
+ }
2538
+ }
2539
+
2540
+ if (!httpOk) {
2541
+ setErr(t('saveFailed') + ': ' + httpError)
2542
+ return
2486
2543
  }
2487
2544
 
2488
2545
  const d = draft || {}
@@ -2501,11 +2558,17 @@ window.__ModuleLoader__.load({
2501
2558
  noiseGateDb: Number(d.noiseGateDb !== undefined ? d.noiseGateDb : -45),
2502
2559
  contextGlossary: d.contextGlossary !== false,
2503
2560
  visualizerStyle: d.visualizerStyle || 'liquid-wave',
2561
+ gatedTurnTaking: d.gatedTurnTaking !== false,
2562
+ autoSendVisualRing: d.autoSendVisualRing !== false,
2563
+ voiceActions: d.voiceActions !== false,
2564
+ techJargonCorrection: d.techJargonCorrection !== false,
2565
+ liveInterimPreview: d.liveInterimPreview !== false,
2566
+ structuredPromptVoice: d.structuredPromptVoice !== false,
2504
2567
  })
2505
2568
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
2506
2569
 
2507
- if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
2508
- setSaved(true); setTimeout(() => setSaved(false), 2000)
2570
+ setSaved(true)
2571
+ setTimeout(() => setSaved(false), 2000)
2509
2572
  fetchStatus()
2510
2573
  }
2511
2574
 
@@ -2964,7 +3027,7 @@ window.__ModuleLoader__.load({
2964
3027
  React.createElement('textarea', {
2965
3028
  rows: 3,
2966
3029
  disabled: !writable,
2967
- placeholder: 'кубик -> k8s\nворктри -> worktree',
3030
+ placeholder: t('jargonPlaceholder'),
2968
3031
  value: Array.isArray(draft && draft.jargonDictionary)
2969
3032
  ? draft.jargonDictionary.map((item) => item && item.from ? `${item.from} -> ${item.to}` : '').filter(Boolean).join('\n')
2970
3033
  : '',
@@ -3047,7 +3110,7 @@ window.__ModuleLoader__.load({
3047
3110
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
3048
3111
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
3049
3112
  React.createElement('span', { className: 'cb-badge cb-badge-ok' },
3050
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.8.32')
3113
+ 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.1')
3051
3114
  ),
3052
3115
  updaterStatus && updaterStatus.updateAvailable
3053
3116
  ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
@@ -0,0 +1,135 @@
1
+ // lib/config-routes.js — Host route for plugin configuration persistence over network.
2
+ // Solves DSH network limitation where client-side persistence is blocked in non-loopback environments (Gitea #149).
3
+
4
+ import { writeJson, readBody, isTrustedCaller } from './http-util.js'
5
+
6
+ export function registerConfigRoutes(ctx, {
7
+ NS,
8
+ live,
9
+ getSettingsService,
10
+ getScope,
11
+ validateConfig,
12
+ triggerAutostart,
13
+ }) {
14
+ return ctx.webServer.register({
15
+ kind: 'exact',
16
+ path: '/dsh-voice/config',
17
+ handler: async (req, res) => {
18
+ // 1. Method check: only GET, PUT, POST allowed
19
+ if (req.method !== 'GET' && req.method !== 'PUT' && req.method !== 'POST') {
20
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET, PUT, or POST only' } })
21
+ return
22
+ }
23
+
24
+ // 2. Security check: loopback or same-origin
25
+ if (!isTrustedCaller(req)) {
26
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } })
27
+ return
28
+ }
29
+
30
+ // 3. GET: return live configuration snapshot
31
+ if (req.method === 'GET') {
32
+ writeJson(res, 200, { ok: true, config: live() })
33
+ return
34
+ }
35
+
36
+ // 4. PUT / POST: check settings service availability
37
+ const settingsService = getSettingsService?.()
38
+ const scope = getScope?.()
39
+ if (!settingsService && !scope) {
40
+ writeJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'settings service not ready' } })
41
+ return
42
+ }
43
+
44
+ // 5. Read request body
45
+ let raw
46
+ try {
47
+ raw = await readBody(req, 1024 * 1024)
48
+ } catch (e) {
49
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
50
+ return
51
+ }
52
+
53
+ // 6. Parse JSON payload
54
+ let payload
55
+ try {
56
+ payload = JSON.parse(raw.toString('utf8') || '{}')
57
+ } catch {
58
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
59
+ return
60
+ }
61
+
62
+ const rawConfig = payload && typeof payload.config === 'object' && payload.config !== null
63
+ ? payload.config
64
+ : payload
65
+
66
+ if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
67
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: 'body must be a JSON object' } })
68
+ return
69
+ }
70
+
71
+ // 7. Validate and merge configuration
72
+ let merged
73
+ try {
74
+ const current = live() || {}
75
+ const candidate = {
76
+ ...current,
77
+ ...rawConfig,
78
+ dictation: { ...(current.dictation || {}), ...(rawConfig.dictation || {}) },
79
+ message: { ...(current.message || {}), ...(rawConfig.message || {}) },
80
+ }
81
+ merged = validateConfig ? validateConfig(candidate) : candidate
82
+ } catch (err) {
83
+ writeJson(res, 400, { ok: false, error: { code: 'validation', message: String(err?.message || err) } })
84
+ return
85
+ }
86
+
87
+ // 8. Persist to host settings
88
+ try {
89
+ let saved = false
90
+ if (settingsService) {
91
+ let rev
92
+ try {
93
+ rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
94
+ } catch { /* ignore revision lookup errors */ }
95
+
96
+ if (typeof settingsService.replace === 'function') {
97
+ await settingsService.replace(NS, merged, rev)
98
+ saved = true
99
+ } else if (typeof settingsService.update === 'function') {
100
+ await settingsService.update(NS, merged, rev)
101
+ saved = true
102
+ }
103
+ }
104
+
105
+ if (scope) {
106
+ if (typeof scope.replace === 'function') {
107
+ await scope.replace(merged)
108
+ saved = true
109
+ } else if (typeof scope.patch === 'function') {
110
+ await scope.patch(merged)
111
+ saved = true
112
+ } else if (typeof scope.set === 'function') {
113
+ for (const [k, v] of Object.entries(merged)) {
114
+ await scope.set(k, v).catch(() => {})
115
+ }
116
+ saved = true
117
+ }
118
+ }
119
+
120
+ if (!saved) {
121
+ writeJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'failed to persist settings' } })
122
+ return
123
+ }
124
+
125
+ if (typeof triggerAutostart === 'function') {
126
+ triggerAutostart()
127
+ }
128
+
129
+ writeJson(res, 200, { ok: true, config: live() })
130
+ } catch (err) {
131
+ writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
132
+ }
133
+ },
134
+ })
135
+ }
package/lib/http-util.js CHANGED
@@ -50,8 +50,7 @@ export function isTrustedCaller(req) {
50
50
  if (isLoopback(remote)) return true
51
51
 
52
52
  const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
53
- if (secSite === 'same-origin' || secSite === 'same-site') return true
54
- if (secSite === 'cross-site') return false
53
+ if (secSite === 'cross-site' || secSite === 'same-site') return false
55
54
 
56
55
  const host = String(req.headers?.host || '').toLowerCase()
57
56
  const origin = String(req.headers?.origin || '').trim().toLowerCase()
@@ -63,6 +62,8 @@ export function isTrustedCaller(req) {
63
62
  return false
64
63
  }
65
64
 
65
+ if (secSite === 'same-origin') return true
66
+
66
67
  const referer = String(req.headers?.referer || '').trim().toLowerCase()
67
68
  if (referer) {
68
69
  try {
package/lib/index.js CHANGED
@@ -29,6 +29,8 @@ import { buildProviderOrder, sessionCommand } from './transcribe-core.js'
29
29
  import { registerPluginUpdater } from './updater.js'
30
30
  import { registerSensevoiceInstaller } from './sensevoice-installer.js'
31
31
  import { createConfigReader } from './config-compat.js'
32
+ import { registerConfigRoutes } from './config-routes.js'
33
+ import { getAllowedAudioRoots, validateAudioPath, validateAudioContent } from './audio-guard.js'
32
34
 
33
35
  function isAutoLang(lang) {
34
36
  return !lang || lang === 'auto' || String(lang).includes(',')
@@ -135,6 +137,7 @@ const BaseConfig = z.object({
135
137
  ffmpegBin: z.string().default('ffmpeg')
136
138
  .description('ffmpeg used to convert browser webm/opus into the WAV that whisper.cpp requires.'),
137
139
  timeoutMs: z.number().default(120000),
140
+ allowedAudioDirs: z.array(z.string()).default([]),
138
141
  maxFileBytes: z.number().default(25 * 1024 * 1024),
139
142
  normalizeTranscript: z.boolean().default(false)
140
143
  .description('transcribe_audio: convert spoken numbers to digits and tidy punctuation.'),
@@ -214,6 +217,7 @@ export function apply(ctx, baseConfig) {
214
217
  // while caching unwrapped values across requests.
215
218
  const readEntryConfig = createConfigReader(baseConfig, ctx)
216
219
  let scope = null
220
+ let settingsService = null
217
221
  let getConfig = readEntryConfig
218
222
  const live = () => BaseConfig(structuredClone(getConfig() ?? {})) ?? readEntryConfig()
219
223
 
@@ -369,6 +373,7 @@ export function apply(ctx, baseConfig) {
369
373
  }
370
374
 
371
375
  ctx.inject(['settings'], (sctx) => {
376
+ settingsService = sctx.settings
372
377
  try {
373
378
  scope = sctx.settings.register(NS, Config, { base: baseConfig })
374
379
  if (scope && typeof scope.get === 'function') {
@@ -380,7 +385,7 @@ export function apply(ctx, baseConfig) {
380
385
  })
381
386
  }
382
387
  } catch { /* modern DSH handles settings via SettingsForms */ }
383
- sctx.effect(() => () => { getConfig = readEntryConfig; scope = null })
388
+ sctx.effect(() => () => { getConfig = readEntryConfig; scope = null; settingsService = null })
384
389
  triggerAutostart()
385
390
  })
386
391
 
@@ -621,14 +626,17 @@ export function apply(ctx, baseConfig) {
621
626
  const cfg = live()
622
627
  const filePath = String(args.file_path || '').trim()
623
628
  if (!filePath) throw new Error('transcribe_audio: file_path is required')
624
- const info = await stat(filePath).catch(() => null)
629
+ const allowedRoots = getAllowedAudioRoots(cfg)
630
+ const { real } = await validateAudioPath(filePath, allowedRoots)
631
+ const info = await stat(real).catch(() => null)
625
632
  if (!info) throw new Error(`transcribe_audio: file not found: ${filePath}`)
626
633
  if (info.size > cfg.maxFileBytes) {
627
634
  throw new Error(`transcribe_audio: file too large (${info.size} bytes, max ${cfg.maxFileBytes})`)
628
635
  }
629
636
  if (info.size < 44) throw new Error('transcribe_audio: file is empty or too small')
630
- const mime = MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'audio/wav'
631
- const bytes = await readFile(filePath)
637
+ const bytes = await readFile(real)
638
+ const sniffedMime = validateAudioContent(bytes)
639
+ const mime = sniffedMime || MIME_BY_EXT[path.extname(real).toLowerCase()] || 'audio/wav'
632
640
  const modeCfg = { ...cfg.message, language: String(args.language || cfg.message.language) }
633
641
  let raw = await transcribe(modeCfg, bytes, mime, exec.signal)
634
642
  if (raw && raw.text) raw = { ...raw, text: await polishText(raw.text, modeCfg, exec.signal) }
@@ -664,5 +672,14 @@ export function apply(ctx, baseConfig) {
664
672
  },
665
673
  startSensevoice,
666
674
  }), 'dsh-voice: sensevoice installer route')
675
+
676
+ ctx.effect(() => registerConfigRoutes(ctx, {
677
+ NS,
678
+ live,
679
+ getSettingsService: () => settingsService,
680
+ getScope: () => scope,
681
+ validateConfig: (val) => BaseConfig(structuredClone(val)),
682
+ triggerAutostart,
683
+ }), 'dsh-voice: config route')
667
684
  }
668
685
 
@@ -41,6 +41,13 @@ export function findModelFile(dir) {
41
41
  return null
42
42
  }
43
43
 
44
+ export function redactModelPath(filePath) {
45
+ if (!filePath) return ""
46
+ const str = String(filePath).replace(/\\/g, "/")
47
+ const idx = str.indexOf(".dsh/models/")
48
+ if (idx !== -1) return "~/" + str.slice(idx)
49
+ return path.basename(str)
50
+ }
44
51
  export function findTokensFile(dir) {
45
52
  if (!dir || !existsSync(dir)) return null
46
53
  try {
@@ -68,8 +75,9 @@ export async function getSensevoiceStatus(cfg, isAliveFn) {
68
75
  return {
69
76
  installed,
70
77
  running,
71
- modelPath: modelFile || '',
72
- tokensPath: tokensFile || '',
78
+ modelPath: redactModelPath(modelFile),
79
+ tokensPath: redactModelPath(tokensFile),
80
+ path: installed ? '~/.dsh/models/sensevoice' : '',
73
81
  installing: installerState.installing,
74
82
  progress: installerState.progress,
75
83
  step: installerState.step,
@@ -189,6 +197,11 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
189
197
  kind: 'exact',
190
198
  path: '/dsh-voice/sensevoice-installer',
191
199
  handler: async (req, res) => {
200
+ if (!isTrustedCaller(req)) {
201
+ writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
202
+ return
203
+ }
204
+
192
205
  if (req.method === 'GET') {
193
206
  const status = await getSensevoiceStatus(liveConfig(), isAlive)
194
207
  writeJson(res, 200, status)
@@ -196,13 +209,9 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
196
209
  }
197
210
 
198
211
  if (req.method === 'POST') {
199
- if (!isTrustedCaller(req)) {
200
- writeJson(res, 403, { ok: false, error: 'Forbidden: untrusted caller' })
201
- return
202
- }
203
-
212
+ const safeState = { ...installerState, modelPath: redactModelPath(installerState.modelPath) }
204
213
  if (installerState.installing) {
205
- writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: installerState })
214
+ writeJson(res, 409, { ok: false, error: 'Installation already in progress', state: safeState })
206
215
  return
207
216
  }
208
217
 
@@ -211,7 +220,7 @@ export function registerSensevoiceInstaller(ctx, { liveConfig, isAlive, updateCo
211
220
  ctx.logger?.warn?.(`SenseVoice 1-click install error: ${String(err)}`)
212
221
  })
213
222
 
214
- writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: installerState })
223
+ writeJson(res, 202, { ok: true, message: 'SenseVoice installation started', state: safeState })
215
224
  return
216
225
  }
217
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.8.38",
3
+ "version": "0.9.1",
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",