diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index fdc2d63832b..019b16a9a68 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -39,6 +39,7 @@ const { shouldRemoveAppBundle, uninstallArgsForMode } = require('./desktop-uninstall.cjs') +const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./workspace-cwd.cjs') const { authModeFromStatus, buildGatewayWsUrl, @@ -1953,6 +1954,21 @@ function resolveRendererIndex() { return candidates[0] } +// True when `dir` lives inside the packaged app bundle / install tree. +// Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling +// leaked into a release build) often resolve here — e.g. win-unpacked on +// Windows — which is exactly where PR #37536 item 16 said we must NOT run. +function isPackagedInstallPath(dir) { + return isPackagedInstallPathUnderRoots(dir, { + isPackaged: IS_PACKAGED, + installRoots: [ + APP_ROOT, + path.dirname(process.execPath), + resolveRemovableAppPath(process.execPath, process.platform, process.env) + ] + }) +} + function resolveHermesCwd() { // In a packaged build, `process.cwd()` resolves to the install root (e.g. // `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...` @@ -1964,7 +1980,7 @@ function resolveHermesCwd() { const candidates = [ readDefaultProjectDir(), process.env.HERMES_DESKTOP_CWD, - process.env.INIT_CWD, + IS_PACKAGED ? null : process.env.INIT_CWD, IS_PACKAGED ? null : process.cwd(), !IS_PACKAGED ? SOURCE_REPO_ROOT : null, app.getPath('home') @@ -1973,12 +1989,37 @@ function resolveHermesCwd() { for (const candidate of candidates) { if (!candidate) continue const resolved = path.resolve(String(candidate)) + + if (isPackagedInstallPath(resolved)) { + continue + } + if (directoryExists(resolved)) return resolved } return app.getPath('home') } +function sanitizeWorkspaceCwd(cwd) { + const trimmed = typeof cwd === 'string' ? cwd.trim() : '' + + if (!trimmed || isPackagedInstallPath(trimmed)) { + return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) } + } + + try { + const resolved = path.resolve(trimmed) + + if (directoryExists(resolved)) { + return { cwd: resolved, sanitized: false } + } + } catch { + // Fall through to the resolved default. + } + + return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) } +} + // Persisted "Default project directory" — surfaced as a setting in the // renderer (see app/settings/sessions-settings.tsx). Stored as JSON in // userData so it survives self-updates without bleeding into the new @@ -4455,6 +4496,10 @@ async function spawnPoolBackend(profile, entry) { ...process.env, HERMES_HOME, ...backend.env, + // Pin the gateway's tool/terminal cwd to the same directory we chose for + // the child process. Inherited TERMINAL_CWD (or a stale config bridge) + // can still point at the install dir even when spawn cwd is home. + TERMINAL_CWD: hermesCwd, HERMES_DASHBOARD_SESSION_TOKEN: token, // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). @@ -4659,6 +4704,7 @@ async function startHermes() { // can't reliably do that, so we set it inline for every spawn. HERMES_HOME, ...backend.env, + TERMINAL_CWD: hermesCwd, HERMES_DASHBOARD_SESSION_TOKEN: token, // Marks this dashboard backend as desktop-spawned so it runs the cron // scheduler tick loop (the gateway isn't running under the app). @@ -5435,9 +5481,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => { // session spawn (no app restart needed). ipcMain.handle('hermes:setting:defaultProjectDir:get', async () => ({ dir: readDefaultProjectDir(), - defaultLabel: path.join(app.getPath('home'), 'hermes-projects') + defaultLabel: app.getPath('home'), + resolvedCwd: resolveHermesCwd() })) +ipcMain.handle('hermes:workspace:sanitize', async (_event, cwd) => sanitizeWorkspaceCwd(cwd)) + ipcMain.handle('hermes:setting:defaultProjectDir:set', async (_event, dir) => { const next = typeof dir === 'string' && dir.trim() ? dir.trim() : null diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index f45616c20fc..c981d0437b1 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -42,6 +42,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), + sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd), settings: { getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'), setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir), diff --git a/apps/desktop/electron/workspace-cwd.cjs b/apps/desktop/electron/workspace-cwd.cjs new file mode 100644 index 00000000000..2955975b0b0 --- /dev/null +++ b/apps/desktop/electron/workspace-cwd.cjs @@ -0,0 +1,38 @@ +const path = require('node:path') + +/** True when `dir` lives inside a packaged app bundle / install tree. */ +function isPackagedInstallPath(dir, { installRoots, isPackaged }) { + if (!isPackaged || !dir) { + return false + } + + let resolved + + try { + resolved = path.resolve(String(dir)) + } catch { + return false + } + + const roots = new Set( + (installRoots ?? []) + .filter(Boolean) + .map(candidate => path.resolve(String(candidate))) + ) + + for (const root of roots) { + if (resolved === root) { + return true + } + + const rel = path.relative(root, resolved) + + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + return true + } + } + + return false +} + +module.exports = { isPackagedInstallPath } diff --git a/apps/desktop/electron/workspace-cwd.test.cjs b/apps/desktop/electron/workspace-cwd.test.cjs new file mode 100644 index 00000000000..760fb9d08ef --- /dev/null +++ b/apps/desktop/electron/workspace-cwd.test.cjs @@ -0,0 +1,45 @@ +/** + * Tests for electron/workspace-cwd.cjs. + * + * Run with: node --test electron/workspace-cwd.test.cjs + */ + +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const { isPackagedInstallPath } = require('./workspace-cwd.cjs') + +const installRoot = path.resolve('/opt/Hermes') + +test('isPackagedInstallPath returns false when not packaged', () => { + assert.equal( + isPackagedInstallPath(installRoot, { isPackaged: false, installRoots: [installRoot] }), + false + ) +}) + +test('isPackagedInstallPath flags the install root itself', () => { + assert.equal( + isPackagedInstallPath(installRoot, { isPackaged: true, installRoots: [installRoot] }), + true + ) +}) + +test('isPackagedInstallPath flags paths nested under the install root', () => { + const nested = path.join(installRoot, 'resources', 'app.asar') + + assert.equal( + isPackagedInstallPath(nested, { isPackaged: true, installRoots: [installRoot] }), + true + ) +}) + +test('isPackagedInstallPath ignores paths outside the install root', () => { + const homeProject = path.resolve('/home/user/projects/demo') + + assert.equal( + isPackagedInstallPath(homeProject, { isPackaged: true, installRoots: [installRoot] }), + false + ) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 30945ecd0a6..734709ec72b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,7 +35,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-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.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", "type-check": "tsc -b", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index b9bfbf021e9..5634a13e2a0 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -29,6 +29,7 @@ import { $connection, $sessions, $workingSessionIds, + ensureDefaultWorkspaceCwd, setConnection, setSessionsLoading } from '@/store/session' @@ -351,6 +352,7 @@ export function useGatewayBoot({ message: translateNow('boot.steps.loadingSettings'), progress: 97 }) + await ensureDefaultWorkspaceCwd() await callbacksRef.current.refreshHermesConfig() if (cancelled) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index ca39d778537..c3e22ca6b4b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -20,6 +20,7 @@ import { $sessions, $yoloActive, getRememberedWorkspaceCwd, + workspaceCwdForNewSession, sessionPinId, setActiveSessionId, setAwaitingResponse, @@ -311,8 +312,9 @@ export function useSessionActions({ }) setSessionStartedAt(null) setTurnStartedAt(null) - // New chats inherit the current workspace. - setCurrentCwd(getRememberedWorkspaceCwd()) + // New chats start in the configured default project dir when set, + // otherwise the sticky last-used workspace (PR #37586). + setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') clearComposerDraft() clearComposerAttachments() @@ -333,7 +335,7 @@ export function useSessionActions({ // Route the new chat to the chosen profile's backend (null = primary, // so single-profile users are unaffected). await ensureGatewayProfile($newChatProfile.get()) - const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd() + const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() // Pass the owning profile so a new chat under a non-launch profile (global // remote mode) builds its agent + persists against THAT profile's home/db. const newChatProfile = $newChatProfile.get() diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index e37c9d7896a..2e043ff0ef3 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -8,7 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons' import { notify, notifyError } from '@/store/notifications' -import { setSessions } from '@/store/session' +import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives' @@ -196,6 +196,7 @@ function DefaultProjectDirSetting() { setDir(result.dir) setFallback(result.defaultLabel) + applyConfiguredDefaultProjectDir(result.dir) }) return () => { @@ -221,7 +222,8 @@ function DefaultProjectDirSetting() { const result = await settings.setDefaultProjectDir(picked.dir) setDir(result.dir) - notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated }) + applyConfiguredDefaultProjectDir(result.dir) + notify({ durationMs: 4_000, kind: 'success', message: s.defaultDirUpdated }) } catch (err) { notifyError(err, s.updateDirFailed) } finally { @@ -241,6 +243,8 @@ function DefaultProjectDirSetting() { try { await settings.setDefaultProjectDir(null) setDir(null) + applyConfiguredDefaultProjectDir(null) + await ensureDefaultWorkspaceCwd() } catch (err) { notifyError(err, s.clearDirFailed) } finally { @@ -268,7 +272,7 @@ function DefaultProjectDirSetting() { )} } - description={dir || s.defaultsTo(fallback || '~/hermes-projects')} + description={dir || s.defaultsTo(fallback || '~')} title={dir ? dir : s.notSet} /> diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 5a7db905f07..04b22cc2e63 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -55,8 +55,9 @@ declare global { setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise fetchLinkTitle: (url: string) => Promise + sanitizeWorkspaceCwd: (cwd?: null | string) => Promise<{ cwd: string; sanitized: boolean }> settings: { - getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string }> + getDefaultProjectDir: () => Promise<{ defaultLabel: string; dir: null | string; resolvedCwd: string }> pickDefaultProjectDir: () => Promise<{ canceled: boolean; dir: null | string }> setDefaultProjectDir: (dir: null | string) => Promise<{ dir: null | string }> } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1915591d5c0..619402ae50a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -519,7 +519,7 @@ export const en: Translations = { defaultDirTitle: 'Default project directory', defaultDirDesc: 'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.', - defaultDirUpdated: 'Default project directory updated', + defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect', defaultsTo: label => `Defaults to ${label}.`, change: 'Change', choose: 'Choose', diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index 7aa8ae20d8a..deb4833868f 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -3,13 +3,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo } from '@/types/hermes' import { + $activeSessionId, $attentionSessionIds, + $currentCwd, $workingSessionIds, + applyConfiguredDefaultProjectDir, getRecentlySettledSessionIds, mergeSessionPage, sessionPinId, setSessionAttention, - setSessionWorking + setSessionWorking, + workspaceCwdForNewSession } from './session' const session = (over: Partial): SessionInfo => ({ @@ -138,6 +142,43 @@ describe('mergeSessionPage', () => { }) }) +describe('workspaceCwdForNewSession', () => { + afterEach(() => { + applyConfiguredDefaultProjectDir(null) + $currentCwd.set('') + $activeSessionId.set(null) + window.localStorage.removeItem('hermes.desktop.workspace-cwd') + }) + + it('prefers the configured default over the sticky remembered workspace', () => { + window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky') + applyConfiguredDefaultProjectDir('/home/user/configured') + + expect(workspaceCwdForNewSession()).toBe('/home/user/configured') + }) + + it('falls back to the remembered workspace when no configured default is set', () => { + window.localStorage.setItem('hermes.desktop.workspace-cwd', '/home/user/sticky') + + expect(workspaceCwdForNewSession()).toBe('/home/user/sticky') + }) + + it('falls back to the live cwd when neither configured nor remembered values exist', () => { + $currentCwd.set('/home/user/live') + + expect(workspaceCwdForNewSession()).toBe('/home/user/live') + }) + + it('does not rewrite the live cwd while a session is active', () => { + $activeSessionId.set('sess-1') + $currentCwd.set('/live/session/path') + applyConfiguredDefaultProjectDir('/home/user/configured') + + expect($currentCwd.get()).toBe('/live/session/path') + expect(workspaceCwdForNewSession()).toBe('/home/user/configured') + }) +}) + describe('getRecentlySettledSessionIds', () => { afterEach(() => { vi.useRealTimers() diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 7fb616be711..6df96946bf1 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -10,8 +10,71 @@ type Updater = T | ((current: T) => T) const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' +// Cached copy of Settings → Sessions → Default project directory. The main +// process persists this in project-dir.json, but the renderer must also honor it +// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir +// wins and new sessions ignore the user's explicit picker choice. +let configuredDefaultProjectDir = '' + export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || '' +export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir + +export async function syncConfiguredDefaultProjectDir(): Promise { + const settings = window.hermesDesktop?.settings?.getDefaultProjectDir + + if (!settings) { + configuredDefaultProjectDir = '' + + return '' + } + + const { dir } = await settings() + configuredDefaultProjectDir = dir?.trim() || '' + + return configuredDefaultProjectDir +} + +/** Align the renderer workspace with the main-process default (home dir when + * packaged, optional Settings override). Clears stale install-dir paths that + * PR #37586's localStorage stickiness can preserve across the #37536 fix. */ +export async function ensureDefaultWorkspaceCwd(): Promise { + const sanitize = window.hermesDesktop?.sanitizeWorkspaceCwd + + if (!sanitize) { + return + } + + await syncConfiguredDefaultProjectDir() + const configured = getConfiguredDefaultProjectDir() + + const seedLiveCwd = (cwd: string) => { + if (cwd && !$activeSessionId.get()) { + setCurrentCwd(cwd) + } + } + + if (configured) { + const { cwd } = await sanitize(configured) + seedLiveCwd(cwd) + + return + } + + const { cwd } = await sanitize(getRememberedWorkspaceCwd()) + seedLiveCwd(cwd) +} + +export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void { + configuredDefaultProjectDir = dir?.trim() || '' + + // Cache only — new chats read this via workspaceCwdForNewSession(). Do not + // rewrite the live workspace (or localStorage) while a session is active. + if (configuredDefaultProjectDir && !$activeSessionId.get()) { + setCurrentCwd(configuredDefaultProjectDir) + } +} + interface AppAtom { get: () => T set: (value: T) => void @@ -171,6 +234,11 @@ export const setCurrentCwd = (next: Updater) => { persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null) } +/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise + * fall back to the sticky last-used folder, then whatever is already live. */ +export const workspaceCwdForNewSession = (): string => + getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim() + export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next) export const setCurrentUsage = (next: Updater) => updateAtom($currentUsage, next) export const setSessionStartedAt = (next: Updater) => updateAtom($sessionStartedAt, next)