diff --git a/electron/main.js b/electron/main.js index 2387886567..1d3c231728 100644 --- a/electron/main.js +++ b/electron/main.js @@ -64,6 +64,64 @@ let serverPort = 20128; const getServerUrl = () => `http://localhost:${serverPort}`; +function resolveDataDir(overridePath, env = process.env) { + if (overridePath && overridePath.trim()) return path.resolve(overridePath); + + const configured = env.DATA_DIR?.trim(); + if (configured) return path.resolve(configured); + + if (process.platform === "win32") { + const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming"); + return path.join(appData, "omniroute"); + } + + const xdg = env.XDG_CONFIG_HOME?.trim(); + if (xdg) return path.join(path.resolve(xdg), "omniroute"); + + return path.join(require("os").homedir(), ".omniroute"); +} + +function getPreferredEnvFilePath(env = process.env) { + const candidates = []; + + if (env.DATA_DIR?.trim()) { + candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env")); + } + + candidates.push(path.join(resolveDataDir(null, env), ".env")); + candidates.push(path.join(process.cwd(), ".env")); + + return candidates.find((filePath) => fs.existsSync(filePath)) || null; +} + +function hasEncryptedCredentials(dbPath) { + if (!fs.existsSync(dbPath)) return false; + + try { + const Database = require("better-sqlite3"); + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const row = db + .prepare( + `SELECT 1 + FROM provider_connections + WHERE access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR api_key LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%' + LIMIT 1` + ) + .get(); + return !!row; + } finally { + db.close(); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`); + } +} + // ── Auto-Updater Configuration ────────────────────────────── autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; @@ -386,12 +444,10 @@ function startNextServer() { // ── Zero-config bootstrap: auto-generate required secrets ───────────────── // Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs. // This mirrors bootstrap-env.mjs logic synchronously: - // 1. Read persisted secrets from userData/server.env + // 1. Read persisted secrets from the resolved DATA_DIR/server.env // 2. Generate missing secrets with crypto.randomBytes() - // 3. Persist back to userData/server.env for future restarts + // 3. Persist back to DATA_DIR/server.env for future restarts const crypto = require("crypto"); - const userDataDir = app.getPath("userData"); - const serverEnvPath = path.join(userDataDir, "server.env"); // Parse a simple KEY=VALUE file function parseEnvFile(filePath) { @@ -407,8 +463,12 @@ function startNextServer() { return env; } + const preferredEnvPath = getPreferredEnvFilePath(process.env); + const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {}; + const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env }); + const serverEnvPath = path.join(dataDir, "server.env"); const persisted = parseEnvFile(serverEnvPath); - const serverEnv = { ...process.env, ...persisted }; + const serverEnv = { ...persisted, ...preferredEnv, ...process.env }; let changed = false; if (!serverEnv.JWT_SECRET) { @@ -417,6 +477,16 @@ function startNextServer() { console.log("[Electron] ✨ JWT_SECRET auto-generated"); } if (!serverEnv.STORAGE_ENCRYPTION_KEY) { + if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) { + console.error( + `[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join( + dataDir, + "storage.sqlite" + )}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.` + ); + sendToRenderer("server-status", { status: "error", port: serverPort }); + return; + } serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto .randomBytes(32) .toString("hex"); @@ -432,7 +502,7 @@ function startNextServer() { if (changed) { serverEnv.OMNIROUTE_BOOTSTRAPPED = "true"; try { - fs.mkdirSync(userDataDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); const lines = [ "# Auto-generated by OmniRoute bootstrap", "", @@ -454,6 +524,7 @@ function startNextServer() { cwd: NEXT_SERVER_PATH, env: { ...serverEnv, + DATA_DIR: dataDir, PORT: String(serverPort), NODE_ENV: "production", }, diff --git a/scripts/bootstrap-env.mjs b/scripts/bootstrap-env.mjs index dacba0063b..de8bfa790d 100644 --- a/scripts/bootstrap-env.mjs +++ b/scripts/bootstrap-env.mjs @@ -7,22 +7,25 @@ * restarts, Docker volume remounts, and upgrades. * * Works across all deployment modes: - * - npm / CLI: called from run-standalone.mjs and run-next.mjs + * - npm / app runners: called from run-standalone.mjs and run-next.mjs * - Docker: same, secrets persisted in mounted volume - * - Electron: called from main.js startup, persisted in userData + * - Electron: called from main.js startup, persisted in DATA_DIR * * Priority (lowest → highest): * 1. Auto-generated defaults * 2. {DATA_DIR}/server.env (persisted on first boot) - * 3. .env in CWD (user overrides) + * 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env) * 4. process.env (shell / Docker -e flags, highest priority) */ -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; +const require = createRequire(import.meta.url); + // ── OAuth secrets that are optional but warn if missing ───────────────────── const OPTIONAL_OAUTH_SECRETS = [ { key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" }, @@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [ ]; // ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ─────────────────────────── -function resolveDataDir(overridePath) { - if (overridePath) return resolve(overridePath); +function resolveDataDir(overridePath, env = process.env) { + if (overridePath?.trim()) return resolve(overridePath); - const configured = process.env.DATA_DIR?.trim(); + const configured = env.DATA_DIR?.trim(); if (configured) return resolve(configured); if (process.platform === "win32") { - const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); + const appData = env.APPDATA || join(homedir(), "AppData", "Roaming"); return join(appData, "omniroute"); } - const xdg = process.env.XDG_CONFIG_HOME?.trim(); + const xdg = env.XDG_CONFIG_HOME?.trim(); if (xdg) return join(resolve(xdg), "omniroute"); return join(homedir(), ".omniroute"); } +function getPreferredEnvFilePath(env = process.env) { + const candidates = []; + + if (env.DATA_DIR?.trim()) { + candidates.push(join(resolve(env.DATA_DIR.trim()), ".env")); + } + + candidates.push(join(resolveDataDir(null, env), ".env")); + candidates.push(join(process.cwd(), ".env")); + + return candidates.find((filePath) => existsSync(filePath)) ?? null; +} + +function hasEncryptedCredentials(dataDir) { + const dbPath = join(dataDir, "storage.sqlite"); + if (!existsSync(dbPath)) return false; + + try { + const Database = require("better-sqlite3"); + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const row = db + .prepare( + `SELECT 1 + FROM provider_connections + WHERE access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR api_key LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%' + LIMIT 1` + ) + .get(); + return !!row; + } finally { + db.close(); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`); + } +} + // ── Parse a simple KEY=VALUE env file ─────────────────────────────────────── function parseEnvFile(filePath) { if (!existsSync(filePath)) return {}; @@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) { export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) { const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`); - const dataDir = resolveDataDir(dataDirOverride); + const preferredEnvPath = getPreferredEnvFilePath(process.env); + const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {}; + const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env }); const serverEnvPath = join(dataDir, "server.env"); - const dotEnvPath = join(process.cwd(), ".env"); // ── Layer 1: Load persisted server.env ──────────────────────────────────── let persisted = parseEnvFile(serverEnvPath); - // ── Layer 2: Load .env from CWD (user overrides, higher priority) ───────── - const dotEnv = parseEnvFile(dotEnvPath); - - // ── Merge: persisted < .env < process.env ───────────────────────────────── - const merged = { ...persisted, ...dotEnv, ...process.env }; + // ── Layer 2: Load the same preferred .env that the CLI wrapper uses ─────── + // This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`. + const merged = { ...persisted, ...preferredEnv, ...process.env }; // ── Auto-generate required secrets ──────────────────────────────────────── let needsPersist = false; @@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) { } if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) { + if (hasEncryptedCredentials(dataDir)) { + throw new Error( + `Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join( + dataDir, + "storage.sqlite" + )}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.` + ); + } persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex"); merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY; needsPersist = true; diff --git a/tests/unit/bootstrap-env.test.mjs b/tests/unit/bootstrap-env.test.mjs new file mode 100644 index 0000000000..6249c44ade --- /dev/null +++ b/tests/unit/bootstrap-env.test.mjs @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Database from "better-sqlite3"; + +import { bootstrapEnv } from "../../scripts/bootstrap-env.mjs"; + +function withTempEnv(fn) { + const originalCwd = process.cwd(); + const originalEnv = { ...process.env }; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bootstrap-test-")); + const tempCwd = path.join(tempRoot, "cwd"); + const tempHome = path.join(tempRoot, "home"); + + fs.mkdirSync(tempCwd, { recursive: true }); + fs.mkdirSync(tempHome, { recursive: true }); + + delete process.env.DATA_DIR; + delete process.env.XDG_CONFIG_HOME; + delete process.env.APPDATA; + delete process.env.JWT_SECRET; + delete process.env.STORAGE_ENCRYPTION_KEY; + delete process.env.STORAGE_ENCRYPTION_KEY_VERSION; + delete process.env.API_KEY_SECRET; + delete process.env.INITIAL_PASSWORD; + process.env.HOME = tempHome; + process.chdir(tempCwd); + + try { + fn({ tempRoot, tempCwd, tempHome, dataDir: path.join(tempHome, ".omniroute") }); + } finally { + process.chdir(originalCwd); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnv)) { + process.env[key] = value; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => { + withTempEnv(({ dataDir }) => { + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync( + path.join(dataDir, ".env"), + "STORAGE_ENCRYPTION_KEY=from-dot-env\nJWT_SECRET=jwt-from-dot-env\n", + "utf8" + ); + fs.writeFileSync( + path.join(dataDir, "server.env"), + "STORAGE_ENCRYPTION_KEY=from-server-env\nJWT_SECRET=jwt-from-server-env\n", + "utf8" + ); + + const env = bootstrapEnv({ quiet: true }); + + assert.equal(env.STORAGE_ENCRYPTION_KEY, "from-dot-env"); + assert.equal(env.JWT_SECRET, "jwt-from-dot-env"); + }); +}); + +test("bootstrapEnv refuses to generate a new key over encrypted data", () => { + withTempEnv(({ dataDir }) => { + fs.mkdirSync(dataDir, { recursive: true }); + const db = new Database(path.join(dataDir, "storage.sqlite")); + try { + db.exec(` + CREATE TABLE provider_connections ( + id TEXT PRIMARY KEY, + access_token TEXT, + refresh_token TEXT, + api_key TEXT, + id_token TEXT + ); + `); + db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)") + .run("conn-1", "enc:v1:deadbeef:feedface:cafebabe"); + } finally { + db.close(); + } + + assert.throws( + () => bootstrapEnv({ quiet: true }), + /Refusing to auto-generate STORAGE_ENCRYPTION_KEY/ + ); + }); +}); + +test("bootstrapEnv fails closed when existing database cannot be inspected", () => { + withTempEnv(({ dataDir }) => { + fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true }); + + assert.throws( + () => bootstrapEnv({ quiet: true }), + /Unable to inspect existing database/ + ); + }); +}); + +test("bootstrapEnv ignores blank dataDirOverride values", () => { + withTempEnv(({ dataDir }) => { + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8"); + + const env = bootstrapEnv({ dataDirOverride: " ", quiet: true }); + + assert.equal(env.JWT_SECRET, "jwt-from-dot-env"); + }); +});