mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
* 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>
260 lines
9.0 KiB
TypeScript
260 lines
9.0 KiB
TypeScript
/**
|
|
* Minimal persistence functions for HAPI CLI
|
|
*
|
|
* Handles settings, encryption key, and runner state storage in ~/.hapi/ (or HAPI_HOME override)
|
|
*/
|
|
|
|
import { FileHandle } 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';
|
|
|
|
interface Settings {
|
|
// This ID is used as the actual database ID on the server
|
|
// All machine operations use this ID
|
|
machineId?: string
|
|
machineIdConfirmedByServer?: boolean
|
|
runnerAutoStartWhenRunningHappy?: boolean
|
|
cliApiToken?: string
|
|
// API URL for server connections (priority: env HAPI_API_URL > this > default)
|
|
apiUrl?: string
|
|
// Extra headers for CLI -> hub requests (priority: env HAPI_EXTRA_HEADERS_JSON > this)
|
|
extraHeaders?: unknown
|
|
// Legacy field name (for migration, read-only)
|
|
serverUrl?: string
|
|
}
|
|
|
|
const defaultSettings: Settings = {}
|
|
|
|
/**
|
|
* Runner state persisted locally (different from API RunnerState)
|
|
* This is written to disk by the runner to track its local process state
|
|
*/
|
|
export interface RunnerLocallyPersistedState {
|
|
pid: number;
|
|
httpPort: number;
|
|
startTime: string;
|
|
startedWithCliVersion: string;
|
|
startedWithCliMtimeMs?: number;
|
|
startedWithApiUrl?: string;
|
|
startedWithMachineId?: string;
|
|
startedWithCliApiTokenHash?: string;
|
|
// SHA-256 of canonicalized extra headers. Raw header values must never be persisted here.
|
|
startedWithExtraHeadersHash?: string;
|
|
/**
|
|
* Original process.argv.slice(2) of the runner process at start time, e.g.
|
|
* ['runner', 'start-sync', '--workspace-root', '/home/user/code'].
|
|
* Used by the self-restart handoff so the replacement runner inherits the
|
|
* same workspace-root / flag configuration instead of starting with defaults.
|
|
*/
|
|
startedWithArgv?: string[];
|
|
lastHeartbeat?: string;
|
|
runnerLogPath?: string;
|
|
/**
|
|
* Snapshot of HAPI_DISABLE_VERSION_HANDOFF=1 at the time this runner
|
|
* started. Lets a later `hapi runner start` invocation (from a shell where
|
|
* the env var is NOT set, e.g. operator's interactive terminal vs a
|
|
* systemd service that owns supervision) honour the running runner's
|
|
* opt-out instead of treating mtime drift as a reason to kill it.
|
|
*
|
|
* Codex review #814 [Major]: env-only check in controlClient meant the
|
|
* supervised use case (env set on service only) would still trigger a
|
|
* mid-rebuild stop. Persisting this fixes that.
|
|
*/
|
|
startedWithVersionHandoffDisabled?: boolean;
|
|
}
|
|
|
|
export async function readSettings(): Promise<Settings> {
|
|
if (!existsSync(configuration.settingsFile)) {
|
|
return { ...defaultSettings }
|
|
}
|
|
|
|
try {
|
|
const content = await readFile(configuration.settingsFile, 'utf8')
|
|
return JSON.parse(content)
|
|
} catch {
|
|
return { ...defaultSettings }
|
|
}
|
|
}
|
|
|
|
/** Strict read for locked updates — never treat corrupt/unreadable files as empty. */
|
|
async function readSettingsForUpdate(): Promise<Settings> {
|
|
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<void> {
|
|
if (!existsSync(configuration.happyHomeDir)) {
|
|
await mkdir(configuration.happyHomeDir, { recursive: true, mode: 0o700 })
|
|
}
|
|
await chmod(configuration.happyHomeDir, 0o700).catch(() => {})
|
|
|
|
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(() => {})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Atomically update settings with multi-process safety via file locking
|
|
* @param updater Function that takes current settings and returns updated settings
|
|
* @returns The updated settings
|
|
*/
|
|
export async function updateSettings(
|
|
updater: (current: Settings) => Settings | Promise<Settings>
|
|
): Promise<Settings> {
|
|
if (!existsSync(configuration.happyHomeDir)) {
|
|
await mkdir(configuration.happyHomeDir, { recursive: true, mode: 0o700 })
|
|
}
|
|
await chmod(configuration.happyHomeDir, 0o700).catch(() => {})
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
//
|
|
// Authentication
|
|
//
|
|
|
|
export async function writeCredentialsDataKey(credentials: { publicKey: Uint8Array, machineKey: Uint8Array, token: string }): Promise<void> {
|
|
if (!existsSync(configuration.happyHomeDir)) {
|
|
await mkdir(configuration.happyHomeDir, { recursive: true })
|
|
}
|
|
await writeFile(configuration.privateKeyFile, JSON.stringify({
|
|
encryption: { publicKey: Buffer.from(credentials.publicKey).toString('base64'), machineKey: Buffer.from(credentials.machineKey).toString('base64') },
|
|
token: credentials.token
|
|
}, null, 2));
|
|
}
|
|
|
|
export async function clearCredentials(): Promise<void> {
|
|
if (existsSync(configuration.privateKeyFile)) {
|
|
await unlink(configuration.privateKeyFile);
|
|
}
|
|
}
|
|
|
|
export async function clearMachineId(): Promise<void> {
|
|
await updateSettings(settings => ({
|
|
...settings,
|
|
machineId: undefined
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Read runner state from local file
|
|
*/
|
|
export async function readRunnerState(): Promise<RunnerLocallyPersistedState | null> {
|
|
try {
|
|
if (!existsSync(configuration.runnerStateFile)) {
|
|
return null;
|
|
}
|
|
const content = await readFile(configuration.runnerStateFile, 'utf-8');
|
|
return JSON.parse(content) as RunnerLocallyPersistedState;
|
|
} catch (error) {
|
|
// State corrupted somehow :(
|
|
console.error(`[PERSISTENCE] Runner state file corrupted: ${configuration.runnerStateFile}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write runner state to local file (synchronously for atomic operation)
|
|
*/
|
|
export function writeRunnerState(state: RunnerLocallyPersistedState): void {
|
|
writeFileSync(configuration.runnerStateFile, JSON.stringify(state, null, 2), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Clean up runner state file and lock file
|
|
*/
|
|
export async function clearRunnerState(): Promise<void> {
|
|
if (existsSync(configuration.runnerStateFile)) {
|
|
await unlink(configuration.runnerStateFile);
|
|
}
|
|
// Also clean up lock file if it exists (for stale cleanup)
|
|
if (existsSync(configuration.runnerLockFile)) {
|
|
try {
|
|
await unlink(configuration.runnerLockFile);
|
|
} catch {
|
|
// Lock file might be held by running runner, ignore error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acquire an exclusive lock file for the runner.
|
|
* The lock file proves the runner is running and prevents multiple instances.
|
|
* Returns the file handle to hold for the runner's lifetime, or null if locked.
|
|
*/
|
|
export async function acquireRunnerLock(
|
|
maxAttempts: number = 5,
|
|
delayIncrementMs: number = 200
|
|
): Promise<FileHandle | null> {
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
try {
|
|
// 'wx' ensures we only create if it doesn't exist (atomic lock acquisition)
|
|
const fileHandle = await open(configuration.runnerLockFile, 'wx');
|
|
// Write PID to lock file for debugging
|
|
await fileHandle.writeFile(String(process.pid));
|
|
return fileHandle;
|
|
} catch (error: any) {
|
|
if (error.code === 'EEXIST') {
|
|
// Lock file exists, check if process is still running
|
|
try {
|
|
const lockPid = readFileSync(configuration.runnerLockFile, 'utf-8').trim();
|
|
if (lockPid && !isNaN(Number(lockPid))) {
|
|
if (!isProcessAlive(Number(lockPid))) {
|
|
// Process doesn't exist, remove stale lock
|
|
unlinkSync(configuration.runnerLockFile);
|
|
continue; // Retry acquisition
|
|
}
|
|
}
|
|
} catch {
|
|
// Can't read lock file, might be corrupted
|
|
}
|
|
}
|
|
|
|
if (attempt === maxAttempts) {
|
|
return null;
|
|
}
|
|
const delayMs = attempt * delayIncrementMs;
|
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Release runner lock by closing handle and deleting lock file
|
|
*/
|
|
export async function releaseRunnerLock(lockHandle: FileHandle): Promise<void> {
|
|
try {
|
|
await lockHandle.close();
|
|
} catch { }
|
|
|
|
try {
|
|
if (existsSync(configuration.runnerLockFile)) {
|
|
unlinkSync(configuration.runnerLockFile);
|
|
}
|
|
} catch { }
|
|
}
|