diff --git a/app/src/components/intelligence/ObsidianVaultSection.test.tsx b/app/src/components/intelligence/ObsidianVaultSection.test.tsx new file mode 100644 index 000000000..c08aba693 --- /dev/null +++ b/app/src/components/intelligence/ObsidianVaultSection.test.tsx @@ -0,0 +1,77 @@ +/** + * Tests for the cross-host guard (#4278) in ObsidianVaultSection: when the + * vault lives on a different-OS core host, "View Vault" must surface guidance + * and disable the local-FS actions instead of firing a doomed deep link. + */ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, type Mock, vi } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import { ObsidianVaultSection } from './ObsidianVaultSection'; + +vi.mock('../../utils/tauriCommands', () => ({ memoryTreeObsidianVaultStatus: vi.fn() })); + +vi.mock('../../utils/tauriCommands/workspacePaths', () => ({ + resolveWorkspaceAbsolutePath: vi.fn().mockResolvedValue('/home/leigh/OHvault'), + revealWorkspacePath: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) })); + +vi.mock('./vaultHostMatch', () => ({ + resolveVaultHostMatch: vi.fn().mockResolvedValue({ local: true }), +})); + +const { memoryTreeObsidianVaultStatus } = + (await import('../../utils/tauriCommands')) as unknown as { memoryTreeObsidianVaultStatus: Mock }; + +const { openUrl } = (await import('../../utils/openUrl')) as unknown as { openUrl: Mock }; + +const { resolveVaultHostMatch } = (await import('./vaultHostMatch')) as unknown as { + resolveVaultHostMatch: Mock; +}; + +const CONTENT_ROOT = '/home/leigh/OHvault'; + +describe(' cross-host (#4278)', () => { + it('surfaces guidance and disables local actions when the vault is on a different-OS core host', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValueOnce({ + registered: true, + config_found: true, + content_root_abs: CONTENT_ROOT, + host_os: 'linux', + }); + resolveVaultHostMatch.mockResolvedValueOnce({ local: false, hostOs: 'linux' }); + + renderWithProviders(); + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + + await waitFor(() => { + expect(screen.getByTestId('obsidian-vault-guidance')).toBeInTheDocument(); + }); + expect(screen.getByTestId('obsidian-vault-guidance')).toHaveTextContent('linux'); + expect(screen.getByTestId('obsidian-reveal')).toBeDisabled(); + expect(screen.getByTestId('obsidian-open-anyway')).toBeDisabled(); + // Registered=true would normally fire the deep link; cross-host must NOT. + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('fires the deep link normally for a same-host registered vault (regression)', async () => { + memoryTreeObsidianVaultStatus.mockResolvedValueOnce({ + registered: true, + config_found: true, + content_root_abs: CONTENT_ROOT, + host_os: 'macos', + }); + resolveVaultHostMatch.mockResolvedValueOnce({ local: true, hostOs: 'macos' }); + + renderWithProviders(); + fireEvent.click(screen.getByTestId('memory-open-in-obsidian')); + + await waitFor(() => { + expect(openUrl).toHaveBeenCalledWith( + 'obsidian://open?path=' + encodeURIComponent(CONTENT_ROOT) + ); + }); + }); +}); diff --git a/app/src/components/intelligence/ObsidianVaultSection.tsx b/app/src/components/intelligence/ObsidianVaultSection.tsx index ce5398ffd..4c3410c9c 100644 --- a/app/src/components/intelligence/ObsidianVaultSection.tsx +++ b/app/src/components/intelligence/ObsidianVaultSection.tsx @@ -31,6 +31,7 @@ import { } from '../../utils/tauriCommands/workspacePaths'; import Button from '../ui/Button'; import { MEMORY_CONTENT_WORKSPACE_PATH } from './memoryWorkspacePaths'; +import { resolveVaultHostMatch } from './vaultHostMatch'; /** localStorage key for the optional Obsidian config-dir override. */ const CONFIG_DIR_KEY = 'openhuman.obsidian.configDir'; @@ -58,6 +59,9 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS const [configFound, setConfigFound] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); const [configDir, setConfigDir] = useState(readConfigDirOverride); + // #4278: set to the core host OS when the vault lives on a different-OS core + // host, so local actions (Reveal / Open in Obsidian) are disabled + explained. + const [crossHostOs, setCrossHostOs] = useState(null); const containerRef = useRef(null); const closePanel = useCallback(() => setExpanded(false), []); @@ -175,10 +179,23 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS const override = configDir.trim(); const status = await memoryTreeObsidianVaultStatus(override || undefined); console.debug( - '[ui-flow][obsidian-vault] status registered=%s config_found=%s', + '[ui-flow][obsidian-vault] status registered=%s config_found=%s host_os=%s', status.registered, - status.config_found + status.config_found, + status.host_os ); + + // #4278: when the vault lives on a core host running a different OS, the + // path is not local — guide instead of firing a doomed deep link / reveal. + const match = await resolveVaultHostMatch(status.host_os); + if (!match.local) { + console.debug('[ui-flow][obsidian-vault] cross-host vault host_os=%s', match.hostOs); + setCrossHostOs(match.hostOs ?? status.host_os ?? ''); + setConfigFound(status.config_found); + setExpanded(true); + return; + } + setCrossHostOs(null); setConfigFound(status.config_found); if (status.registered) { @@ -216,8 +233,9 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS handleViewVault(); }, [configDir, handleViewVault]); - const helpText = - configFound === false + const helpText = crossHostOs + ? `${t('crossHostVault.title')} ${t('crossHostVault.message').replace('{os}', crossHostOs)}` + : configFound === false ? t('workspace.obsidianNotFoundHelp') : t('workspace.vaultNotRegisteredHelp'); @@ -266,15 +284,21 @@ export function ObsidianVaultSection({ contentRootAbs, onToast }: ObsidianVaultS
- diff --git a/app/src/components/intelligence/VaultHealthChecklist.test.tsx b/app/src/components/intelligence/VaultHealthChecklist.test.tsx index a3e7bd20c..f54d2fe24 100644 --- a/app/src/components/intelligence/VaultHealthChecklist.test.tsx +++ b/app/src/components/intelligence/VaultHealthChecklist.test.tsx @@ -12,6 +12,10 @@ vi.mock('../../utils/openUrl', () => ({ revealPath: vi.fn().mockResolvedValue(undefined), })); +vi.mock('./vaultHostMatch', () => ({ + resolveVaultHostMatch: vi.fn().mockResolvedValue({ local: true }), +})); + const { memoryTreeVaultHealthCheck } = (await import('../../utils/tauriCommands')) as unknown as { memoryTreeVaultHealthCheck: Mock; }; @@ -21,6 +25,10 @@ const { openUrl, revealPath } = (await import('../../utils/openUrl')) as unknown revealPath: Mock; }; +const { resolveVaultHostMatch } = (await import('./vaultHostMatch')) as unknown as { + resolveVaultHostMatch: Mock; +}; + function health(overrides: Partial = {}): VaultHealthCheck { return { content_root_abs: '/tmp/workspace/memory_tree/content', @@ -89,4 +97,36 @@ describe('', () => { expect(revealPath).toHaveBeenCalledWith('/tmp/workspace/memory_tree/content'); }); }); + + // #4278: vault on a different-OS core host — surface a banner and disable the + // local-FS actions instead of letting them act on a foreign path. + it('warns and disables local actions when the vault is on a different-OS core host', async () => { + memoryTreeVaultHealthCheck.mockResolvedValueOnce(health({ host_os: 'linux' })); + resolveVaultHostMatch.mockResolvedValueOnce({ local: false, hostOs: 'linux' }); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('vault-health-cross-host')).toBeInTheDocument(); + }); + expect(screen.getByTestId('vault-health-cross-host')).toHaveTextContent('linux'); + expect(screen.getByTestId('vault-health-reveal')).toBeDisabled(); + expect(screen.getByTestId('vault-health-open-obsidian')).toBeDisabled(); + + fireEvent.click(screen.getByTestId('vault-health-reveal')); + fireEvent.click(screen.getByTestId('vault-health-open-obsidian')); + expect(revealPath).not.toHaveBeenCalled(); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('keeps local actions enabled for a same-host vault (regression)', async () => { + memoryTreeVaultHealthCheck.mockResolvedValueOnce(health({ host_os: 'macos' })); + resolveVaultHostMatch.mockResolvedValueOnce({ local: true, hostOs: 'macos' }); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId('vault-health-reveal')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('vault-health-cross-host')).not.toBeInTheDocument(); + expect(screen.getByTestId('vault-health-reveal')).not.toBeDisabled(); + }); }); diff --git a/app/src/components/intelligence/VaultHealthChecklist.tsx b/app/src/components/intelligence/VaultHealthChecklist.tsx index 4877a6d3c..c59274367 100644 --- a/app/src/components/intelligence/VaultHealthChecklist.tsx +++ b/app/src/components/intelligence/VaultHealthChecklist.tsx @@ -5,6 +5,7 @@ import type { ToastNotification } from '../../types/intelligence'; import { openUrl, revealPath } from '../../utils/openUrl'; import { memoryTreeVaultHealthCheck, type VaultHealthCheck } from '../../utils/tauriCommands'; import Button from '../ui/Button'; +import { resolveVaultHostMatch, type VaultHostMatch } from './vaultHostMatch'; const OBSIDIAN_DOWNLOAD_URL = 'https://obsidian.md/download'; @@ -41,6 +42,7 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro const { t } = useT(); const resolvedTitle = title ?? t('vaultHealth.title'); const [health, setHealth] = useState(null); + const [hostMatch, setHostMatch] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); @@ -50,6 +52,7 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro try { const next = await memoryTreeVaultHealthCheck(); setHealth(next); + setHostMatch(await resolveVaultHostMatch(next.host_os)); setError(null); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -69,6 +72,12 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro return health.exists ? health.content_root_abs : dirname(health.content_root_abs); }, [health]); + // #4278: the vault lives on the core host's filesystem. When this frontend + // runs on a different OS than the core, the path can't be opened/registered + // locally — disable the local-FS actions and explain why instead of firing a + // doomed reveal / deep link. + const crossHost = hostMatch ? !hostMatch.local : false; + const openObsidian = useCallback(() => { if (!health?.content_root_abs) return; void (async () => { @@ -164,19 +173,28 @@ export function VaultHealthChecklist({ onToast, title }: VaultHealthChecklistPro ) : null} + {crossHost ? ( +
+ {t('crossHostVault.title')}{' '} + {t('crossHostVault.message').replace('{os}', hostMatch?.hostOs ?? '')} +
+ ) : null} +