From 28df974edd2a754ef1e2d7c0608e5501ebf17b99 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:20:03 +0100 Subject: [PATCH] feat(settings): onboard hub provider credentials for dictation and voice (#1392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): onboard hub transcription provider credentials in UI Env-only keys made dictation invisible; Settings can now add/edit/clear hub-side credentials (masked), with env still winning as override. Refs tiann/hapi#1384. Co-authored-by: Cursor * feat(settings): onboard voice-assistant backends alongside dictation Same Settings credential surface now covers ElevenLabs, Gemini Live, and Qwen Realtime (alias env pairs), not only transcription providers. Co-authored-by: Cursor * fix(settings): address PR #1392 Major credential onboard findings Alias env locks, non-destructive Save (omit empty fields), and owner-only settings.json permissions for hub-stored provider secrets. Co-authored-by: Cursor * fix(settings): harden credential onboard for second-pass Majors Owner-namespace gate, stage-then-sync env after persist, and per-field OpenAI-compatible editability under mixed env locks. Co-authored-by: Cursor * fix(settings): serialize settings RMW and clear partial compatible creds Per-file settings lock for concurrent credential PUTs, and Clear shown for partial OpenAI-compatible entries (key/url/model alone). Co-authored-by: Cursor * fix(settings): serialize all settings writers via updateSettings Route credentials, relay auth, generators, server settings, and CLI token persistence through a locked RMW helper; reset Clear form state. Co-authored-by: Cursor * fix(settings): share cross-process settings lock with CLI Extract withSettingsFileLock for hub+CLI, keep owner-only 0o600 rewrites, and race hub credential updates against CLI-style writers. Co-authored-by: Cursor * fix(settings): keep UI secrets out of process.env; PID-own settings locks Settings-backed provider credentials now live in an in-memory overlay (getProviderEnvironment) so tunnel/ACP/Codex children do not inherit them. Settings file locks record pid+token and only reclaim dead or legacy locks. Co-authored-by: Cursor * fix(settings): never reclaim ownerless settings lock sidecars wx creates the lock path before the owner JSON is visible; unlinking null owners let a waiter steal a live acquisition and collide on settings.json.tmp (CI ENOENT). Only reclaim parsed owners with dead PIDs. Co-authored-by: Cursor * fix(settings): reclaim dead locks via rename; clean up failed publishes Stale reclaim renames the sidecar to a unique break path and re-verifies the expected dead owner before deleting it, so a loser cannot unlink a successor's live lock. Failed owner writes unlink the wx sidecar. Reclaim uses a sync owner read so contenders do not all observe one dead owner across an await and race the exclusive create. Co-authored-by: Cursor * fix(settings): reclaim dead locks under exclusive reaper sidecar Stale reclaim now takes a fixed settings.json.lock.reap lock, re-validates pid+token, then unlinks — so a delayed contender cannot move a successor's live lock aside. Also document providerCredentials in settings.schema.json. Co-authored-by: Cursor * fix(settings): fail closed on corrupt CLI settings; backoff busy reaper CLI updateSettings now uses a strict read that rejects invalid JSON instead of treating errors as {}, which could wipe providerCredentials. Settings lock reclaim sleeps when another process holds .reap so retries are not burned synchronously. Co-authored-by: Cursor * fix(settings): publish locks via candidate+link; fix CLI vitest hoist Acquire settings locks by writing a complete candidate then linkSync to the fixed path so a crash cannot leave an empty live sidecar. Fix the CLI persistence regression test to create its temp dir inside vi.hoisted. Co-authored-by: Cursor * fix(settings): replace bespoke lock with proper-lockfile; hide tenant creds UI Codex kept finding crash windows in hand-rolled lock sidecars. Switch the shared settings lock to proper-lockfile's mkdir + mtime lease. Hide the owner-only credentials editor from non-default namespaces on the voice page. Co-authored-by: Cursor * fix(settings): adapt sessionSummaryContract to outcome updateSettings Rebase onto main brought #1376 unique tmp + outcome-shaped writers; wire sessionSummaryContract and the write-failure credential test to match. Co-authored-by: Cursor * chore: retrigger CI after rebase onto upstream/main Empty commit — Meta reported no checks on da0c6c258 after tip-forward rebase. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- bun.lock | 12 + cli/src/persistence.test.ts | 48 ++ cli/src/persistence.ts | 96 ++-- docs/guide/installation.md | 14 +- docs/guide/voice-assistant.md | 4 +- docs/public/schemas/settings.schema.json | 7 + hub/README.md | 6 +- hub/src/config/cliApiToken.ts | 13 +- hub/src/config/generators.ts | 28 +- hub/src/config/providerCredentials.test.ts | 418 ++++++++++++++++++ hub/src/config/providerCredentials.ts | 373 ++++++++++++++++ hub/src/config/serverSettings.ts | 277 ++++++------ hub/src/config/sessionSummaryContract.test.ts | 7 +- hub/src/config/sessionSummaryContract.ts | 15 +- hub/src/config/settings.ts | 129 +++--- hub/src/configuration.ts | 5 + hub/src/tunnel/relayAuth.ts | 47 +- hub/src/web/routes/voice.test.ts | 127 +++++- hub/src/web/routes/voice.ts | 85 +++- hub/src/web/server.ts | 9 +- shared/package.json | 7 +- shared/src/settingsFileLock.test.ts | 80 ++++ shared/src/settingsFileLock.ts | 54 +++ web/src/api/client.test.ts | 36 ++ web/src/api/client.ts | 57 +++ web/src/components/settings/SettingsNav.tsx | 4 +- .../settings/TranscriptionProviderOnboard.tsx | 309 +++++++++++++ web/src/hooks/useVoiceInputPreferences.ts | 35 +- web/src/lib/locales/en.ts | 26 +- web/src/lib/locales/zh-CN.ts | 26 +- web/src/routes/settings/useVoiceSettings.ts | 40 +- web/src/routes/settings/voice.tsx | 50 ++- 32 files changed, 2072 insertions(+), 372 deletions(-) create mode 100644 cli/src/persistence.test.ts create mode 100644 hub/src/config/providerCredentials.test.ts create mode 100644 hub/src/config/providerCredentials.ts create mode 100644 shared/src/settingsFileLock.test.ts create mode 100644 shared/src/settingsFileLock.ts create mode 100644 web/src/components/settings/TranscriptionProviderOnboard.tsx diff --git a/bun.lock b/bun.lock index 873f5c34..cca331ed 100644 --- a/bun.lock +++ b/bun.lock @@ -87,8 +87,12 @@ "name": "@hapi/protocol", "version": "0.1.0", "dependencies": { + "proper-lockfile": "^4.1.2", "zod": "^4.2.1", }, + "devDependencies": { + "@types/proper-lockfile": "^4.1.4", + }, }, "web": { "name": "hapi-web", @@ -1228,6 +1232,8 @@ "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="], + "@types/ps-list": ["@types/ps-list@6.2.1", "", { "dependencies": { "ps-list": "*" } }, "sha512-YGrVUmLaAu89QffaehCcCp5kphlbCf72+vDxnRyP1soC9y7xaatnoB0Fgo/tEGHk8teglcLwY6b4iHcyd/UWFA=="], "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], @@ -1242,6 +1248,8 @@ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], + "@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], @@ -2508,6 +2516,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -2704,6 +2714,8 @@ "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], diff --git a/cli/src/persistence.test.ts b/cli/src/persistence.test.ts new file mode 100644 index 00000000..ad4b9690 --- /dev/null +++ b/cli/src/persistence.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { writeFileSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +const { dir } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { mkdtempSync } = require('node:fs') as typeof import('node:fs') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { tmpdir } = require('node:os') as typeof import('node:os') + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { join: pathJoin } = require('node:path') as typeof import('node:path') + return { dir: mkdtempSync(pathJoin(tmpdir(), 'hapi-cli-settings-')) } +}) + +vi.mock('@/configuration', () => ({ + configuration: { + happyHomeDir: dir, + settingsFile: join(dir, 'settings.json'), + privateKeyFile: join(dir, 'access.key'), + runnerStateFile: join(dir, 'runner.state.json'), + runnerLockFile: join(dir, 'runner.state.json.lock'), + logsDir: join(dir, 'logs'), + }, +})) + +import { updateSettings } from './persistence' + +describe('updateSettings', () => { + afterEach(() => { + try { + rmSync(join(dir, 'settings.json'), { force: true }) + rmSync(join(dir, 'settings.json.lock'), { force: true }) + } catch { + // ignore + } + }) + + it('rejects corrupt settings.json and leaves the original bytes intact', async () => { + const settingsFile = join(dir, 'settings.json') + writeFileSync(settingsFile, '{not-json') + + await expect( + updateSettings((current) => ({ ...current, apiUrl: 'http://should-not-write' })) + ).rejects.toThrow() + + expect(readFileSync(settingsFile, 'utf8')).toBe('{not-json') + }) +}) diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 6b4113a9..a09b291b 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -5,8 +5,9 @@ */ import { FileHandle } from 'node:fs/promises' -import { readFile, writeFile, mkdir, open, unlink, rename, stat } from 'node:fs/promises' +import { readFile, writeFile, mkdir, open, unlink, rename, chmod } from 'node:fs/promises' import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs' +import { withSettingsFileLock } from '@hapi/protocol/settingsFileLock' import { configuration } from '@/configuration' import { isProcessAlive } from '@/utils/process'; @@ -78,12 +79,32 @@ export async function readSettings(): Promise { } } +/** Strict read for locked updates — never treat corrupt/unreadable files as empty. */ +async function readSettingsForUpdate(): Promise { + if (!existsSync(configuration.settingsFile)) { + return { ...defaultSettings } + } + const content = await readFile(configuration.settingsFile, 'utf8') + const parsed: unknown = JSON.parse(content) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid settings file: ${configuration.settingsFile}`) + } + return parsed as Settings +} + export async function writeSettings(settings: Settings): Promise { if (!existsSync(configuration.happyHomeDir)) { - await mkdir(configuration.happyHomeDir, { recursive: true }) + await mkdir(configuration.happyHomeDir, { recursive: true, mode: 0o700 }) } + await chmod(configuration.happyHomeDir, 0o700).catch(() => {}) - await writeFile(configuration.settingsFile, JSON.stringify(settings, null, 2)) + await withSettingsFileLock(configuration.settingsFile, async () => { + const tmpFile = configuration.settingsFile + '.tmp' + await writeFile(tmpFile, JSON.stringify(settings, null, 2), { mode: 0o600 }) + await chmod(tmpFile, 0o600).catch(() => {}) + await rename(tmpFile, configuration.settingsFile) + await chmod(configuration.settingsFile, 0o600).catch(() => {}) + }) } /** @@ -94,66 +115,21 @@ export async function writeSettings(settings: Settings): Promise { export async function updateSettings( updater: (current: Settings) => Settings | Promise ): Promise { - // Timing constants - const LOCK_RETRY_INTERVAL_MS = 100; // How long to wait between lock attempts - const MAX_LOCK_ATTEMPTS = 50; // Maximum number of attempts (5 seconds total) - const STALE_LOCK_TIMEOUT_MS = 10000; // Consider lock stale after 10 seconds - if (!existsSync(configuration.happyHomeDir)) { - await mkdir(configuration.happyHomeDir, { recursive: true }); + await mkdir(configuration.happyHomeDir, { recursive: true, mode: 0o700 }) } + await chmod(configuration.happyHomeDir, 0o700).catch(() => {}) - const lockFile = configuration.settingsFile + '.lock'; - const tmpFile = configuration.settingsFile + '.tmp'; - let fileHandle; - let attempts = 0; - - // Acquire exclusive lock with retries - while (attempts < MAX_LOCK_ATTEMPTS) { - try { - // 'wx' = create exclusively, fail if exists (cross-platform compatible) - fileHandle = await open(lockFile, 'wx'); - break; - } catch (err: any) { - if (err.code === 'EEXIST') { - // Lock file exists, wait and retry - attempts++; - await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)); - - // Check for stale lock - try { - const stats = await stat(lockFile); - if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) { - await unlink(lockFile).catch(() => { }); - } - } catch { } - } else { - throw err; - } - } - } - - if (!fileHandle) { - throw new Error(`Failed to acquire settings lock after ${MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS / 1000} seconds`); - } - - try { - // Read current settings with defaults - const current = await readSettings() || { ...defaultSettings }; - - // Apply update - const updated = await updater(current); - - // Write atomically using rename - await writeFile(tmpFile, JSON.stringify(updated, null, 2)); - await rename(tmpFile, configuration.settingsFile); // Atomic on POSIX - - return updated; - } finally { - // Release lock - await fileHandle.close(); - await unlink(lockFile).catch(() => { }); // Remove lock file - } + return withSettingsFileLock(configuration.settingsFile, async () => { + const current = await readSettingsForUpdate() + const updated = await updater(current) + const tmpFile = configuration.settingsFile + '.tmp' + await writeFile(tmpFile, JSON.stringify(updated, null, 2), { mode: 0o600 }) + await chmod(tmpFile, 0o600).catch(() => {}) + await rename(tmpFile, configuration.settingsFile) + await chmod(configuration.settingsFile, 0o600).catch(() => {}) + return updated + }) } // diff --git a/docs/guide/installation.md b/docs/guide/installation.md index dd948084..e208dc31 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -196,14 +196,14 @@ On first run, HAPI: | `HAPI_HOME` | `~/.hapi` | - | Config directory path | | `DB_PATH` | `~/.hapi/hapi.db` | - | Database file path | | `HAPI_EXPERIMENTAL` | - | - | CLI: enable experimental features (`true`/`1`/`yes`) | -| `ELEVENLABS_API_KEY` | - | - | ElevenLabs API key for voice | +| `ELEVENLABS_API_KEY` | - | Settings / env | ElevenLabs API key for voice + dictation | | `ELEVENLABS_AGENT_ID` | Auto-created | - | Custom ElevenLabs agent ID | -| `OPENAI_API_KEY` | - | - | OpenAI API key for dictation (`gpt-transcribe` / `gpt-live-transcribe`) | -| `DEEPGRAM_API_KEY` | - | - | Deepgram API key for dictation (`nova-3`) | -| `GROQ_API_KEY` | - | - | Groq API key for dictation (`whisper-large-v3`) | -| `TRANSCRIPTION_BASE_URL` | - | - | OpenAI-compatible/local transcription base URL | -| `TRANSCRIPTION_MODEL` | - | - | Model for the OpenAI-compatible transcription endpoint | -| `TRANSCRIPTION_API_KEY` | - | - | Optional bearer token for that endpoint | +| `OPENAI_API_KEY` | - | Settings / env | OpenAI API key for dictation (`gpt-transcribe` / `gpt-live-transcribe`) | +| `DEEPGRAM_API_KEY` | - | Settings / env | Deepgram API key for dictation (`nova-3`) | +| `GROQ_API_KEY` | - | Settings / env | Groq API key for dictation (`whisper-large-v3`) | +| `TRANSCRIPTION_BASE_URL` | - | Settings / env | OpenAI-compatible/local transcription base URL | +| `TRANSCRIPTION_MODEL` | - | Settings / env | Model for the OpenAI-compatible transcription endpoint | +| `TRANSCRIPTION_API_KEY` | - | Settings / env | Optional bearer token for that endpoint |
diff --git a/docs/guide/voice-assistant.md b/docs/guide/voice-assistant.md index e21fa4de..6690e679 100644 --- a/docs/guide/voice-assistant.md +++ b/docs/guide/voice-assistant.md @@ -4,7 +4,7 @@ Control your AI coding agent with your voice. The built-in voice assistant suppo For speech-to-text without a spoken assistant, open **Settings → Voice**, choose **Dictation**, then select a configured provider. Dictation records until you tap the microphone again, inserts the transcript into the composer, and never sends it automatically. Standard mode is the default. Realtime mode shows a live transcript while you speak and inserts the final result when you stop. -Provider credentials are read only from the hub's startup environment: +Dictation and voice-assistant provider credentials can be added in **Settings → Voice** (saved on the hub, masked in the UI). Environment variables still win when set at process start, and remain the preferred ops/bootstrap path: ```bash # Voice assistant backends (any of these enables the assistant) @@ -27,7 +27,7 @@ export TRANSCRIPTION_MODEL="Systran/faster-whisper-large-v3" export TRANSCRIPTION_API_KEY="..." # optional ``` -Restart the hub after changing credentials. API keys are not entered or stored in the web app. +Settings-managed keys apply immediately (no hub restart). Restart is only required when you change process environment variables outside the UI. API keys are never returned in full to the browser. Realtime OpenAI, ElevenLabs, and Deepgram dictation sessions receive only short-lived credentials minted by the hub. Gemini Live and Qwen Realtime assistant sessions connect through hub-side WebSocket proxies, so those API keys never reach the browser either. Eligible desktop browsers with the on-device `SpeechRecognition` API expose **Browser on-device** as a realtime-only dictation provider. HAPI checks the selected language pack when dictation starts and never falls back from that option to browser-hosted recognition. Mobile and unknown browser environments fail closed because this API is experimental and some Android WebViews expose unsafe partial implementations. ## Overview diff --git a/docs/public/schemas/settings.schema.json b/docs/public/schemas/settings.schema.json index 9c46732f..6fbde231 100644 --- a/docs/public/schemas/settings.schema.json +++ b/docs/public/schemas/settings.schema.json @@ -92,6 +92,13 @@ "runnerAutoStartWhenRunningHappy": { "type": "boolean", "description": "Auto-start runner when running hapi command." + }, + "providerCredentials": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Hub-managed voice and dictation provider credentials; process environment variables win over settings-backed values." } }, "additionalProperties": false diff --git a/hub/README.md b/hub/README.md index facb4eea..6c82b5ea 100644 --- a/hub/README.md +++ b/hub/README.md @@ -26,8 +26,12 @@ See `src/configuration.ts` for all options. ### Optional (Voice) -- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice assistant. +Dictation and voice-assistant provider keys can also be added from **Settings → Voice** (stored in `settings.json` under `providerCredentials`; env vars still win when set at process start). + +- `ELEVENLABS_API_KEY` - ElevenLabs API key for voice assistant + dictation. - `ELEVENLABS_AGENT_ID` - Custom ElevenLabs agent ID (auto-created if not set). +- `GEMINI_API_KEY` / `GOOGLE_API_KEY` - Gemini Live voice assistant. +- `DASHSCOPE_API_KEY` / `QWEN_API_KEY` - Qwen Realtime voice assistant. - `OPENAI_API_KEY` - OpenAI dictation (`gpt-transcribe` / `gpt-live-transcribe`). - `DEEPGRAM_API_KEY` - Deepgram dictation (`nova-3`, standard and realtime). - `GROQ_API_KEY` - Groq dictation (`whisper-large-v3`). diff --git a/hub/src/config/cliApiToken.ts b/hub/src/config/cliApiToken.ts index 95e8d856..8b073cf7 100644 --- a/hub/src/config/cliApiToken.ts +++ b/hub/src/config/cliApiToken.ts @@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto' import { getOrCreateSettingsValue } from './generators' -import { getSettingsFile, readSettings, writeSettings } from './settings' +import { getSettingsFile, readSettings, updateSettings } from './settings' export interface CliApiTokenResult { token: string @@ -71,8 +71,15 @@ export async function getOrCreateCliApiToken(dataDir: string): Promise { + if (current.cliApiToken) { + return { settings: current, result: undefined, write: false } + } + return { + settings: { ...current, cliApiToken: token }, + result: undefined, + } + }) } return { token, source: 'env', isNew: false, filePath: settingsFile } diff --git a/hub/src/config/generators.ts b/hub/src/config/generators.ts index 60352dcc..521e0afa 100644 --- a/hub/src/config/generators.ts +++ b/hub/src/config/generators.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { readSettingsOrThrow, writeSettings, type Settings } from './settings' +import { updateSettings, type Settings, type SettingsUpdateOutcome } from './settings' export type GetOrCreateResult = { value: T @@ -19,19 +19,23 @@ export async function getOrCreateSettingsValue(options: { writeValue: (settings: Settings, value: T) => void generate: () => T }): Promise> { - const settings = await readSettingsOrThrow(options.settingsFile) - const existing = options.readValue(settings) - if (existing) { - if (existing.writeBack) { - await writeSettings(options.settingsFile, settings) + return updateSettings(options.settingsFile, (settings): SettingsUpdateOutcome> => { + const existing = options.readValue(settings) + if (existing) { + return { + settings, + result: { value: existing.value, created: false }, + write: Boolean(existing.writeBack), + } } - return { value: existing.value, created: false } - } - const generated = options.generate() - options.writeValue(settings, generated) - await writeSettings(options.settingsFile, settings) - return { value: generated, created: true } + const generated = options.generate() + options.writeValue(settings, generated) + return { + settings, + result: { value: generated, created: true }, + } + }) } export async function getOrCreateJsonFile(options: { diff --git a/hub/src/config/providerCredentials.test.ts b/hub/src/config/providerCredentials.test.ts new file mode 100644 index 00000000..b90df55c --- /dev/null +++ b/hub/src/config/providerCredentials.test.ts @@ -0,0 +1,418 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + applyProviderCredentialsFromSettings, + getProviderEnvironment, + getTranscriptionCredentialStatus, + maskSecret, + resetProviderCredentialEnvLocksForTests, + updateTranscriptionCredentials, +} from './providerCredentials' + +const MANAGED_KEYS = [ + 'OPENAI_API_KEY', + 'ELEVENLABS_API_KEY', + 'DEEPGRAM_API_KEY', + 'GROQ_API_KEY', + 'TRANSCRIPTION_BASE_URL', + 'TRANSCRIPTION_MODEL', + 'TRANSCRIPTION_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'DASHSCOPE_API_KEY', + 'QWEN_API_KEY', +] as const + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), 'hapi-provider-creds-')) +} + +describe('providerCredentials', () => { + let dir: string | null = null + const previous = new Map() + + beforeEach(() => { + for (const key of MANAGED_KEYS) { + previous.set(key, process.env[key]) + delete process.env[key] + } + resetProviderCredentialEnvLocksForTests() + }) + + afterEach(() => { + for (const key of MANAGED_KEYS) { + const value = previous.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + resetProviderCredentialEnvLocksForTests() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + dir = null + } + }) + + it('masks secrets with last four characters only', () => { + expect(maskSecret('sk-abcdefghij')).toBe('••••ghij') + expect(maskSecret('ab')).toBe('••••') + }) + + it('exposes settings-backed keys via getProviderEnvironment without mutating process.env', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { + OPENAI_API_KEY: 'settings-openai-key', + TRANSCRIPTION_BASE_URL: 'http://127.0.0.1:8000/v1', + TRANSCRIPTION_MODEL: 'local-whisper', + } + })) + + await applyProviderCredentialsFromSettings(dir) + + expect(getProviderEnvironment().OPENAI_API_KEY).toBe('settings-openai-key') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(getProviderEnvironment().TRANSCRIPTION_BASE_URL).toBe('http://127.0.0.1:8000/v1') + expect(getProviderEnvironment().TRANSCRIPTION_MODEL).toBe('local-whisper') + expect(process.env.TRANSCRIPTION_BASE_URL).toBeUndefined() + expect(process.env.TRANSCRIPTION_MODEL).toBeUndefined() + }) + + it('does not override env-provided keys with settings values', async () => { + dir = makeTempDir() + process.env.OPENAI_API_KEY = 'env-openai-key' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { OPENAI_API_KEY: 'settings-openai-key' } + })) + + await applyProviderCredentialsFromSettings(dir) + + expect(process.env.OPENAI_API_KEY).toBe('env-openai-key') + const status = await getTranscriptionCredentialStatus(dir) + expect(status.openai).toEqual({ + configured: true, + source: 'env', + hint: '••••-key', + editable: false, + }) + }) + + it('persists UI updates to settings and applies them live', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + const status = await updateTranscriptionCredentials(dir, { + openai: 'ui-openai-secret', + openaiCompatible: { + baseUrl: 'http://127.0.0.1:9000/v1', + model: 'whisper-large', + apiKey: 'local-token', + } + }) + + expect(status.openai.configured).toBe(true) + expect(status.openai.source).toBe('settings') + expect(status.openai.hint).toBe('••••cret') + expect(status.openai.editable).toBe(true) + expect(status.openaiCompatible.baseUrl).toBe('http://127.0.0.1:9000/v1') + expect(status.openaiCompatible.model).toBe('whisper-large') + expect(status.openaiCompatible.apiKey.configured).toBe(true) + expect(getProviderEnvironment().OPENAI_API_KEY).toBe('ui-openai-secret') + expect(getProviderEnvironment().TRANSCRIPTION_BASE_URL).toBe('http://127.0.0.1:9000/v1') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.TRANSCRIPTION_BASE_URL).toBeUndefined() + + const saved = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) as { + providerCredentials: Record + } + expect(saved.providerCredentials.OPENAI_API_KEY).toBe('ui-openai-secret') + expect(saved.providerCredentials.TRANSCRIPTION_API_KEY).toBe('local-token') + }) + + it('clears settings-backed keys and refuses to clear env-locked keys', async () => { + dir = makeTempDir() + process.env.ELEVENLABS_API_KEY = 'env-eleven' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { OPENAI_API_KEY: 'settings-openai' } + })) + await applyProviderCredentialsFromSettings(dir) + + await updateTranscriptionCredentials(dir, { openai: null }) + expect(getProviderEnvironment().OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + + await expect(updateTranscriptionCredentials(dir, { elevenlabs: null })).rejects.toThrow( + /environment variable/ + ) + expect(process.env.ELEVENLABS_API_KEY).toBe('env-eleven') + }) + + it('persists Gemini and Qwen voice backend keys and discovers them live', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + const status = await updateTranscriptionCredentials(dir, { + geminiLive: 'gemini-ui-key', + qwenRealtime: 'dashscope-ui-key', + }) + + expect(status.voiceBackends.geminiLive).toEqual({ + configured: true, + source: 'settings', + hint: '••••-key', + editable: true, + }) + expect(status.voiceBackends.qwenRealtime.configured).toBe(true) + expect(getProviderEnvironment().GEMINI_API_KEY).toBe('gemini-ui-key') + expect(getProviderEnvironment().DASHSCOPE_API_KEY).toBe('dashscope-ui-key') + expect(process.env.GEMINI_API_KEY).toBeUndefined() + expect(process.env.DASHSCOPE_API_KEY).toBeUndefined() + expect(process.env.GOOGLE_API_KEY).toBeUndefined() + expect(process.env.QWEN_API_KEY).toBeUndefined() + }) + + it('refuses to overwrite GOOGLE_API_KEY-locked Gemini via Settings', async () => { + dir = makeTempDir() + process.env.GOOGLE_API_KEY = 'env-google' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + await expect(updateTranscriptionCredentials(dir, { geminiLive: 'ui' })).rejects.toThrow( + /environment variable/ + ) + expect(process.env.GOOGLE_API_KEY).toBe('env-google') + }) + + it('does not let settings GEMINI_API_KEY shadow env GOOGLE_API_KEY on apply', async () => { + dir = makeTempDir() + process.env.GOOGLE_API_KEY = 'env-google' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { GEMINI_API_KEY: 'settings-gemini' } + })) + + await applyProviderCredentialsFromSettings(dir) + + expect(process.env.GOOGLE_API_KEY).toBe('env-google') + expect(process.env.GEMINI_API_KEY).toBeUndefined() + expect(getProviderEnvironment().GEMINI_API_KEY).toBeUndefined() + expect(getProviderEnvironment().GOOGLE_API_KEY).toBe('env-google') + const status = await getTranscriptionCredentialStatus(dir) + expect(status.voiceBackends.geminiLive).toEqual({ + configured: true, + source: 'env', + hint: '••••ogle', + editable: false, + }) + }) + + it('does not let settings QWEN_API_KEY shadow env DASHSCOPE_API_KEY on apply', async () => { + dir = makeTempDir() + process.env.DASHSCOPE_API_KEY = 'env-dashscope' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { QWEN_API_KEY: 'settings-qwen' } + })) + + await applyProviderCredentialsFromSettings(dir) + + expect(process.env.DASHSCOPE_API_KEY).toBe('env-dashscope') + expect(process.env.QWEN_API_KEY).toBeUndefined() + expect(getProviderEnvironment().QWEN_API_KEY).toBeUndefined() + expect(getProviderEnvironment().DASHSCOPE_API_KEY).toBe('env-dashscope') + }) + + it('omits undefined credential fields instead of clearing them', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + await updateTranscriptionCredentials(dir, { + openai: 'keep-me-secret', + openaiCompatible: { + baseUrl: 'http://127.0.0.1:8000/v1', + model: 'whisper-large', + apiKey: 'local-token', + }, + }) + + await updateTranscriptionCredentials(dir, { + openai: undefined, + openaiCompatible: { + baseUrl: undefined, + model: undefined, + apiKey: 'rotated-token', + }, + }) + + expect(getProviderEnvironment().OPENAI_API_KEY).toBe('keep-me-secret') + expect(getProviderEnvironment().TRANSCRIPTION_BASE_URL).toBe('http://127.0.0.1:8000/v1') + expect(getProviderEnvironment().TRANSCRIPTION_MODEL).toBe('whisper-large') + expect(getProviderEnvironment().TRANSCRIPTION_API_KEY).toBe('rotated-token') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.TRANSCRIPTION_API_KEY).toBeUndefined() + }) + + it('leaves process.env unchanged when a later field in the same update is env-locked', async () => { + dir = makeTempDir() + process.env.ELEVENLABS_API_KEY = 'env-eleven' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + await expect(updateTranscriptionCredentials(dir, { + openai: 'should-not-apply', + elevenlabs: 'also-blocked', + })).rejects.toThrow(/environment variable/) + + expect(getProviderEnvironment().OPENAI_API_KEY).toBeUndefined() + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.ELEVENLABS_API_KEY).toBe('env-eleven') + }) + + it('does not sync overlay when settings write fails before afterCommit', async () => { + dir = makeTempDir() + const settingsFile = join(dir, 'settings.json') + writeFileSync(settingsFile, JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + // Unique tmp paths (#1376) mean occupying settings.json.tmp no longer + // blocks writes. Sabotage rename instead: after the locked read, turn + // settings.json into a directory so rename(tmp → settings) fails and + // afterCommit (overlay sync) must not run. + const { updateSettings } = await import('./settings') + const { unlinkSync, mkdirSync } = await import('node:fs') + let afterCommitRan = false + await expect( + updateSettings(settingsFile, (current) => { + unlinkSync(settingsFile) + mkdirSync(settingsFile) + writeFileSync(join(settingsFile, 'blocker'), 'x') + return { + settings: { + ...current, + providerCredentials: { OPENAI_API_KEY: 'disk-full-secret' }, + }, + result: undefined, + afterCommit: () => { + afterCommitRan = true + }, + } + }) + ).rejects.toThrow() + + expect(afterCommitRan).toBe(false) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(getProviderEnvironment().OPENAI_API_KEY).toBeUndefined() + }) + + it('reports OpenAI-compatible fields independently editable under mixed env locks', async () => { + dir = makeTempDir() + process.env.TRANSCRIPTION_API_KEY = 'env-api-key' + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { + TRANSCRIPTION_BASE_URL: 'http://127.0.0.1:8000/v1', + TRANSCRIPTION_MODEL: 'whisper-large', + } + })) + await applyProviderCredentialsFromSettings(dir) + const status = await getTranscriptionCredentialStatus(dir) + expect(status.openaiCompatible.apiKey.editable).toBe(false) + expect(status.openaiCompatible.baseUrlEditable).toBe(true) + expect(status.openaiCompatible.modelEditable).toBe(true) + }) + + it('serializes concurrent disjoint credential updates without losing either', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + await Promise.all([ + updateTranscriptionCredentials(dir, { openai: 'concurrent-openai' }), + updateTranscriptionCredentials(dir, { groq: 'concurrent-groq' }), + ]) + + expect(getProviderEnvironment().OPENAI_API_KEY).toBe('concurrent-openai') + expect(getProviderEnvironment().GROQ_API_KEY).toBe('concurrent-groq') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + expect(process.env.GROQ_API_KEY).toBeUndefined() + const saved = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) as { + providerCredentials: Record + } + expect(saved.providerCredentials.OPENAI_API_KEY).toBe('concurrent-openai') + expect(saved.providerCredentials.GROQ_API_KEY).toBe('concurrent-groq') + }) + + it('preserves credentials when raced with relay-key persistence', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + const { updateSettings, getSettingsFile } = await import('./settings') + const settingsFile = getSettingsFile(dir) + + await Promise.all([ + updateTranscriptionCredentials(dir, { openai: 'race-openai' }), + updateSettings(settingsFile, (settings) => ({ + settings: { ...settings, relayAuthKey: 'race-relay' }, + result: undefined, + })), + ]) + + const saved = JSON.parse(readFileSync(settingsFile, 'utf8')) as { + providerCredentials?: Record + relayAuthKey?: string + } + expect(saved.providerCredentials?.OPENAI_API_KEY).toBe('race-openai') + expect(saved.relayAuthKey).toBe('race-relay') + expect(getProviderEnvironment().OPENAI_API_KEY).toBe('race-openai') + expect(process.env.OPENAI_API_KEY).toBeUndefined() + }) + + it('preserves hub credentials when raced with CLI-style settings lock writer', async () => { + if (process.platform === 'win32') return + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ machineId: 'keep-me' })) + await applyProviderCredentialsFromSettings(dir) + const { updateSettings, getSettingsFile, readSettings } = await import('./settings') + const { withSettingsFileLock } = await import('@hapi/protocol/settingsFileLock') + const { writeFile, chmod, rename, stat } = await import('node:fs/promises') + const settingsFile = getSettingsFile(dir) + + await Promise.all([ + updateTranscriptionCredentials(dir, { openai: 'hub-cli-race' }), + withSettingsFileLock(settingsFile, async () => { + const current = (await readSettings(settingsFile)) ?? {} + const updated = { ...current, apiUrl: 'http://cli-writer.example' } + const tmpFile = `${settingsFile}.tmp` + await writeFile(tmpFile, JSON.stringify(updated, null, 2), { mode: 0o600 }) + await chmod(tmpFile, 0o600) + await rename(tmpFile, settingsFile) + await chmod(settingsFile, 0o600) + }), + ]) + + const saved = JSON.parse(readFileSync(settingsFile, 'utf8')) as { + providerCredentials?: Record + machineId?: string + apiUrl?: string + } + expect(saved.machineId).toBe('keep-me') + expect(saved.apiUrl).toBe('http://cli-writer.example') + expect(saved.providerCredentials?.OPENAI_API_KEY).toBe('hub-cli-race') + expect((await stat(settingsFile)).mode & 0o777).toBe(0o600) + }) + + it('keeps partial OpenAI-compatible values queryable for clear UX', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + await updateTranscriptionCredentials(dir, { + openaiCompatible: { apiKey: 'partial-only-key' }, + }) + const status = await getTranscriptionCredentialStatus(dir) + expect(status.openaiCompatible.configured).toBe(false) + expect(status.openaiCompatible.apiKey.configured).toBe(true) + expect(status.openaiCompatible.apiKey.hint).toBe('••••-key') + expect(status.openaiCompatible.baseUrl).toBeNull() + expect(status.openaiCompatible.model).toBeNull() + }) +}) diff --git a/hub/src/config/providerCredentials.ts b/hub/src/config/providerCredentials.ts new file mode 100644 index 00000000..192f58f6 --- /dev/null +++ b/hub/src/config/providerCredentials.ts @@ -0,0 +1,373 @@ +/** + * Hub-side provider credentials for dictation + voice-assistant backends. + * + * Priority: process env (ops bootstrap) > settings.json providerCredentials. + * Env-locked keys cannot be overwritten or cleared from the Settings UI. + * Settings-backed values live in an in-memory overlay exposed via + * `getProviderEnvironment()` — they are never copied into `process.env`. + */ + +import { getSettingsFile, readSettings, updateSettings, type Settings } from './settings' + +export const PROVIDER_CREDENTIAL_ENV_KEYS = [ + 'OPENAI_API_KEY', + 'ELEVENLABS_API_KEY', + 'DEEPGRAM_API_KEY', + 'GROQ_API_KEY', + 'TRANSCRIPTION_BASE_URL', + 'TRANSCRIPTION_MODEL', + 'TRANSCRIPTION_API_KEY', + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'DASHSCOPE_API_KEY', + 'QWEN_API_KEY', +] as const + +/** @deprecated use PROVIDER_CREDENTIAL_ENV_KEYS */ +export const TRANSCRIPTION_CREDENTIAL_ENV_KEYS = PROVIDER_CREDENTIAL_ENV_KEYS + +export type ProviderCredentialEnvKey = (typeof PROVIDER_CREDENTIAL_ENV_KEYS)[number] +export type TranscriptionCredentialEnvKey = ProviderCredentialEnvKey + +export type ProviderCredentialSource = 'env' | 'settings' | 'none' + +export interface MaskedCredentialStatus { + configured: boolean + source: ProviderCredentialSource + hint: string | null + editable: boolean +} + +export interface OpenAICompatibleCredentialStatus { + configured: boolean + source: ProviderCredentialSource + baseUrl: string | null + model: string | null + baseUrlEditable: boolean + modelEditable: boolean + apiKey: MaskedCredentialStatus +} + +export interface TranscriptionCredentialStatus { + openai: MaskedCredentialStatus + elevenlabs: MaskedCredentialStatus + deepgram: MaskedCredentialStatus + groq: MaskedCredentialStatus + openaiCompatible: OpenAICompatibleCredentialStatus + voiceBackends: { + elevenlabs: MaskedCredentialStatus + geminiLive: MaskedCredentialStatus + qwenRealtime: MaskedCredentialStatus + } +} + +export interface TranscriptionCredentialsUpdate { + openai?: string | null + elevenlabs?: string | null + deepgram?: string | null + groq?: string | null + openaiCompatible?: { + baseUrl?: string | null + model?: string | null + apiKey?: string | null + } + geminiLive?: string | null + qwenRealtime?: string | null +} + +export type ProviderCredentialsMap = Partial> + +let envLockedKeys = new Set() + +export function resetProviderCredentialEnvLocksForTests(): void { + envLockedKeys = new Set() + settingsBackedCredentials = {} +} + +export function maskSecret(value: string): string { + const trimmed = value.trim() + if (trimmed.length <= 4) return '••••' + return `••••${trimmed.slice(-4)}` +} + +let settingsBackedCredentials: ProviderCredentialsMap = {} + +function setSettingsBackedCredentials(stored: ProviderCredentialsMap): void { + const next: ProviderCredentialsMap = {} + for (const key of PROVIDER_CREDENTIAL_ENV_KEYS) { + if (isLogicallyEnvLocked(key)) continue + const value = stored[key] + if (value) next[key] = value + } + settingsBackedCredentials = next +} + +/** + * Effective provider credential environment for voice/dictation paths only. + * Settings-backed secrets stay out of `process.env` so unrelated child processes + * (tunnel, Cursor ACP, Codex helpers) do not inherit them. + */ +export function getProviderEnvironment( + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + return { ...env, ...settingsBackedCredentials } +} + +function snapshotEnvLocks(env: NodeJS.ProcessEnv = process.env): void { + envLockedKeys = new Set( + PROVIDER_CREDENTIAL_ENV_KEYS.filter((key) => Boolean(env[key]?.trim())) + ) +} + +/** Alias pairs share one logical lock so a saved GEMINI cannot shadow env GOOGLE (and vice versa). */ +function isLogicallyEnvLocked(key: ProviderCredentialEnvKey): boolean { + if (key === 'GEMINI_API_KEY' || key === 'GOOGLE_API_KEY') { + return envLockedKeys.has('GEMINI_API_KEY') || envLockedKeys.has('GOOGLE_API_KEY') + } + if (key === 'DASHSCOPE_API_KEY' || key === 'QWEN_API_KEY') { + return envLockedKeys.has('DASHSCOPE_API_KEY') || envLockedKeys.has('QWEN_API_KEY') + } + return envLockedKeys.has(key) +} + +function readProviderCredentials(settings: Settings | null): ProviderCredentialsMap { + const raw = settings?.providerCredentials + if (!raw || typeof raw !== 'object') return {} + const out: ProviderCredentialsMap = {} + for (const key of PROVIDER_CREDENTIAL_ENV_KEYS) { + const value = raw[key] + if (typeof value === 'string' && value.trim()) { + out[key] = value.trim() + } + } + return out +} + +function statusForKey( + key: ProviderCredentialEnvKey, + stored: ProviderCredentialsMap +): MaskedCredentialStatus { + if (envLockedKeys.has(key)) { + const value = process.env[key]?.trim() ?? '' + return { + configured: Boolean(value), + source: 'env', + hint: value ? maskSecret(value) : null, + editable: false, + } + } + const value = stored[key] ?? process.env[key]?.trim() + if (value) { + return { + configured: true, + source: 'settings', + hint: maskSecret(value), + editable: true, + } + } + return { configured: false, source: 'none', hint: null, editable: true } +} + +/** Gemini accepts GEMINI_API_KEY or GOOGLE_API_KEY; Qwen accepts DASHSCOPE or QWEN. */ +function statusForAliasPair( + primary: ProviderCredentialEnvKey, + secondary: ProviderCredentialEnvKey, + stored: ProviderCredentialsMap +): MaskedCredentialStatus { + const primaryLocked = envLockedKeys.has(primary) + const secondaryLocked = envLockedKeys.has(secondary) + if (primaryLocked || secondaryLocked) { + const value = (process.env[primary] ?? process.env[secondary] ?? '').trim() + return { + configured: Boolean(value), + source: 'env', + hint: value ? maskSecret(value) : null, + editable: false, + } + } + const value = ( + stored[primary] + ?? stored[secondary] + ?? process.env[primary] + ?? process.env[secondary] + ?? '' + ).trim() + if (value) { + return { + configured: true, + source: 'settings', + hint: maskSecret(value), + editable: true, + } + } + return { configured: false, source: 'none', hint: null, editable: true } +} + +function compatibleSource( + baseUrl: MaskedCredentialStatus, + model: MaskedCredentialStatus, + apiKey: MaskedCredentialStatus +): ProviderCredentialSource { + if (baseUrl.source === 'env' || model.source === 'env' || apiKey.source === 'env') return 'env' + if (baseUrl.source === 'settings' || model.source === 'settings' || apiKey.source === 'settings') { + return 'settings' + } + return 'none' +} + +export async function applyProviderCredentialsFromSettings(dataDir: string): Promise { + snapshotEnvLocks() + const settings = await readSettings(getSettingsFile(dataDir)) + if (settings === null) { + setSettingsBackedCredentials({}) + return + } + setSettingsBackedCredentials(readProviderCredentials(settings)) +} + +function buildStatus(stored: ProviderCredentialsMap): TranscriptionCredentialStatus { + const openai = statusForKey('OPENAI_API_KEY', stored) + const elevenlabs = statusForKey('ELEVENLABS_API_KEY', stored) + const deepgram = statusForKey('DEEPGRAM_API_KEY', stored) + const groq = statusForKey('GROQ_API_KEY', stored) + const baseUrl = statusForKey('TRANSCRIPTION_BASE_URL', stored) + const model = statusForKey('TRANSCRIPTION_MODEL', stored) + const apiKey = statusForKey('TRANSCRIPTION_API_KEY', stored) + const env = getProviderEnvironment() + const baseUrlValue = env.TRANSCRIPTION_BASE_URL?.trim() || null + const modelValue = env.TRANSCRIPTION_MODEL?.trim() || null + const geminiLive = statusForAliasPair('GEMINI_API_KEY', 'GOOGLE_API_KEY', stored) + const qwenRealtime = statusForAliasPair('DASHSCOPE_API_KEY', 'QWEN_API_KEY', stored) + return { + openai, + elevenlabs, + deepgram, + groq, + openaiCompatible: { + configured: Boolean(baseUrlValue && modelValue), + source: compatibleSource(baseUrl, model, apiKey), + baseUrl: baseUrlValue, + model: modelValue, + baseUrlEditable: baseUrl.editable, + modelEditable: model.editable, + apiKey, + }, + voiceBackends: { + elevenlabs, + geminiLive, + qwenRealtime, + }, + } +} + +export async function getTranscriptionCredentialStatus( + dataDir: string +): Promise { + const settings = await readSettings(getSettingsFile(dataDir)) + const stored = settings === null ? {} : readProviderCredentials(settings) + return buildStatus(stored) +} + +function normalizeOptionalSecret(value: string | null | undefined): string | null | undefined { + if (value === undefined) return undefined + if (value === null) return null + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +function applyPatchToStored( + stored: ProviderCredentialsMap, + key: ProviderCredentialEnvKey, + value: string | null | undefined +): void { + if (value === undefined) return + if (isLogicallyEnvLocked(key)) { + throw new Error(`${key} is set by an environment variable and cannot be changed from Settings`) + } + if (value === null) { + delete stored[key] + return + } + stored[key] = value +} + +function applyAliasPairPatch( + stored: ProviderCredentialsMap, + primary: ProviderCredentialEnvKey, + secondary: ProviderCredentialEnvKey, + value: string | null | undefined +): void { + if (value === undefined) return + if (isLogicallyEnvLocked(primary) || isLogicallyEnvLocked(secondary)) { + throw new Error( + `${primary} (or ${secondary}) is set by an environment variable and cannot be changed from Settings` + ) + } + if (value === null) { + delete stored[primary] + delete stored[secondary] + return + } + // Canonical primary; drop secondary settings entry so one source of truth + stored[primary] = value + delete stored[secondary] +} + +/** Refresh the in-memory overlay after a successful persist (never touches process.env). */ +function syncSettingsCredentialsOverlay(stored: ProviderCredentialsMap): void { + setSettingsBackedCredentials(stored) +} + +export async function updateTranscriptionCredentials( + dataDir: string, + update: TranscriptionCredentialsUpdate +): Promise { + const settingsFile = getSettingsFile(dataDir) + return updateSettings(settingsFile, async (settings) => { + const nextStored: ProviderCredentialsMap = { ...readProviderCredentials(settings) } + + applyPatchToStored(nextStored, 'OPENAI_API_KEY', normalizeOptionalSecret(update.openai)) + applyPatchToStored(nextStored, 'ELEVENLABS_API_KEY', normalizeOptionalSecret(update.elevenlabs)) + applyPatchToStored(nextStored, 'DEEPGRAM_API_KEY', normalizeOptionalSecret(update.deepgram)) + applyPatchToStored(nextStored, 'GROQ_API_KEY', normalizeOptionalSecret(update.groq)) + + if (update.openaiCompatible) { + applyPatchToStored( + nextStored, + 'TRANSCRIPTION_BASE_URL', + normalizeOptionalSecret(update.openaiCompatible.baseUrl) + ) + applyPatchToStored( + nextStored, + 'TRANSCRIPTION_MODEL', + normalizeOptionalSecret(update.openaiCompatible.model) + ) + applyPatchToStored( + nextStored, + 'TRANSCRIPTION_API_KEY', + normalizeOptionalSecret(update.openaiCompatible.apiKey) + ) + } + + applyAliasPairPatch( + nextStored, + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + normalizeOptionalSecret(update.geminiLive) + ) + applyAliasPairPatch( + nextStored, + 'DASHSCOPE_API_KEY', + 'QWEN_API_KEY', + normalizeOptionalSecret(update.qwenRealtime) + ) + + return { + settings: { ...settings, providerCredentials: nextStored }, + result: nextStored, + afterCommit: () => { + syncSettingsCredentialsOverlay(nextStored) + }, + } + }).then((nextStored) => buildStatus(nextStored)) +} diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index 2057d8a0..a422be83 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -8,7 +8,7 @@ * it will be saved to settings.json for future use */ -import { getSettingsFile, readSettings, writeSettings } from './settings' +import { getSettingsFile, updateSettings } from './settings' const OLD_SETTINGS_FIELDS = ['webappHost', 'webappPort', 'webappUrl'] as const @@ -91,162 +91,155 @@ function rejectOldSettingsFields(settings: object, settingsFile: string): void { */ export async function loadServerSettings(dataDir: string): Promise { const settingsFile = getSettingsFile(dataDir) - const settings = await readSettings(settingsFile) + return updateSettings(settingsFile, (settings) => { + rejectOldSettingsFields(settings, settingsFile) - // If settings file exists but couldn't be parsed, fail fast - if (settings === null) { - throw new Error( - `Cannot read ${settingsFile}. Please fix or remove the file and restart.` - ) - } - rejectOldSettingsFields(settings, settingsFile) - - let needsSave = false - const sources: ServerSettingsResult['sources'] = { - telegramBotToken: 'default', - telegramNotification: 'default', - serverChanSendKey: 'default', - serverChanNotification: 'default', - listenHost: 'default', - listenPort: 'default', - publicUrl: 'default', - corsOrigins: 'default', - } - // telegramBotToken: env > file > null - let telegramBotToken: string | null = null - if (process.env.TELEGRAM_BOT_TOKEN) { - telegramBotToken = process.env.TELEGRAM_BOT_TOKEN - sources.telegramBotToken = 'env' - if (settings.telegramBotToken === undefined) { - settings.telegramBotToken = telegramBotToken - needsSave = true + let needsSave = false + const sources: ServerSettingsResult['sources'] = { + telegramBotToken: 'default', + telegramNotification: 'default', + serverChanSendKey: 'default', + serverChanNotification: 'default', + listenHost: 'default', + listenPort: 'default', + publicUrl: 'default', + corsOrigins: 'default', } - } else if (settings.telegramBotToken !== undefined) { - telegramBotToken = settings.telegramBotToken - sources.telegramBotToken = 'file' - } - - // telegramNotification: env > file > true - let telegramNotification = true - if (process.env.TELEGRAM_NOTIFICATION !== undefined) { - telegramNotification = process.env.TELEGRAM_NOTIFICATION === 'true' - sources.telegramNotification = 'env' - if (settings.telegramNotification === undefined) { - settings.telegramNotification = telegramNotification - needsSave = true + // telegramBotToken: env > file > null + let telegramBotToken: string | null = null + if (process.env.TELEGRAM_BOT_TOKEN) { + telegramBotToken = process.env.TELEGRAM_BOT_TOKEN + sources.telegramBotToken = 'env' + if (settings.telegramBotToken === undefined) { + settings.telegramBotToken = telegramBotToken + needsSave = true + } + } else if (settings.telegramBotToken !== undefined) { + telegramBotToken = settings.telegramBotToken + sources.telegramBotToken = 'file' } - } else if (settings.telegramNotification !== undefined) { - telegramNotification = settings.telegramNotification - sources.telegramNotification = 'file' - } - // serverChanSendKey: env > file > null - let serverChanSendKey: string | null = null - if (process.env.SERVERCHAN_SENDKEY) { - serverChanSendKey = process.env.SERVERCHAN_SENDKEY - sources.serverChanSendKey = 'env' - if (settings.serverChanSendKey === undefined) { - settings.serverChanSendKey = serverChanSendKey - needsSave = true + // telegramNotification: env > file > true + let telegramNotification = true + if (process.env.TELEGRAM_NOTIFICATION !== undefined) { + telegramNotification = process.env.TELEGRAM_NOTIFICATION === 'true' + sources.telegramNotification = 'env' + if (settings.telegramNotification === undefined) { + settings.telegramNotification = telegramNotification + needsSave = true + } + } else if (settings.telegramNotification !== undefined) { + telegramNotification = settings.telegramNotification + sources.telegramNotification = 'file' } - } else if (settings.serverChanSendKey !== undefined) { - serverChanSendKey = settings.serverChanSendKey - sources.serverChanSendKey = 'file' - } - // serverChanNotification: env > file > true - let serverChanNotification = true - if (process.env.SERVERCHAN_NOTIFICATION !== undefined) { - serverChanNotification = process.env.SERVERCHAN_NOTIFICATION === 'true' - sources.serverChanNotification = 'env' - if (settings.serverChanNotification === undefined) { - settings.serverChanNotification = serverChanNotification - needsSave = true + // serverChanSendKey: env > file > null + let serverChanSendKey: string | null = null + if (process.env.SERVERCHAN_SENDKEY) { + serverChanSendKey = process.env.SERVERCHAN_SENDKEY + sources.serverChanSendKey = 'env' + if (settings.serverChanSendKey === undefined) { + settings.serverChanSendKey = serverChanSendKey + needsSave = true + } + } else if (settings.serverChanSendKey !== undefined) { + serverChanSendKey = settings.serverChanSendKey + sources.serverChanSendKey = 'file' } - } else if (settings.serverChanNotification !== undefined) { - serverChanNotification = settings.serverChanNotification - sources.serverChanNotification = 'file' - } - // listenHost: env > file > default - let listenHost = '127.0.0.1' - if (process.env.HAPI_LISTEN_HOST) { - listenHost = process.env.HAPI_LISTEN_HOST - sources.listenHost = 'env' - if (settings.listenHost === undefined) { - settings.listenHost = listenHost - needsSave = true + // serverChanNotification: env > file > true + let serverChanNotification = true + if (process.env.SERVERCHAN_NOTIFICATION !== undefined) { + serverChanNotification = process.env.SERVERCHAN_NOTIFICATION === 'true' + sources.serverChanNotification = 'env' + if (settings.serverChanNotification === undefined) { + settings.serverChanNotification = serverChanNotification + needsSave = true + } + } else if (settings.serverChanNotification !== undefined) { + serverChanNotification = settings.serverChanNotification + sources.serverChanNotification = 'file' } - } else if (settings.listenHost !== undefined) { - listenHost = settings.listenHost - sources.listenHost = 'file' - } - // listenPort: env > file > default - let listenPort = 3006 - if (process.env.HAPI_LISTEN_PORT) { - const parsed = parseInt(process.env.HAPI_LISTEN_PORT, 10) - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error('HAPI_LISTEN_PORT must be a valid port number') + // listenHost: env > file > default + let listenHost = '127.0.0.1' + if (process.env.HAPI_LISTEN_HOST) { + listenHost = process.env.HAPI_LISTEN_HOST + sources.listenHost = 'env' + if (settings.listenHost === undefined) { + settings.listenHost = listenHost + needsSave = true + } + } else if (settings.listenHost !== undefined) { + listenHost = settings.listenHost + sources.listenHost = 'file' } - listenPort = parsed - sources.listenPort = 'env' - if (settings.listenPort === undefined) { - settings.listenPort = listenPort - needsSave = true + + // listenPort: env > file > default + let listenPort = 3006 + if (process.env.HAPI_LISTEN_PORT) { + const parsed = parseInt(process.env.HAPI_LISTEN_PORT, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error('HAPI_LISTEN_PORT must be a valid port number') + } + listenPort = parsed + sources.listenPort = 'env' + if (settings.listenPort === undefined) { + settings.listenPort = listenPort + needsSave = true + } + } else if (settings.listenPort !== undefined) { + listenPort = settings.listenPort + sources.listenPort = 'file' } - } else if (settings.listenPort !== undefined) { - listenPort = settings.listenPort - sources.listenPort = 'file' - } - // publicUrl: env > file > default - let publicUrl = `http://localhost:${listenPort}` - if (process.env.HAPI_PUBLIC_URL) { - publicUrl = process.env.HAPI_PUBLIC_URL - sources.publicUrl = 'env' - if (settings.publicUrl === undefined) { - settings.publicUrl = publicUrl - needsSave = true + // publicUrl: env > file > default + let publicUrl = `http://localhost:${listenPort}` + if (process.env.HAPI_PUBLIC_URL) { + publicUrl = process.env.HAPI_PUBLIC_URL + sources.publicUrl = 'env' + if (settings.publicUrl === undefined) { + settings.publicUrl = publicUrl + needsSave = true + } + } else if (settings.publicUrl !== undefined) { + publicUrl = settings.publicUrl + sources.publicUrl = 'file' } - } else if (settings.publicUrl !== undefined) { - publicUrl = settings.publicUrl - sources.publicUrl = 'file' - } - // corsOrigins: env > file > derived from publicUrl - let corsOrigins: string[] - if (process.env.CORS_ORIGINS) { - corsOrigins = parseCorsOrigins(process.env.CORS_ORIGINS) - sources.corsOrigins = 'env' - if (settings.corsOrigins === undefined) { - settings.corsOrigins = corsOrigins - needsSave = true + // corsOrigins: env > file > derived from publicUrl + let corsOrigins: string[] + if (process.env.CORS_ORIGINS) { + corsOrigins = parseCorsOrigins(process.env.CORS_ORIGINS) + sources.corsOrigins = 'env' + if (settings.corsOrigins === undefined) { + settings.corsOrigins = corsOrigins + needsSave = true + } + } else if (settings.corsOrigins !== undefined) { + corsOrigins = settings.corsOrigins + sources.corsOrigins = 'file' + } else { + corsOrigins = deriveCorsOrigins(publicUrl) } - } else if (settings.corsOrigins !== undefined) { - corsOrigins = settings.corsOrigins - sources.corsOrigins = 'file' - } else { - corsOrigins = deriveCorsOrigins(publicUrl) - } - // Save settings if any new values were added - if (needsSave) { - await writeSettings(settingsFile, settings) - } - - return { - settings: { - telegramBotToken, - telegramNotification, - serverChanSendKey, - serverChanNotification, - listenHost, - listenPort, - publicUrl, - corsOrigins, - }, - sources, - savedToFile: needsSave, - } + return { + settings, + write: needsSave, + result: { + settings: { + telegramBotToken, + telegramNotification, + serverChanSendKey, + serverChanNotification, + listenHost, + listenPort, + publicUrl, + corsOrigins, + }, + sources, + savedToFile: needsSave, + }, + } + }) } diff --git a/hub/src/config/sessionSummaryContract.test.ts b/hub/src/config/sessionSummaryContract.test.ts index 04932761..c20e4d20 100644 --- a/hub/src/config/sessionSummaryContract.test.ts +++ b/hub/src/config/sessionSummaryContract.test.ts @@ -53,8 +53,11 @@ describe('sessionSummaryContract setting', () => { const results = await Promise.all([ writeSessionSummaryContractEnabled(dataDir, true), updateSettings(settingsFile, (current) => ({ - ...current, - publicUrl: 'https://example.test' + settings: { + ...current, + publicUrl: 'https://example.test' + }, + result: undefined })), writeSessionSummaryContractEnabled(dataDir, true) ]) diff --git a/hub/src/config/sessionSummaryContract.ts b/hub/src/config/sessionSummaryContract.ts index d2e68d95..beb4a3d7 100644 --- a/hub/src/config/sessionSummaryContract.ts +++ b/hub/src/config/sessionSummaryContract.ts @@ -23,9 +23,14 @@ export async function writeSessionSummaryContractEnabled( dataDir: string, enabled: boolean ): Promise { - const next = await updateSettings(getSettingsFile(dataDir), (current) => ({ - ...current, - sessionSummaryContract: enabled - })) - return next.sessionSummaryContract === true + return updateSettings(getSettingsFile(dataDir), (current) => { + const settings = { + ...current, + sessionSummaryContract: enabled + } + return { + settings, + result: settings.sessionSummaryContract === true + } + }) } diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 6fc29aed..eaa2384f 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -1,7 +1,8 @@ import { existsSync } from 'node:fs' -import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { dirname, join } from 'node:path' +import { withSettingsFileLock } from '@hapi/protocol/settingsFileLock' export interface Settings { machineId?: string @@ -28,6 +29,11 @@ export interface Settings { * into supported flavor system / developer instructions. Default off. */ sessionSummaryContract?: boolean + /** + * Hub-side provider API keys / endpoints managed from Settings. + * Env vars still win when set at process start (ops override). + */ + providerCredentials?: Partial> } export function getSettingsFile(dataDir: string): string { @@ -62,18 +68,47 @@ export async function readSettingsOrThrow(settingsFile: string): Promise { +/** Per-file in-process promise chain (hub concurrent requests). */ +const settingsUpdateChains = new Map>() + +async function withInProcessSettingsLock( + settingsFile: string, + work: () => Promise +): Promise { + const previous = settingsUpdateChains.get(settingsFile) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(work) + settingsUpdateChains.set(settingsFile, run.then(() => undefined, () => undefined)) + return run +} + +/** + * Serialize against in-process writers and the CLI's cross-process `.lock` file. + */ +export async function withSettingsLock( + settingsFile: string, + work: () => Promise +): Promise { + return withInProcessSettingsLock(settingsFile, () => withSettingsFileLock(settingsFile, work)) +} + +/** + * Atomic write without taking the settings lock. + * Callers must already hold `withSettingsLock` for `settingsFile`. + * Unique temp path + owner-only modes (Codex #1376 / #1392). + */ +async function writeSettingsUnlocked(settingsFile: string, settings: Settings): Promise { const dir = dirname(settingsFile) if (!existsSync(dir)) { await mkdir(dir, { recursive: true, mode: 0o700 }) } + await chmod(dir, 0o700).catch(() => {}) - // Unique temp path so concurrent writers cannot clobber each other's - // staging file before rename (Codex #1376 Major). const tmpFile = join(dir, `.settings.${randomUUID()}.tmp`) try { - await writeFile(tmpFile, JSON.stringify(settings, null, 2)) + await writeFile(tmpFile, JSON.stringify(settings, null, 2), { mode: 0o600 }) + await chmod(tmpFile, 0o600).catch(() => {}) await rename(tmpFile, settingsFile) + await chmod(settingsFile, 0o600).catch(() => {}) } catch (error) { await unlink(tmpFile).catch(() => {}) throw error @@ -82,72 +117,36 @@ async function writeSettingsAtomic(settingsFile: string, settings: Settings): Pr /** * Write settings to file atomically (unique temp file + rename). - * Prefer {@link updateSettings} for read-modify-write so concurrent writers - * serialize via the shared lock file. + * Owner-only modes: settings may hold provider API keys and bot tokens. + * Serialized against every other settings writer for this file. */ export async function writeSettings(settingsFile: string, settings: Settings): Promise { - await writeSettingsAtomic(settingsFile, settings) + return withSettingsLock(settingsFile, () => writeSettingsUnlocked(settingsFile, settings)) +} + +export type SettingsUpdateOutcome = { + settings: Settings + result: T + afterCommit?: () => void + /** When false, skip persisting (lock still held for a consistent read). Default true. */ + write?: boolean } /** - * Atomically update settings with multi-process safety via file locking. - * Lock path matches CLI (`${settingsFile}.lock`) so hub and CLI serialize - * when they share the same ~/.hapi/settings.json. + * Serialized read-modify-write for settings.json. + * Prefer this over unlocked read + `writeSettings` for any runtime mutation. */ -export async function updateSettings( +export async function updateSettings( settingsFile: string, - updater: (current: Settings) => Settings | Promise -): Promise { - const LOCK_RETRY_INTERVAL_MS = 100 - const MAX_LOCK_ATTEMPTS = 50 - const STALE_LOCK_TIMEOUT_MS = 10_000 - - const dir = dirname(settingsFile) - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true, mode: 0o700 }) - } - - const lockFile = `${settingsFile}.lock` - let fileHandle: Awaited> | undefined - let attempts = 0 - - while (attempts < MAX_LOCK_ATTEMPTS) { - try { - // 'wx' = create exclusively, fail if exists (cross-platform) - fileHandle = await open(lockFile, 'wx') - break - } catch (err: unknown) { - const code = err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined - if (code === 'EEXIST') { - attempts++ - await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)) - try { - const stats = await stat(lockFile) - if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) { - await unlink(lockFile).catch(() => {}) - } - } catch { - // ignore stale-check races - } - } else { - throw err - } - } - } - - if (!fileHandle) { - throw new Error( - `Failed to acquire settings lock after ${(MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS) / 1000} seconds` - ) - } - - try { + mutate: (settings: Settings) => SettingsUpdateOutcome | Promise> +): Promise { + return withSettingsLock(settingsFile, async () => { const current = await readSettingsOrThrow(settingsFile) - const updated = await updater(current) - await writeSettingsAtomic(settingsFile, updated) - return updated - } finally { - await fileHandle.close() - await unlink(lockFile).catch(() => {}) - } + const outcome = await mutate(current) + if (outcome.write !== false) { + await writeSettingsUnlocked(settingsFile, outcome.settings) + } + outcome.afterCommit?.() + return outcome.result + }) } diff --git a/hub/src/configuration.ts b/hub/src/configuration.ts index 63742120..79dca35d 100644 --- a/hub/src/configuration.ts +++ b/hub/src/configuration.ts @@ -27,6 +27,7 @@ import { existsSync, mkdirSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { getOrCreateCliApiToken } from './config/cliApiToken' +import { applyProviderCredentialsFromSettings } from './config/providerCredentials' import { getSettingsFile } from './config/settings' import { loadServerSettings, type ServerSettings, type ServerSettingsResult } from './config/serverSettings' @@ -155,6 +156,10 @@ class Configuration { console.log(`[Hub] Configuration saved to ${getSettingsFile(dataDir)}`) } + // 3b. Apply Settings-managed provider credentials into process.env + // (env vars set at process start still win; no restart needed for UI saves) + await applyProviderCredentialsFromSettings(dataDir) + // 4. Create configuration instance const config = new Configuration( dataDir, diff --git a/hub/src/tunnel/relayAuth.ts b/hub/src/tunnel/relayAuth.ts index 069f314b..9ab8cc76 100644 --- a/hub/src/tunnel/relayAuth.ts +++ b/hub/src/tunnel/relayAuth.ts @@ -9,14 +9,14 @@ * tunnel — there is no shared-key fallback. */ -import { readSettings, updateSettings, type Settings } from '../config/settings' +import { readSettings, updateSettings, type Settings, type SettingsUpdateOutcome } from '../config/settings' type FetchRelayAuth = (input: string | URL | Request, init?: RequestInit) => Promise async function issueRelayAuthKey( apiDomain: string, settingsFile: string, - settings: Settings | null, + settingsReadable: boolean, fetchRelayAuth: FetchRelayAuth ): Promise { const resp = await fetchRelayAuth(`https://${apiDomain}/issue`, { @@ -41,11 +41,11 @@ async function issueRelayAuthKey( if (typeof data.key !== 'string' || !data.key) { throw new Error(`Relay at ${apiDomain} returned an invalid key response.`) } - // settings === null means the file exists but is unparseable; don't clobber it - if (settings !== null) { + // settingsReadable === false means the file exists but is unparseable; don't clobber it + if (settingsReadable) { await updateSettings(settingsFile, (current) => ({ - ...current, - relayAuthKey: data.key + settings: { ...current, relayAuthKey: data.key }, + result: undefined, })) } console.log('[Tunnel] Obtained per-hub relay auth key') @@ -67,7 +67,7 @@ export async function resolveRelayAuthKey( return settings.relayAuthKey } - return issueRelayAuthKey(apiDomain, settingsFile, settings, fetchRelayAuth) + return issueRelayAuthKey(apiDomain, settingsFile, settings !== null, fetchRelayAuth) } export async function refreshRejectedRelayAuthKey( @@ -83,19 +83,26 @@ export async function refreshRejectedRelayAuthKey( ) } - const settings = await readSettings(settingsFile) - if (settings === null) { - throw new Error(`Cannot refresh relay auth while ${settingsFile} is unreadable.`) - } - if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) { - return settings.relayAuthKey - } - - const cleared = await updateSettings(settingsFile, (current) => { - const next = { ...current } - delete next.relayAuthKey - return next + const keptOrCleared = await updateSettings(settingsFile, (settings): SettingsUpdateOutcome< + { kind: 'keep'; key: string } | { kind: 'cleared' } + > => { + if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) { + return { + settings, + result: { kind: 'keep', key: settings.relayAuthKey }, + write: false, + } + } + const clearedSettings: Settings = { ...settings } + delete clearedSettings.relayAuthKey + return { + settings: clearedSettings, + result: { kind: 'cleared' }, + } }) + if (keptOrCleared.kind === 'keep') { + return keptOrCleared.key + } console.warn('[Tunnel] Relay auth key rejected; requesting a replacement') - return issueRelayAuthKey(apiDomain, settingsFile, cleared, fetchRelayAuth) + return issueRelayAuthKey(apiDomain, settingsFile, true, fetchRelayAuth) } diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 0969ff70..7b0f5a71 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it, mock, test, afterEach } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Hono } from 'hono' import { SignJWT } from 'jose' import type { WebAppEnv } from '../middleware/auth' import { createAuthMiddleware } from '../middleware/auth' import { createVoiceRoutes } from './voice' +import { resetProviderCredentialEnvLocksForTests } from '../../config/providerCredentials' const JWT_SECRET = new TextEncoder().encode('test-secret') @@ -16,10 +20,10 @@ async function authHeaders() { return { authorization: `Bearer ${token}` } } -function createApp() { +function createApp(dataDir?: string) { const app = new Hono() app.use('*', createAuthMiddleware(JWT_SECRET)) - app.route('/api', createVoiceRoutes()) + app.route('/api', createVoiceRoutes(dataDir ? { dataDir } : {})) return app } @@ -663,3 +667,122 @@ describe('POST /api/voice/qwen-token', () => { expect(body).not.toHaveProperty('apiKey') }) }) + +describe('transcription credentials onboarding', () => { + const managedKeys = [ + 'OPENAI_API_KEY', + 'ELEVENLABS_API_KEY', + 'DEEPGRAM_API_KEY', + 'GROQ_API_KEY', + 'TRANSCRIPTION_BASE_URL', + 'TRANSCRIPTION_MODEL', + 'TRANSCRIPTION_API_KEY', + ] as const + const previous = new Map() + let dir: string | null = null + + afterEach(() => { + for (const key of managedKeys) { + const value = previous.get(key) + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + previous.clear() + resetProviderCredentialEnvLocksForTests() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + dir = null + } + }) + + function clearManagedEnv(): void { + for (const key of managedKeys) { + previous.set(key, process.env[key]) + delete process.env[key] + } + resetProviderCredentialEnvLocksForTests() + } + + test('PUT saves a key, masks GET response, and discovers the provider live', async () => { + clearManagedEnv() + dir = mkdtempSync(join(tmpdir(), 'hapi-voice-creds-')) + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + const app = createApp(dir) + const headers = await authHeaders() + + const put = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ openai: 'sk-live-onboard-secret' }), + }) + expect(put.status).toBe(200) + const putBody = await put.json() as { openai: { configured: boolean; hint: string | null; source: string } } + expect(putBody.openai.configured).toBe(true) + expect(putBody.openai.source).toBe('settings') + expect(putBody.openai.hint).toBe('••••cret') + expect(JSON.stringify(putBody)).not.toContain('sk-live-onboard-secret') + // Settings-backed secrets must stay out of process.env (tunnel/ACP/Codex inherit it). + expect(process.env.OPENAI_API_KEY).toBeUndefined() + + const providers = await app.request('/api/voice/transcription/providers', { headers }) + expect(await providers.json()).toEqual({ + providers: [{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] }], + }) + + const get = await app.request('/api/voice/transcription/credentials', { headers }) + expect(get.status).toBe(200) + const getBody = await get.json() as { openai: { hint: string | null } } + expect(getBody.openai.hint).toBe('••••cret') + expect(JSON.stringify(getBody)).not.toContain('sk-live-onboard-secret') + }) + + test('PUT refuses to overwrite an env-locked key', async () => { + clearManagedEnv() + process.env.OPENAI_API_KEY = 'env-locked-key' + resetProviderCredentialEnvLocksForTests() + // Re-lock as if hub started with this env + const { applyProviderCredentialsFromSettings } = await import('../../config/providerCredentials') + dir = mkdtempSync(join(tmpdir(), 'hapi-voice-creds-env-')) + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + const app = createApp(dir) + const headers = await authHeaders() + const put = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ openai: 'ui-attempt' }), + }) + expect(put.status).toBe(409) + expect(process.env.OPENAI_API_KEY).toBe('env-locked-key') + }) + + test('credentials routes refuse non-owner namespaces', async () => { + clearManagedEnv() + dir = mkdtempSync(join(tmpdir(), 'hapi-voice-creds-ns-')) + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + const app = createApp(dir) + const token = await new SignJWT({ uid: 2, ns: 'other' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(JWT_SECRET) + const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' } + + const get = await app.request('/api/voice/transcription/credentials', { headers }) + expect(get.status).toBe(403) + + const put = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers, + body: JSON.stringify({ openai: 'ns-attack' }), + }) + expect(put.status).toBe(403) + expect(process.env.OPENAI_API_KEY).toBeUndefined() + const { readFileSync } = await import('node:fs') + const saved = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) as { + providerCredentials?: Record + } + expect(saved.providerCredentials?.OPENAI_API_KEY).toBeUndefined() + }) +}) diff --git a/hub/src/web/routes/voice.ts b/hub/src/web/routes/voice.ts index bea263b9..8a0fc967 100644 --- a/hub/src/web/routes/voice.ts +++ b/hub/src/web/routes/voice.ts @@ -17,6 +17,12 @@ import { resolveHubVoiceBackend } from '@hapi/protocol/voice' import type { TranscriptionProvider, VoiceBackendType } from '@hapi/protocol/voice' +import { + getProviderEnvironment, + getTranscriptionCredentialStatus, + updateTranscriptionCredentials, +} from '../../config/providerCredentials' +import { getConfiguration } from '../../configuration' function buildVoiceWsUrl(base: string, pathname: string): string { const url = new URL(base) @@ -77,21 +83,22 @@ function getTranscriptionConfig(provider: TranscriptionProvider): { baseUrl: string model: string } | null { - if (!listConfiguredTranscriptionProviders(process.env).some((candidate) => candidate.id === provider)) return null + const env = getProviderEnvironment() + if (!listConfiguredTranscriptionProviders(env).some((candidate) => candidate.id === provider)) return null switch (provider) { case 'openai': - return { apiKey: process.env.OPENAI_API_KEY!.trim(), baseUrl: 'https://api.openai.com/v1', model: OPENAI_TRANSCRIPTION_MODEL } + return { apiKey: env.OPENAI_API_KEY!.trim(), baseUrl: 'https://api.openai.com/v1', model: OPENAI_TRANSCRIPTION_MODEL } case 'elevenlabs': - return { apiKey: process.env.ELEVENLABS_API_KEY!.trim(), baseUrl: ELEVENLABS_API_BASE, model: ELEVENLABS_TRANSCRIPTION_MODEL } + return { apiKey: env.ELEVENLABS_API_KEY!.trim(), baseUrl: ELEVENLABS_API_BASE, model: ELEVENLABS_TRANSCRIPTION_MODEL } case 'deepgram': - return { apiKey: process.env.DEEPGRAM_API_KEY!.trim(), baseUrl: 'https://api.deepgram.com/v1', model: DEEPGRAM_TRANSCRIPTION_MODEL } + return { apiKey: env.DEEPGRAM_API_KEY!.trim(), baseUrl: 'https://api.deepgram.com/v1', model: DEEPGRAM_TRANSCRIPTION_MODEL } case 'groq': - return { apiKey: process.env.GROQ_API_KEY!.trim(), baseUrl: 'https://api.groq.com/openai/v1', model: GROQ_TRANSCRIPTION_MODEL } + return { apiKey: env.GROQ_API_KEY!.trim(), baseUrl: 'https://api.groq.com/openai/v1', model: GROQ_TRANSCRIPTION_MODEL } case 'openai-compatible': { - const baseUrl = process.env.TRANSCRIPTION_BASE_URL?.trim() - const model = process.env.TRANSCRIPTION_MODEL?.trim() + const baseUrl = env.TRANSCRIPTION_BASE_URL?.trim() + const model = env.TRANSCRIPTION_MODEL?.trim() return baseUrl && model - ? { apiKey: process.env.TRANSCRIPTION_API_KEY?.trim(), baseUrl: trimTrailingSlash(baseUrl), model } + ? { apiKey: env.TRANSCRIPTION_API_KEY?.trim(), baseUrl: trimTrailingSlash(baseUrl), model } : null } case 'browser-local': @@ -449,18 +456,62 @@ async function getOrCreateAgentIdForVoice(apiKey: string, voiceId?: string): Pro return agentId } -export function createVoiceRoutes(): Hono { +const transcriptionCredentialsUpdateSchema = z.object({ + openai: z.string().nullable().optional(), + elevenlabs: z.string().nullable().optional(), + deepgram: z.string().nullable().optional(), + groq: z.string().nullable().optional(), + openaiCompatible: z.object({ + baseUrl: z.string().nullable().optional(), + model: z.string().nullable().optional(), + apiKey: z.string().nullable().optional(), + }).optional(), + geminiLive: z.string().nullable().optional(), + qwenRealtime: z.string().nullable().optional(), +}).strict() + +export function createVoiceRoutes(options: { dataDir?: string } = {}): Hono { const app = new Hono() + const resolveDataDir = () => options.dataDir ?? getConfiguration().dataDir // Hub default backend + all backends with credentials configured app.get('/voice/backend', (c) => { - const backends = listConfiguredVoiceBackends(process.env) - const backend = resolveHubVoiceBackend(process.env) + const env = getProviderEnvironment() + const backends = listConfiguredVoiceBackends(env) + const backend = resolveHubVoiceBackend(env) return c.json({ backend, backends }) }) app.get('/voice/transcription/providers', (c) => { - return c.json({ providers: listConfiguredTranscriptionProviders(process.env) }) + return c.json({ providers: listConfiguredTranscriptionProviders(getProviderEnvironment()) }) + }) + + app.get('/voice/transcription/credentials', async (c) => { + if (c.get('namespace') !== 'default') { + return c.json({ error: 'Provider credentials are only available to the hub owner' }, 403) + } + return c.json(await getTranscriptionCredentialStatus(resolveDataDir())) + }) + + app.put('/voice/transcription/credentials', async (c) => { + if (c.get('namespace') !== 'default') { + return c.json({ error: 'Provider credentials are only available to the hub owner' }, 403) + } + const json = await c.req.json().catch(() => null) + const parsed = transcriptionCredentialsUpdateSchema.safeParse(json) + if (!parsed.success) { + return c.json({ error: 'Invalid transcription credentials payload' }, 400) + } + try { + const status = await updateTranscriptionCredentials(resolveDataDir(), parsed.data) + return c.json(status) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update credentials' + if (message.includes('environment variable')) { + return c.json({ error: message }, 409) + } + return c.json({ error: message }, 500) + } }) app.post('/voice/transcription', bodyLimit({ @@ -508,7 +559,8 @@ export function createVoiceRoutes(): Hono { // Gemini Live API does not support ephemeral tokens, so we proxy the key. // The key is short-lived in the browser session and never persisted client-side. app.post('/voice/gemini-token', async (c) => { - const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + const env = getProviderEnvironment() + const apiKey = env.GEMINI_API_KEY || env.GOOGLE_API_KEY if (!apiKey) { return c.json({ allowed: false, @@ -535,7 +587,8 @@ export function createVoiceRoutes(): Hono { // Check Qwen (DashScope) availability for Qwen Realtime voice sessions // The actual API key is never sent to the browser — it stays server-side in the WS proxy. app.post('/voice/qwen-token', async (c) => { - const apiKey = process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY + const env = getProviderEnvironment() + const apiKey = env.DASHSCOPE_API_KEY || env.QWEN_API_KEY if (!apiKey) { return c.json({ allowed: false, @@ -566,7 +619,7 @@ export function createVoiceRoutes(): Hono { const { customAgentId, customApiKey, voiceId } = parsed.data // Use custom credentials if provided, otherwise fall back to env vars - const apiKey = customApiKey || process.env.ELEVENLABS_API_KEY + const apiKey = customApiKey || getProviderEnvironment().ELEVENLABS_API_KEY const voiceAgentMap = parseVoiceAgentMap() const mappedAgentId = voiceId ? voiceAgentMap[voiceId] : undefined let agentId = customAgentId || mappedAgentId @@ -680,7 +733,7 @@ export function createVoiceRoutes(): Hono { // Get available ElevenLabs voices (includes user's voice clones) app.get('/voice/voices', async (c) => { const requestId = crypto.randomUUID() - const apiKey = process.env.ELEVENLABS_API_KEY + const apiKey = getProviderEnvironment().ELEVENLABS_API_KEY if (!apiKey) { console.warn('[Voice][Voices] Missing API key, returning empty voices list', { requestId }) return c.json({ voices: [] }) diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index a94ac4ef..3af9dcb7 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -8,6 +8,7 @@ import { serveStatic } from 'hono/bun' import { getConfiguration } from '../configuration' import { PROTOCOL_VERSION } from '@hapi/protocol' import { buildGeminiLiveSetupMessage, QWEN_REALTIME_MODEL } from '@hapi/protocol/voice' +import { getProviderEnvironment } from '../config/providerCredentials' import { createQwenProxyWebSocketHandler } from './qwenProxyHandler' import { decodeVoiceSystemPromptParam } from '../voiceSystemPromptParam' import type { SyncEngine } from '../sync/syncEngine' @@ -293,7 +294,7 @@ function createWebApp(options: { })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) app.route('/api', createDevicesRoutes(options.store)) - app.route('/api', createVoiceRoutes()) + app.route('/api', createVoiceRoutes({ dataDir: configuration.dataDir })) // Skip static serving in relay mode, show helpful message on root if (options.relayMode) { @@ -500,7 +501,8 @@ export async function startWebServer(options: { // Gemini Live WebSocket proxy if (url.pathname === '/api/voice/gemini-ws') { - const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + const env = getProviderEnvironment() + const apiKey = env.GEMINI_API_KEY || env.GOOGLE_API_KEY if (!apiKey) { return new Response('Gemini API key not configured', { status: 400 }) } @@ -518,7 +520,8 @@ export async function startWebServer(options: { } // Qwen Realtime WebSocket proxy if (url.pathname === '/api/voice/qwen-ws') { - const apiKey = process.env.DASHSCOPE_API_KEY || process.env.QWEN_API_KEY + const env = getProviderEnvironment() + const apiKey = env.DASHSCOPE_API_KEY || env.QWEN_API_KEY const model = QWEN_REALTIME_MODEL const language = url.searchParams.get('language') ?? undefined const voiceParam = url.searchParams.get('voice')?.trim() || undefined diff --git a/shared/package.json b/shared/package.json index 07c0c8f0..b1e8edfe 100644 --- a/shared/package.json +++ b/shared/package.json @@ -21,13 +21,18 @@ "./voice": "./src/voice.ts", "./voicePickerCatalog": "./src/voicePickerCatalog.ts", "./voice-personality": "./src/voicePersonality.ts", - "./usage": "./src/usage.ts" + "./usage": "./src/usage.ts", + "./settingsFileLock": "./src/settingsFileLock.ts" }, "sideEffects": false, "scripts": { "test": "bun test" }, "dependencies": { + "proper-lockfile": "^4.1.2", "zod": "^4.2.1" + }, + "devDependencies": { + "@types/proper-lockfile": "^4.1.4" } } diff --git a/shared/src/settingsFileLock.test.ts b/shared/src/settingsFileLock.test.ts new file mode 100644 index 00000000..cf05d39e --- /dev/null +++ b/shared/src/settingsFileLock.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test, afterEach } from 'bun:test' +import { mkdtempSync, writeFileSync, existsSync, readFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + setSettingsLockMaxAttemptsForTests, + setSettingsLockStaleMsForTests, + withSettingsFileLock, +} from './settingsFileLock' + +describe('withSettingsFileLock', () => { + afterEach(() => { + setSettingsLockMaxAttemptsForTests(undefined) + setSettingsLockStaleMsForTests(undefined) + }) + + test('serializes concurrent writers without .tmp collisions', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-settings-lock-race-')) + const settingsFile = join(dir, 'settings.json') + writeFileSync(settingsFile, JSON.stringify({ n: 0 })) + const { writeFile, rename, readFile } = await import('node:fs/promises') + + await Promise.all( + Array.from({ length: 8 }, (_, i) => + withSettingsFileLock(settingsFile, async () => { + const current = JSON.parse(await readFile(settingsFile, 'utf8')) as { n: number } + const next = { n: current.n + 1, last: i } + const tmp = `${settingsFile}.tmp` + await writeFile(tmp, JSON.stringify(next)) + await rename(tmp, settingsFile) + }) + ) + ) + + const saved = JSON.parse(readFileSync(settingsFile, 'utf8')) as { n: number } + expect(saved.n).toBe(8) + expect(existsSync(`${settingsFile}.hapi.lock`)).toBe(false) + }) + + test('reclaims a stale lock left by a crashed holder', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-settings-lock-stale-')) + const settingsFile = join(dir, 'settings.json') + writeFileSync(settingsFile, JSON.stringify({ ok: true })) + const lockDir = `${settingsFile}.hapi.lock` + mkdirSync(lockDir) + // proper-lockfile treats mtime older than `stale` as reclaimable. + setSettingsLockStaleMsForTests(5_000) + const past = new Date(Date.now() - 60_000) + const { utimesSync } = await import('node:fs') + utimesSync(lockDir, past, past) + + const result = await withSettingsFileLock(settingsFile, async () => 'recovered') + expect(result).toBe('recovered') + expect(existsSync(lockDir)).toBe(false) + }) + + test('does not allow overlapping critical sections', async () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-settings-lock-excl-')) + const settingsFile = join(dir, 'settings.json') + writeFileSync(settingsFile, '{}') + + let inCritical = 0 + let maxInCritical = 0 + const bump = async () => { + inCritical++ + maxInCritical = Math.max(maxInCritical, inCritical) + await new Promise((r) => setTimeout(r, 40)) + inCritical-- + return 'ok' + } + + const results = await Promise.all([ + withSettingsFileLock(settingsFile, bump), + withSettingsFileLock(settingsFile, bump), + withSettingsFileLock(settingsFile, bump), + ]) + expect(results).toEqual(['ok', 'ok', 'ok']) + expect(maxInCritical).toBe(1) + }) +}) diff --git a/shared/src/settingsFileLock.ts b/shared/src/settingsFileLock.ts new file mode 100644 index 00000000..7a57b107 --- /dev/null +++ b/shared/src/settingsFileLock.ts @@ -0,0 +1,54 @@ +/** + * Cross-process exclusive lock for settings.json (and similar). + * Shared by hub and CLI. Uses proper-lockfile (mkdir + mtime lease) so a + * crash cannot leave an unreclaimable empty sidecar. + */ + +import lockfile from 'proper-lockfile' + +const LOCK_RETRY_INTERVAL_MS = 100 +const MAX_LOCK_ATTEMPTS = 50 +const STALE_MS = 30_000 +const UPDATE_MS = 10_000 + +let maxLockAttemptsForTests: number | undefined +let staleMsForTests: number | undefined + +/** @internal test-only */ +export function setSettingsLockMaxAttemptsForTests(value: number | undefined): void { + maxLockAttemptsForTests = value +} + +/** @internal test-only */ +export function setSettingsLockStaleMsForTests(value: number | undefined): void { + staleMsForTests = value +} + +export async function withSettingsFileLock( + settingsFile: string, + work: () => Promise +): Promise { + const retries = maxLockAttemptsForTests ?? MAX_LOCK_ATTEMPTS + const stale = staleMsForTests ?? STALE_MS + // Distinct from earlier file-shaped `*.lock` sidecars in this PR so a + // leftover empty file cannot block mkdir-based acquisition. + const lockfilePath = `${settingsFile}.hapi.lock` + + const release = await lockfile.lock(settingsFile, { + realpath: false, + lockfilePath, + stale, + update: Math.min(UPDATE_MS, Math.floor(stale / 2)), + retries: { + retries, + minTimeout: LOCK_RETRY_INTERVAL_MS, + maxTimeout: LOCK_RETRY_INTERVAL_MS, + }, + }) + + try { + return await work() + } finally { + await release() + } +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 576770e1..5d3a1e0e 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -183,4 +183,40 @@ describe('ApiClient error mapping', () => { await expect(api.fetchVoiceBackend()).resolves.toEqual({ backend: null, backends: [] }) expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/voice/backend') }) + + it('reads and updates masked transcription credentials', async () => { + const status = { + openai: { configured: true, source: 'settings', hint: '••••cret', editable: true }, + elevenlabs: { configured: false, source: 'none', hint: null, editable: true }, + deepgram: { configured: false, source: 'none', hint: null, editable: true }, + groq: { configured: false, source: 'none', hint: null, editable: true }, + openaiCompatible: { + configured: false, + source: 'none', + baseUrl: null, + model: null, + baseUrlEditable: true, + modelEditable: true, + apiKey: { configured: false, source: 'none', hint: null, editable: true }, + }, + voiceBackends: { + elevenlabs: { configured: false, source: 'none', hint: null, editable: true }, + geminiLive: { configured: false, source: 'none', hint: null, editable: true }, + qwenRealtime: { configured: false, source: 'none', hint: null, editable: true }, + }, + } + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify(status), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(status), { status: 200 })) + + const api = new ApiClient('test-token') + await expect(api.fetchTranscriptionCredentials()).resolves.toEqual(status) + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/voice/transcription/credentials') + + await expect(api.updateTranscriptionCredentials({ openai: 'sk-test' })).resolves.toEqual(status) + const [, init] = fetchMock.mock.calls[1] ?? [] + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/voice/transcription/credentials') + expect(init?.method).toBe('PUT') + expect(init?.body).toBe(JSON.stringify({ openai: 'sk-test' })) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 469b29a7..79bd6fb2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -56,6 +56,50 @@ import type { AgentFlavor, MessageDeliveryMode } from '@hapi/protocol' import type { CancelMessageResponse } from '@hapi/protocol/schemas' import type { TranscriptionMode, TranscriptionProvider, TranscriptionProviderInfo } from '@hapi/protocol/voice' +export type ProviderCredentialSource = 'env' | 'settings' | 'none' + +export interface MaskedCredentialStatus { + configured: boolean + source: ProviderCredentialSource + hint: string | null + editable: boolean +} + +export interface TranscriptionCredentialStatus { + openai: MaskedCredentialStatus + elevenlabs: MaskedCredentialStatus + deepgram: MaskedCredentialStatus + groq: MaskedCredentialStatus + openaiCompatible: { + configured: boolean + source: ProviderCredentialSource + baseUrl: string | null + model: string | null + baseUrlEditable: boolean + modelEditable: boolean + apiKey: MaskedCredentialStatus + } + voiceBackends: { + elevenlabs: MaskedCredentialStatus + geminiLive: MaskedCredentialStatus + qwenRealtime: MaskedCredentialStatus + } +} + +export interface TranscriptionCredentialsUpdate { + openai?: string | null + elevenlabs?: string | null + deepgram?: string | null + groq?: string | null + openaiCompatible?: { + baseUrl?: string | null + model?: string | null + apiKey?: string | null + } + geminiLive?: string | null + qwenRealtime?: string | null +} + type ApiClientOptions = { baseUrl?: string getToken?: () => string | null @@ -1040,6 +1084,19 @@ export class ApiClient { return await this.request('/api/voice/transcription/providers') } + async fetchTranscriptionCredentials(): Promise { + return await this.request('/api/voice/transcription/credentials') + } + + async updateTranscriptionCredentials( + update: TranscriptionCredentialsUpdate + ): Promise { + return await this.request('/api/voice/transcription/credentials', { + method: 'PUT', + body: JSON.stringify(update), + }) + } + async transcribeVoice(options: { file: File provider: TranscriptionProvider diff --git a/web/src/components/settings/SettingsNav.tsx b/web/src/components/settings/SettingsNav.tsx index 2763dd02..5e4fbea5 100644 --- a/web/src/components/settings/SettingsNav.tsx +++ b/web/src/components/settings/SettingsNav.tsx @@ -9,7 +9,7 @@ import { ChevronRightIcon } from './SettingsPrimitives' const OWNER_ONLY_CATEGORIES = new Set(['storage', 'usage']) -function getNamespace(token: string): string | null { +export function getNamespaceFromToken(token: string): string | null { try { const payload = token.split('.')[1] if (!payload) return null @@ -39,7 +39,7 @@ export function SettingsNav(props: { activeId?: string; mobile?: boolean }) { usage: t('settings.usage.summary'), about: `v${__APP_VERSION__}`, } - const visibleCategories = settingsCategories.filter((category) => !OWNER_ONLY_CATEGORIES.has(category.id) || getNamespace(token) === 'default') + const visibleCategories = settingsCategories.filter((category) => !OWNER_ONLY_CATEGORIES.has(category.id) || getNamespaceFromToken(token) === 'default') return (