/** * 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 { 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 { 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, 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 ): Promise { 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 { 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 { if (existsSync(configuration.privateKeyFile)) { await unlink(configuration.privateKeyFile); } } export async function clearMachineId(): Promise { await updateSettings(settings => ({ ...settings, machineId: undefined })); } /** * Read runner state from local file */ export async function readRunnerState(): Promise { 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 { 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 { 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 { try { await lockHandle.close(); } catch { } try { if (existsSync(configuration.runnerLockFile)) { unlinkSync(configuration.runnerLockFile); } } catch { } }