Files
hapi/shared/src/settingsFileLock.test.ts
T
28df974edd feat(settings): onboard hub provider credentials for dictation and voice (#1392)
* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* chore: retrigger CI after rebase onto upstream/main

Empty commit — Meta reported no checks on da0c6c258 after tip-forward rebase.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:20:03 +08:00

81 lines
3.2 KiB
TypeScript

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)
})
})