diff --git a/packages/core/src/common/runtime.ts b/packages/core/src/common/runtime.ts index bfaa9737..d1f5e610 100644 --- a/packages/core/src/common/runtime.ts +++ b/packages/core/src/common/runtime.ts @@ -21,26 +21,45 @@ export function loadRuntimeConfig(): RuntimeConfig { fs.mkdirSync(CONFIG_DIR, { recursive: true }); const loaded = tryLoadRuntimeConfig(); + let normalized: RuntimeConfig; if (!loaded) { saveRuntimeConfig(DEFAULT_RUNTIME_CONFIG); - return { ...DEFAULT_RUNTIME_CONFIG }; + normalized = { ...DEFAULT_RUNTIME_CONFIG }; + } else { + normalized = { + webuiPort: normalizePort(loaded.webuiPort ?? 5099, 5099), + hookAutoLoad: normalizeBool(loaded.hookAutoLoad, false), + }; + if ( + normalized.webuiPort !== loaded.webuiPort + || normalized.hookAutoLoad !== loaded.hookAutoLoad + ) { + saveRuntimeConfig(normalized); + } } - const normalized: RuntimeConfig = { - webuiPort: normalizePort(loaded.webuiPort ?? 5099, 5099), - hookAutoLoad: normalizeBool(loaded.hookAutoLoad, false), - }; - - if ( - normalized.webuiPort !== loaded.webuiPort - || normalized.hookAutoLoad !== loaded.hookAutoLoad - ) { - saveRuntimeConfig(normalized); + // SNOWLUMA_WEBUI_PORT overrides the on-disk value at runtime without + // rewriting runtime.json. Lets a trusted launcher (e.g. SnowLumaDesktop) + // pick a free port per-launch while CLI/Docker users continue to drive + // it from runtime.json. + const envPort = envPortOverride(); + if (envPort !== undefined) { + normalized = { ...normalized, webuiPort: envPort }; } return normalized; } +function envPortOverride(): number | undefined { + const raw = process.env.SNOWLUMA_WEBUI_PORT; + if (!raw || typeof raw !== 'string') return undefined; + const n = Number(raw.trim()); + if (!Number.isFinite(n)) return undefined; + const port = Math.trunc(n); + if (port <= 0 || port > 65535) return undefined; + return port; +} + function tryLoadRuntimeConfig(): RuntimeConfig | null { if (!fs.existsSync(RUNTIME_CONFIG_PATH)) return null; diff --git a/packages/core/src/webui/auth.ts b/packages/core/src/webui/auth.ts index b5688f50..79226178 100644 --- a/packages/core/src/webui/auth.ts +++ b/packages/core/src/webui/auth.ts @@ -24,6 +24,16 @@ export function isDevAuthMode(): boolean { return process.env.SNOWLUMA_DEV_MODE === '1'; } +function envBootstrapPassword(): string | null { + const raw = process.env.SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD; + if (!raw || typeof raw !== 'string') return null; + // Minimum sanity: avoid accidental empty / 1-char seeds. Strength + // rules still don't apply because we want trusted launchers to be + // able to pick a high-entropy random secret (e.g. base64(16)). + if (raw.length < 8) return null; + return raw; +} + export interface WebuiAuthState { passwordHash: string; // hex passwordSalt: string; // hex @@ -153,6 +163,27 @@ export class WebuiAuth { backupCorruptConfig(); } } + // SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD lets a trusted launcher seed the + // password on first run instead of having to scrape it from logs. + // Effect: write webui.json with the env-provided password, hashed, + // and mustChangePassword=false. Only honoured when there is NO existing + // webui.json — once persisted, password changes happen via the UI. + const envPassword = envBootstrapPassword(); + if (envPassword !== null) { + const salt = randomBytes(16); + const hash = hashPassword(envPassword, salt); + const now = new Date().toISOString(); + const state: WebuiAuthState = { + passwordHash: hash.toString('hex'), + passwordSalt: salt.toString('hex'), + mustChangePassword: false, + generatedAt: now, + updatedAt: now, + }; + atomicWrite(state); + log.info('webui credentials seeded from SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD'); + return new WebuiAuth(state, null, false); + } const initialPassword = randomBytes(8).toString('hex'); const state = generateInitialState(initialPassword); atomicWrite(state);