fix(memory): surface cross-host vault paths on a shared core (#4278) (#4330)

Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
oxoxDev
2026-06-30 22:11:52 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 06e9bd10f5
commit 7bf18562a1
26 changed files with 525 additions and 8 deletions
@@ -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('<ObsidianVaultSection /> 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(<ObsidianVaultSection contentRootAbs={CONTENT_ROOT} />);
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(<ObsidianVaultSection contentRootAbs={CONTENT_ROOT} />);
fireEvent.click(screen.getByTestId('memory-open-in-obsidian'));
await waitFor(() => {
expect(openUrl).toHaveBeenCalledWith(
'obsidian://open?path=' + encodeURIComponent(CONTENT_ROOT)
);
});
});
});
@@ -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<boolean | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [configDir, setConfigDir] = useState<string>(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<string | null>(null);
const containerRef = useRef<HTMLDivElement>(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
</code>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={reveal} data-testid="obsidian-reveal">
<Button
variant="secondary"
size="sm"
onClick={reveal}
disabled={crossHostOs !== null}
data-testid="obsidian-reveal">
{t('workspace.revealFolder')}
</Button>
<button
type="button"
onClick={openAnyway}
disabled={crossHostOs !== null}
data-testid="obsidian-open-anyway"
className="rounded-md border border-violet-300 bg-surface px-3 py-1.5 text-xs font-semibold
text-violet-700 hover:bg-violet-50 dark:border-violet-500/40
text-violet-700 hover:bg-violet-50 disabled:opacity-50 dark:border-violet-500/40
dark:bg-surface-muted dark:text-violet-300">
{t('workspace.openAnyway')}
</button>
@@ -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> = {}): VaultHealthCheck {
return {
content_root_abs: '/tmp/workspace/memory_tree/content',
@@ -89,4 +97,36 @@ describe('<VaultHealthChecklist />', () => {
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(<VaultHealthChecklist />);
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(<VaultHealthChecklist />);
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();
});
});
@@ -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<VaultHealthCheck | null>(null);
const [hostMatch, setHostMatch] = useState<VaultHostMatch | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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
</code>
) : null}
{crossHost ? (
<div
className="rounded-md border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
data-testid="vault-health-cross-host">
<span className="font-semibold">{t('crossHostVault.title')}</span>{' '}
{t('crossHostVault.message').replace('{os}', hostMatch?.hostOs ?? '')}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={revealVault}
disabled={!health?.content_root_abs}
disabled={!health?.content_root_abs || crossHost}
data-testid="vault-health-reveal">
{t('vaultHealth.revealFolder')}
</Button>
<button
type="button"
onClick={openObsidian}
disabled={!health?.content_root_abs}
disabled={!health?.content_root_abs || crossHost}
className="rounded-md border border-violet-300 dark:border-violet-500/40 bg-surface px-3 py-1.5 text-xs font-semibold text-violet-700 dark:text-violet-300 disabled:opacity-50"
data-testid="vault-health-open-obsidian">
{t('vaultHealth.openInObsidian')}
@@ -0,0 +1,88 @@
/**
* Unit tests for cross-host vault detection (#4278).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isVaultLocalToThisDevice, normalizeOs, resolveVaultHostMatch } from './vaultHostMatch';
const isTauriMock = vi.fn();
const platformMock = vi.fn();
vi.mock('../../utils/tauriCommands/common', () => ({ isTauri: () => isTauriMock() }));
vi.mock('@tauri-apps/plugin-os', () => ({ platform: () => platformMock() }));
describe('normalizeOs', () => {
it('folds known aliases to canonical tokens', () => {
expect(normalizeOs('macos')).toBe('macos');
expect(normalizeOs('Darwin')).toBe('macos');
expect(normalizeOs('win32')).toBe('windows');
expect(normalizeOs('Windows')).toBe('windows');
expect(normalizeOs('linux')).toBe('linux');
});
it('returns undefined for empty / unknown values', () => {
expect(normalizeOs(undefined)).toBeUndefined();
expect(normalizeOs(null)).toBeUndefined();
expect(normalizeOs('')).toBeUndefined();
expect(normalizeOs('plan9')).toBeUndefined();
});
});
describe('isVaultLocalToThisDevice', () => {
it('is true only when both OSes are known and equal', () => {
expect(isVaultLocalToThisDevice('macos', 'macos')).toBe(true);
expect(isVaultLocalToThisDevice('linux', 'linux')).toBe(true);
});
it('is false when the OSes differ', () => {
expect(isVaultLocalToThisDevice('macos', 'linux')).toBe(false);
expect(isVaultLocalToThisDevice('linux', 'windows')).toBe(false);
});
it('defaults to local (true) when either OS is unknown — never block on missing signal', () => {
expect(isVaultLocalToThisDevice(undefined, 'macos')).toBe(true);
expect(isVaultLocalToThisDevice('macos', undefined)).toBe(true);
expect(isVaultLocalToThisDevice(undefined, undefined)).toBe(true);
});
});
describe('resolveVaultHostMatch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('treats a vault as local when the core reports no host_os (older core)', async () => {
isTauriMock.mockReturnValue(true);
const m = await resolveVaultHostMatch(undefined);
expect(m).toEqual({ local: true });
expect(platformMock).not.toHaveBeenCalled();
});
it('treats a vault as local outside Tauri', async () => {
isTauriMock.mockReturnValue(false);
const m = await resolveVaultHostMatch('linux');
expect(m).toEqual({ local: true, hostOs: 'linux' });
expect(platformMock).not.toHaveBeenCalled();
});
it('flags cross-host when the core OS differs from this device', async () => {
isTauriMock.mockReturnValue(true);
platformMock.mockResolvedValue('macos');
const m = await resolveVaultHostMatch('linux');
expect(m).toEqual({ local: false, hostOs: 'linux' });
});
it('reports local when the core OS matches this device', async () => {
isTauriMock.mockReturnValue(true);
platformMock.mockResolvedValue('linux');
const m = await resolveVaultHostMatch('linux');
expect(m).toEqual({ local: true, hostOs: 'linux' });
});
it('falls back to local when the platform probe throws', async () => {
isTauriMock.mockReturnValue(true);
platformMock.mockRejectedValue(new Error('no os'));
const m = await resolveVaultHostMatch('windows');
expect(m).toEqual({ local: true, hostOs: 'windows' });
});
});
@@ -0,0 +1,86 @@
/**
* Cross-host vault awareness (issue #4278).
*
* `openhuman-core` owns a single local filesystem. The memory-tree / Obsidian
* vault physically lives under the CORE host's filesystem, and the vault RPCs
* return that host's absolute path (`content_root_abs`) plus the core's OS
* (`host_os`). When a frontend attaches from a DIFFERENT OS than the core, that
* path cannot be opened or registered locally — "Reveal Folder" / "Open in
* Obsidian" would act on a path that does not exist on this machine.
*
* These helpers compare the core's OS against this frontend's OS so the local
* file integrations can be disabled with a clear message instead of silently
* firing a doomed deep link or a cryptic opener failure.
*
* Backward compatibility: when `host_os` is absent (older core) or this
* frontend's OS cannot be determined, treat the vault as local — never block a
* single-host user on missing signal.
*/
import { platform } from '@tauri-apps/plugin-os';
import { isTauri } from '../../utils/tauriCommands/common';
/** Canonical OS tokens shared by Rust `std::env::consts::OS` and plugin-os. */
export type OsToken = 'macos' | 'linux' | 'windows';
/**
* Normalize an OS string to a canonical token. Rust's `std::env::consts::OS`
* and `@tauri-apps/plugin-os` both emit `"macos"`/`"linux"`/`"windows"`, but we
* defensively fold common aliases (`darwin`, `win32`, …) so a future source
* change can't silently break the comparison.
*/
export function normalizeOs(os: string | undefined | null): OsToken | undefined {
if (!os) return undefined;
const v = os.trim().toLowerCase();
if (v === 'macos' || v === 'darwin' || v === 'mac' || v === 'osx') return 'macos';
if (v === 'windows' || v === 'win' || v === 'win32') return 'windows';
if (v === 'linux') return 'linux';
return undefined;
}
/**
* Pure decision: is a vault whose path lives on `hostOs` local to a frontend
* running on `clientOs`?
*
* Returns `true` (local — safe to open) when either OS is unknown, so a missing
* signal never disables local actions for a genuine single-host setup.
*/
export function isVaultLocalToThisDevice(
hostOs: string | undefined | null,
clientOs: string | undefined | null
): boolean {
const host = normalizeOs(hostOs);
const client = normalizeOs(clientOs);
if (!host || !client) return true;
return host === client;
}
/** Resolved cross-host state for a vault response. */
export interface VaultHostMatch {
/** True when the vault path is openable on this device (same OS, or unknown). */
local: boolean;
/** Normalized core host OS, when known. */
hostOs?: OsToken;
}
/**
* Resolve the cross-host state for a vault response carrying `host_os`.
*
* Reads this frontend's OS via `@tauri-apps/plugin-os`. Outside Tauri, or if
* the platform probe throws, falls back to "local" so non-desktop / preview
* contexts are never blocked.
*/
export async function resolveVaultHostMatch(
hostOs: string | undefined | null
): Promise<VaultHostMatch> {
const normalizedHost = normalizeOs(hostOs);
if (!normalizedHost) return { local: true };
if (!isTauri()) return { local: true, hostOs: normalizedHost };
try {
const clientOs = await platform();
return { local: isVaultLocalToThisDevice(normalizedHost, clientOs), hostOs: normalizedHost };
} catch {
// Can't determine this device's OS — don't block local actions.
return { local: true, hostOs: normalizedHost };
}
}
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Arabic (العربية) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'الخزنة موجودة على مضيف النواة.',
'crossHostVault.message':
'يتم تخزين خزنة الذاكرة هذه على مضيف openhuman-core ({os}). لا يمكن فتحها أو عرضها إلا على ذلك الجهاز، وليس من هذا الجهاز.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'شارك ملاحظاتك',
'feedback.board': 'لوحة الملاحظات',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Bengali (বাংলা) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'ভল্টটি কোর হোস্টে রয়েছে।',
'crossHostVault.message':
'এই মেমরি ভল্টটি openhuman-core হোস্টে ({os}) সংরক্ষিত আছে। এটি কেবল সেই মেশিনেই খোলা বা দেখানো যায়, এই ডিভাইস থেকে নয়।',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'মতামত দিন',
'feedback.board': 'মতামত বোর্ড',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// German (Deutsch) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.',
'crossHostVault.message':
'Dieser Memory-Vault wird auf dem openhuman-core-Host ({os}) gespeichert. Er kann nur auf diesem Rechner geöffnet oder angezeigt werden, nicht von diesem Gerät.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Feedback geben',
'feedback.board': 'Feedback-Board',
+5
View File
@@ -2904,6 +2904,11 @@ const en: TranslationMap = {
'vaultHealth.timeDayAgo': '{n} day ago',
'vaultHealth.timeDaysAgo': '{n} days ago',
// Cross-host vault (#4278) — shared by VaultHealthChecklist + ObsidianVaultSection
'crossHostVault.title': 'Vault is on the core host.',
'crossHostVault.message':
'This memory vault is stored on the openhuman-core host ({os}). It can only be opened or revealed on that machine, not from this device.',
// Memory data panel (storage explainer)
'memoryData.howItWorks': 'How memory storage works',
'memoryData.workspaceVault': 'Workspace vault · write',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Spanish (Español) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'El vault está en el host del core.',
'crossHostVault.message':
'Este vault de memoria se almacena en el host de openhuman-core ({os}). Solo se puede abrir o mostrar en esa máquina, no desde este dispositivo.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Compartir opiniones',
'feedback.board': 'Tablero de opiniones',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// French (Français) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': "Le coffre se trouve sur l'hôte du cœur.",
'crossHostVault.message':
"Ce coffre de mémoire est stocké sur l'hôte openhuman-core ({os}). Il ne peut être ouvert ou affiché que sur cette machine, pas depuis cet appareil.",
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Donner mon avis',
'feedback.board': 'Tableau des suggestions',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Hindi (हिन्दी) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'वॉल्ट कोर होस्ट पर है।',
'crossHostVault.message':
'यह मेमोरी वॉल्ट openhuman-core होस्ट ({os}) पर संग्रहीत है। इसे केवल उसी मशीन पर खोला या दिखाया जा सकता है, इस डिवाइस से नहीं।',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'फ़ीडबैक साझा करें',
'feedback.board': 'फ़ीडबैक बोर्ड',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Indonesian (Bahasa Indonesia) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'Vault berada di host core.',
'crossHostVault.message':
'Vault memori ini disimpan di host openhuman-core ({os}). Hanya dapat dibuka atau ditampilkan di mesin tersebut, bukan dari perangkat ini.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Bagikan masukan',
'feedback.board': 'Papan masukan',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Italian (Italiano) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': "Il vault è sull'host del core.",
'crossHostVault.message':
"Questo vault di memoria è archiviato sull'host openhuman-core ({os}). Può essere aperto o mostrato solo su quella macchina, non da questo dispositivo.",
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Condividi feedback',
'feedback.board': 'Bacheca dei feedback',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Korean (한국어) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': '보관소가 코어 호스트에 있습니다.',
'crossHostVault.message':
'이 메모리 보관소는 openhuman-core 호스트({os})에 저장되어 있습니다. 해당 컴퓨터에서만 열거나 표시할 수 있으며 이 기기에서는 불가능합니다.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': '피드백 보내기',
'feedback.board': '피드백 보드',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Polish (Polski) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'Skarbiec znajduje się na hoście rdzenia.',
'crossHostVault.message':
'Ten skarbiec pamięci jest przechowywany na hoście openhuman-core ({os}). Można go otworzyć lub pokazać tylko na tym komputerze, a nie z tego urządzenia.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Podziel się opinią',
'feedback.board': 'Tablica opinii',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Portuguese (Português) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'O vault está no host do core.',
'crossHostVault.message':
'Este vault de memória fica armazenado no host openhuman-core ({os}). Só pode ser aberto ou exibido nessa máquina, não a partir deste dispositivo.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Enviar feedback',
'feedback.board': 'Quadro de feedback',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Russian (Русский) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': 'Хранилище находится на хосте ядра.',
'crossHostVault.message':
'Это хранилище памяти размещено на хосте openhuman-core ({os}). Его можно открыть или показать только на той машине, но не с этого устройства.',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': 'Поделиться отзывом',
'feedback.board': 'Доска отзывов',
+4
View File
@@ -3,6 +3,10 @@ import type { TranslationMap } from './types';
// Simplified Chinese (简体中文) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Cross-host vault (#4278)
'crossHostVault.title': '记忆库位于核心主机上。',
'crossHostVault.message':
'此记忆库存储在 openhuman-core 主机({os})上。只能在该机器上打开或显示,无法从本设备访问。',
'conversations.backgroundTasks.title': 'Background tasks',
'nav.feedback': '分享反馈',
'feedback.board': '反馈板',
+46
View File
@@ -11,6 +11,7 @@ const isTauriMock = vi.fn();
const tauriOpenUrlMock = vi.fn();
const revealItemInDirMock = vi.fn();
const addBreadcrumbMock = vi.fn();
const platformMock = vi.fn();
vi.mock('./tauriCommands/common', () => ({ isTauri: () => isTauriMock() }));
@@ -19,6 +20,8 @@ vi.mock('@tauri-apps/plugin-opener', () => ({
revealItemInDir: (path: string) => revealItemInDirMock(path),
}));
vi.mock('@tauri-apps/plugin-os', () => ({ platform: () => platformMock() }));
vi.mock('@sentry/react', () => ({
addBreadcrumb: (...args: unknown[]) => addBreadcrumbMock(...args),
}));
@@ -29,6 +32,9 @@ describe('openUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default this device to macOS so existing POSIX-path reveal tests pass; the
// #4278 cross-host tests override per-case.
platformMock.mockResolvedValue('macos');
originalWindowOpen = window.open;
windowOpenMock = vi.fn();
window.open = windowOpenMock as unknown as typeof window.open;
@@ -154,6 +160,46 @@ describe('openUrl', () => {
await expect(revealPath('/Users/me/Vault')).rejects.toThrow('reveal failed');
});
// #4278: a shared openhuman-core on a different OS serves its own absolute
// path; revealing it locally must fail with a clear error, not cryptically.
it('revealPath rejects a foreign-OS path instead of revealing it (#4278)', async () => {
isTauriMock.mockReturnValue(true);
platformMock.mockResolvedValue('windows'); // core served a POSIX path to a Windows frontend
revealItemInDirMock.mockResolvedValue(undefined);
const { revealPath } = await import('./openUrl');
await expect(revealPath('/home/leigh/OHvault')).rejects.toThrow(/openhuman-core host/);
expect(revealItemInDirMock).not.toHaveBeenCalled();
});
it('revealPath still reveals a path native to this device (#4278 regression)', async () => {
isTauriMock.mockReturnValue(true);
platformMock.mockResolvedValue('windows');
revealItemInDirMock.mockResolvedValue(undefined);
const { revealPath } = await import('./openUrl');
await revealPath('C:\\Users\\me\\Vault');
expect(revealItemInDirMock).toHaveBeenCalledWith('C:\\Users\\me\\Vault');
});
describe('isForeignFsPath', () => {
it('flags a POSIX path on Windows and a Windows path on POSIX', async () => {
const { isForeignFsPath } = await import('./openUrl');
expect(isForeignFsPath('/home/leigh/OHvault', 'windows')).toBe(true);
expect(isForeignFsPath('C:\\Users\\me\\Vault', 'macos')).toBe(true);
expect(isForeignFsPath('\\\\server\\share', 'linux')).toBe(true);
});
it('treats same-family paths as local and is permissive on unknown OS', async () => {
const { isForeignFsPath } = await import('./openUrl');
expect(isForeignFsPath('/Users/me/Vault', 'macos')).toBe(false);
expect(isForeignFsPath('C:\\x', 'windows')).toBe(false);
expect(isForeignFsPath('/home/x', undefined)).toBe(false);
expect(isForeignFsPath('', 'windows')).toBe(false);
});
});
it('trims surrounding whitespace before classifying an http URL for fallback', async () => {
isTauriMock.mockReturnValue(true);
tauriOpenUrlMock.mockRejectedValue(
+37
View File
@@ -1,5 +1,6 @@
import * as Sentry from '@sentry/react';
import { revealItemInDir, openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener';
import { platform } from '@tauri-apps/plugin-os';
import { isTauri } from './tauriCommands/common';
@@ -70,6 +71,27 @@ export const openUrl = async (url: string): Promise<void> => {
window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
};
/**
* Detects a filesystem path that belongs to a different OS family than the one
* running this frontend — a Windows path (`C:\…` or a `\\UNC` share) on a POSIX
* host, or a POSIX absolute path (`/…`) on Windows.
*
* This is the cross-host guard for issue #4278: `openhuman-core` can serve a
* path that lives on its own (possibly different-OS) host, and revealing such a
* path locally would fail with a cryptic opener error. Returns `false` when the
* OS is unknown so we never block a legitimate same-host reveal.
*/
export const isForeignFsPath = (path: string, clientOs: string | undefined): boolean => {
const p = path.trim();
if (!p) return false;
const looksWindows = /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
const looksPosixAbs = p.startsWith('/');
const os = (clientOs ?? '').toLowerCase();
if (os === 'windows') return looksPosixAbs && !looksWindows;
if (os === 'macos' || os === 'linux') return looksWindows;
return false;
};
/**
* Reveals a filesystem path in the host OS file manager
* (Finder on macOS, Explorer on Windows, the default file manager on
@@ -78,8 +100,23 @@ export const openUrl = async (url: string): Promise<void> => {
* target app isn't installed.
*
* Outside Tauri this is a no-op — there's no OS shell to drive.
*
* Rejects with a clear error when `path` belongs to a different OS than this
* device (issue #4278) — e.g. a shared `openhuman-core` running on another OS
* served its own absolute path — instead of letting the opener fail cryptically.
*/
export const revealPath = async (path: string): Promise<void> => {
if (!isTauri()) return;
let clientOs: string | undefined;
try {
clientOs = await platform();
} catch {
clientOs = undefined;
}
if (isForeignFsPath(path, clientOs)) {
throw new Error(
`Cannot reveal "${path}" on this device — it is a path on the openhuman-core host's filesystem (a different OS). Open it on the machine running the core.`
);
}
await revealItemInDir(path);
};
+14
View File
@@ -638,6 +638,13 @@ export interface ObsidianVaultStatus {
config_found: boolean;
/** Absolute filesystem path to `<workspace>/memory_tree/content/`. */
content_root_abs: string;
/**
* OS of the core host (`"macos"` / `"linux"` / `"windows"`). `content_root_abs`
* is a path on that host; when the frontend runs on a different OS the vault
* cannot be opened/registered locally (issue #4278). Optional for backward
* compatibility with cores that predate this field.
*/
host_os?: string;
}
/**
@@ -687,6 +694,13 @@ export interface VaultHealthCheck {
pipeline_healthy: boolean;
/** Epoch ms of newest chunk timestamp; zero when no chunks exist yet. */
last_sync_ms: number;
/**
* OS of the core host (`"macos"` / `"linux"` / `"windows"`). `exists` /
* `readable` / `writable` describe that host's filesystem; a frontend on a
* different OS cannot open `content_root_abs` locally even when they report
* healthy (issue #4278). Optional for backward compatibility.
*/
host_os?: string;
}
/**
+10
View File
@@ -160,6 +160,11 @@ pub struct ObsidianVaultStatusResponse {
pub registered: bool,
pub config_found: bool,
pub content_root_abs: String,
/// OS of the host the core runs on (`std::env::consts::OS`:
/// `"macos"` / `"linux"` / `"windows"`). `content_root_abs` is a path on
/// THIS host's filesystem; a frontend attached from a different OS must not
/// treat it as a local path (issue #4278).
pub host_os: String,
}
/// Response shape for [`vault_health_check_rpc`].
@@ -172,4 +177,9 @@ pub struct VaultHealthCheckResponse {
pub obsidian_registered: bool,
pub pipeline_healthy: bool,
pub last_sync_ms: i64,
/// OS of the host the core runs on (`std::env::consts::OS`). The
/// `exists` / `readable` / `writable` probes describe THIS host's
/// filesystem; a frontend on a different OS cannot open `content_root_abs`
/// locally even when these report healthy (issue #4278).
pub host_os: String,
}
+2
View File
@@ -23,6 +23,7 @@ pub async fn obsidian_vault_status_rpc(
registered: reg.registered,
config_found: reg.config_found,
content_root_abs: content_root.to_string_lossy().to_string(),
host_os: std::env::consts::OS.to_string(),
}
})
.await
@@ -84,6 +85,7 @@ pub async fn vault_health_check_rpc(
obsidian_registered,
pipeline_healthy,
last_sync_ms,
host_os: std::env::consts::OS.to_string(),
};
let log = format!(
+18
View File
@@ -913,6 +913,24 @@ async fn vault_health_check_reports_missing_content_root_for_fresh_workspace() {
assert_eq!(outcome.value.last_sync_ms, 0);
}
/// #4278: both vault RPCs stamp the core host's OS so a frontend attached
/// from a different OS can tell `content_root_abs` is a foreign-host path and
/// must not open/reveal it locally.
#[tokio::test]
async fn vault_rpcs_report_core_host_os() {
let (_tmp, cfg) = test_config();
let status = obsidian_vault_status_rpc(&cfg, None).await.unwrap();
assert_eq!(status.value.host_os, std::env::consts::OS);
let health = vault_health_check_rpc(&cfg, None).await.unwrap();
assert_eq!(health.value.host_os, std::env::consts::OS);
assert!(
!health.value.host_os.is_empty(),
"host_os must be populated"
);
}
#[tokio::test]
async fn vault_health_check_reports_writable_and_obsidian_registered_when_ready() {
let (_tmp, cfg) = test_config();