diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c714a46ee46..98b32f0532c 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') +const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') const { worktreesForIpc } = require('./git-worktrees.cjs') @@ -242,6 +243,16 @@ if (INSTALL_STAMP) { function resolveHermesHome() { if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (IS_WINDOWS) { + // A GUI app launched from Explorer inherits the environment block captured + // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in + // process.env even though the CLI (a fresh shell) sees it. Without this the + // backend silently falls back to %LOCALAPPDATA%\hermes and reports "No + // inference provider configured" despite a valid configured home (#45471). + // Consult the live User-scoped registry value before the default below. + const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') + if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + } if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.cjs new file mode 100644 index 00000000000..0ba93d339aa --- /dev/null +++ b/apps/desktop/electron/windows-user-env.cjs @@ -0,0 +1,76 @@ +// windows-user-env.cjs +// +// Read a User-scoped environment variable straight from the Windows registry +// (HKCU\Environment). +// +// A GUI app launched from Explorer inherits the environment block captured at +// login, so a variable set via `setx` AFTER login is invisible in process.env +// even though a fresh shell — and the Hermes CLI — sees it immediately. The +// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot +// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading +// the live registry value closes the gap. See #45471. + +const { execFileSync } = require('node:child_process') + +// Parse the output of `reg query HKCU\Environment /v `, which looks like: +// +// HKEY_CURRENT_USER\Environment +// HERMES_HOME REG_SZ F:\Hermes\data +// +// Returns the raw value string (spaces inside the value preserved), or null when +// the requested value line isn't present. +function parseRegQueryValue(stdout, name) { + if (!stdout || !name) return null + const typePattern = + /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { + const line = rawLine.trim() + const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { + return match[2] + } + } + return null +} + +// Expand %VAR% references against an env map. REG_EXPAND_SZ values store +// unexpanded references; plain REG_SZ paths have none, so this is a no-op for +// the common F:\... case. Unknown references are left verbatim. +function expandWindowsEnvRefs(value, env = process.env) { + if (!value) return value + return value.replace(/%([^%]+)%/g, (whole, name) => { + const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole + }) +} + +// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null +// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero +// (the value doesn't exist), or when the value is empty. +function readWindowsUserEnvVar( + name, + { platform = process.platform, env = process.env, exec = execFileSync } = {} +) { + if (platform !== 'win32' || !name) return null + let stdout + try { + stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { + encoding: 'utf8', + windowsHide: true, + timeout: 5000 + }) + } catch { + // `reg` missing, or value absent (reg exits 1) — caller falls back. + return null + } + const raw = parseRegQueryValue(stdout, name) + if (raw == null) return null + const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null +} + +module.exports = { + expandWindowsEnvRefs, + parseRegQueryValue, + readWindowsUserEnvVar +} diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.cjs new file mode 100644 index 00000000000..dcc71d2c95b --- /dev/null +++ b/apps/desktop/electron/windows-user-env.test.cjs @@ -0,0 +1,90 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { + expandWindowsEnvRefs, + parseRegQueryValue, + readWindowsUserEnvVar +} = require('./windows-user-env.cjs') + +// ── parseRegQueryValue ───────────────────────────────────────────────────── + +test('parseRegQueryValue extracts a REG_SZ value', () => { + const out = [ + '', + 'HKEY_CURRENT_USER\\Environment', + ' HERMES_HOME REG_SZ F:\\Hermes\\data', + '' + ].join('\r\n') + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data') +}) + +test('parseRegQueryValue matches the name case-insensitively', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h') +}) + +test('parseRegQueryValue preserves spaces inside the value', () => { + const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes') +}) + +test('parseRegQueryValue returns null when the value line is absent', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null) +}) + +// ── expandWindowsEnvRefs ─────────────────────────────────────────────────── + +test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => { + assert.equal( + expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), + 'C:\\Users\\jeff\\h' + ) +}) + +test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => { + assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data') + assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x') +}) + +// ── readWindowsUserEnvVar ────────────────────────────────────────────────── + +test('readWindowsUserEnvVar returns null off Windows without spawning', () => { + let spawned = false + const exec = () => { + spawned = true + return '' + } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) + assert.equal(spawned, false) +}) + +test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { + const calls = [] + const exec = (cmd, args) => { + calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' + } + const value = readWindowsUserEnvVar('HERMES_HOME', { + platform: 'win32', + env: { DRIVE: 'F:' }, + exec + }) + assert.equal(value, 'F:\\Hermes') + assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) +}) + +test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => { + const exec = () => { + throw new Error('reg exited 1') + } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) + +test('readWindowsUserEnvVar returns null for an empty value', () => { + const exec = () => ' HERMES_HOME REG_SZ \r\n' + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ebc9293668a..08080188a53 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix",