Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 77 additions & 6 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
Comment on lines +67 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These helper functions (resolveDataDir, getPreferredEnvFilePath, hasEncryptedCredentials) are duplicated from scripts/bootstrap-env.mjs. While I understand this is due to the CJS/ESM module differences between Electron's main process and the scripts, this duplication creates a maintenance burden. If the logic changes in one place, it must be manually updated in the other.

To avoid this, consider extracting these shared functions into a separate CommonJS file (e.g., scripts/env-helpers.cjs). This new file could be require'd here and also imported into the ESM bootstrap-env.mjs module. This would centralize the logic and improve maintainability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this PR scoped to the startup/key-safety fix. I agree the duplication is a maintenance cost, but extracting a shared helper across the Electron CJS path and the bootstrap ESM path would broaden the runtime/module-loading surface for this PR. I would prefer to handle that as a separate follow-up refactor once the startup regression is merged.


// ── Auto-Updater Configuration ──────────────────────────────
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 });
Comment thread
kfiramar marked this conversation as resolved.
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) {
Expand All @@ -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");
Expand All @@ -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",
"",
Expand All @@ -454,6 +524,7 @@ function startNextServer() {
cwd: NEXT_SERVER_PATH,
env: {
...serverEnv,
DATA_DIR: dataDir,
PORT: String(serverPort),
NODE_ENV: "production",
},
Expand Down
84 changes: 68 additions & 16 deletions scripts/bootstrap-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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) {
Comment thread
kfiramar marked this conversation as resolved.
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) {
Comment thread
kfiramar marked this conversation as resolved.
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 {};
Expand Down Expand Up @@ -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 });
Comment thread
kfiramar marked this conversation as resolved.
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;
Expand All @@ -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;
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/bootstrap-env.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});