diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index dee008ff5d224..8187ae0915a3e 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -38,6 +38,7 @@ import fsp from 'node:fs/promises' import https from 'node:https' import path from 'node:path' +import { scrubDesktopChildEnv } from './scrub-child-env' import { hiddenWindowsChildOptions } from './windows-child-options' const IS_WINDOWS = process.platform === 'win32' @@ -466,12 +467,11 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme fullArgs, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, + env: scrubDesktopChildEnv(process.env, { // Pass HERMES_HOME through so install.ps1 respects the caller's // choice rather than re-computing the default. HERMES_HOME: hermesHome || process.env.HERMES_HOME || '' - } + }) }) ) @@ -564,10 +564,9 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome return new Promise((resolve, reject) => { const child = spawn('bash', [scriptPath, ...args], { stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, + env: scrubDesktopChildEnv(process.env, { HERMES_HOME: hermesHome || process.env.HERMES_HOME || '' - } + }) }) let stdout = '' diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 3856cd2abc222..2f2f4c030817d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -130,6 +130,7 @@ import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import * as remoteLifecycle from './remote-lifecycle' import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' +import { scrubDesktopChildEnv } from './scrub-child-env' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -2712,11 +2713,10 @@ async function applyUpdates(opts = {}) { // `hermes update` will run (the venv shim is locked while we live). const child = spawnUpdaterProcess(updater, updaterArgs, { cwd: HERMES_HOME, - env: { - ...process.env, + env: scrubDesktopChildEnv(process.env, { HERMES_HOME, PATH: pathWithHermesManagedNode(venvBin) - }, + }), detached: true, stdio: 'ignore' }) @@ -2787,11 +2787,10 @@ async function handOffWindowsBootstrapRecovery(reason) { const child = spawnUpdaterProcess(updater, updaterArgs, { cwd: HERMES_HOME, - env: { - ...process.env, + env: scrubDesktopChildEnv(process.env, { HERMES_HOME, PATH: pathWithHermesManagedNode(venvBin) - }, + }), detached: true, stdio: 'ignore' }) @@ -2840,7 +2839,7 @@ function runStreamedUpdate(command, args, { cwd, env, stage }: any = {}) { args, hiddenWindowsChildOptions({ cwd, - env: { ...process.env, ...(env || {}) }, + env: scrubDesktopChildEnv(process.env, env || {}), stdio: ['ignore', 'pipe', 'pipe'] }) ) diff --git a/apps/desktop/electron/scrub-child-env.test.ts b/apps/desktop/electron/scrub-child-env.test.ts new file mode 100644 index 0000000000000..fc130c02ebbd7 --- /dev/null +++ b/apps/desktop/electron/scrub-child-env.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { isCredentialEnvVar, scrubDesktopChildEnv } from './scrub-child-env' + +test('isCredentialEnvVar matches suffix and known names', () => { + assert.equal(isCredentialEnvVar('OPENROUTER_API_KEY'), true) + assert.equal(isCredentialEnvVar('HERMES_DESKTOP_REMOTE_TOKEN'), true) + assert.equal(isCredentialEnvVar('AWS_SECRET_ACCESS_KEY'), true) + assert.equal(isCredentialEnvVar('FAL_KEY'), true) + assert.equal(isCredentialEnvVar('PATH'), false) + assert.equal(isCredentialEnvVar('HERMES_HOME'), false) + assert.equal(isCredentialEnvVar('HERMES_DESKTOP'), false) +}) + +test('scrubDesktopChildEnv drops secrets and keeps operational keys', () => { + const scrubbed = scrubDesktopChildEnv( + { + PATH: '/usr/bin', + HERMES_HOME: '/home/u/.hermes', + OPENROUTER_API_KEY: 'sk-live', + TELEGRAM_BOT_TOKEN: '123:abc', + FAL_KEY: 'fal-secret', + HERMES_DESKTOP_REMOTE_TOKEN: 'remote-secret', + EMPTY: '' + }, + { + HERMES_DESKTOP: '1', + HERMES_DASHBOARD_SESSION_TOKEN: 'minted-session' + } + ) + + assert.equal(scrubbed.PATH, '/usr/bin') + assert.equal(scrubbed.HERMES_HOME, '/home/u/.hermes') + assert.equal(scrubbed.HERMES_DESKTOP, '1') + assert.equal(scrubbed.HERMES_DASHBOARD_SESSION_TOKEN, 'minted-session') + assert.equal(scrubbed.OPENROUTER_API_KEY, undefined) + assert.equal(scrubbed.TELEGRAM_BOT_TOKEN, undefined) + assert.equal(scrubbed.FAL_KEY, undefined) + assert.equal(scrubbed.HERMES_DESKTOP_REMOTE_TOKEN, undefined) +}) diff --git a/apps/desktop/electron/scrub-child-env.ts b/apps/desktop/electron/scrub-child-env.ts new file mode 100644 index 0000000000000..3f93d6b373141 --- /dev/null +++ b/apps/desktop/electron/scrub-child-env.ts @@ -0,0 +1,81 @@ +/** + * Shared credential scrub for Desktop Electron child processes. + * + * Desktop often spreads `{ ...process.env }` into PTY / serve / updater children. + * Provider and messaging secrets belong in HERMES_HOME/.env for the backend, not + * in the parent Electron environment forwarded wholesale to every child. + */ + +const CREDENTIAL_SUFFIXES = Object.freeze([ + '_API_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_CREDENTIALS', + '_ACCESS_KEY', + '_PRIVATE_KEY', + '_OAUTH_TOKEN' +]) + +const CREDENTIAL_NAMES = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_TOKEN', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'CUSTOM_API_KEY', + 'FAL_KEY', + 'GEMINI_BASE_URL', + 'OPENAI_BASE_URL', + 'OPENROUTER_BASE_URL', + 'OLLAMA_BASE_URL', + 'GROQ_BASE_URL', + 'XAI_BASE_URL' +]) + +export function isCredentialEnvVar(name: string): boolean { + if (!name) { + return false + } + + if (CREDENTIAL_NAMES.has(name)) { + return true + } + + return CREDENTIAL_SUFFIXES.some(suffix => name.endsWith(suffix)) +} + +export type EnvMap = Record + +/** + * Copy `source` while dropping credential-shaped keys. Optionally re-apply + * explicit overrides afterwards (e.g. a minted dashboard session token). + */ +export function scrubDesktopChildEnv(source: EnvMap = {}, overrides: EnvMap = {}): Record { + const out: Record = {} + + for (const [key, value] of Object.entries(source || {})) { + if (value == null || value === '') { + continue + } + + if (isCredentialEnvVar(key)) { + continue + } + + out[key] = String(value) + } + + for (const [key, value] of Object.entries(overrides || {})) { + if (value == null || value === '') { + delete out[key] + continue + } + + out[key] = String(value) + } + + return out +} + +export { CREDENTIAL_NAMES, CREDENTIAL_SUFFIXES }