mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: prompt user consent when OS keyring is unavailable (#3075)
This commit is contained in:
@@ -13,6 +13,7 @@ import CommandProvider from './components/commands/CommandProvider';
|
||||
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
|
||||
import DictationHotkeyManager from './components/DictationHotkeyManager';
|
||||
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
|
||||
import KeyringConsentOverlay from './components/keyring/KeyringConsentOverlay';
|
||||
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
|
||||
import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog';
|
||||
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
|
||||
@@ -107,6 +108,7 @@ function App() {
|
||||
{!onMobile && <DictationHotkeyManager />}
|
||||
{!onMobile && <LocalAIDownloadSnackbar />}
|
||||
{!onMobile && <AppUpdatePrompt />}
|
||||
<KeyringConsentOverlay />
|
||||
<SecretPromptDialog />
|
||||
</ServiceBlockingGate>
|
||||
</CommandProvider>
|
||||
|
||||
@@ -71,7 +71,13 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean
|
||||
onboardingCompleted: true,
|
||||
chatOnboardingCompleted: true,
|
||||
analyticsEnabled: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
isBootstrapping: false,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../providers/CoreStateProvider';
|
||||
import { decideKeyringConsent, retryKeyringProbe } from '../../services/keyringApi';
|
||||
|
||||
const KeyringConsentOverlay = () => {
|
||||
const { t } = useT();
|
||||
const { snapshot } = useCoreState();
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [isConsenting, setIsConsenting] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const keyringStatus = snapshot.keyringStatus;
|
||||
const needsConsent = keyringStatus.activeMode === 'consent_pending';
|
||||
|
||||
if (!needsConsent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleConsent = async () => {
|
||||
setIsConsenting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideKeyringConsent('local_encrypted');
|
||||
} catch {
|
||||
setError(t('keyring.consent.error'));
|
||||
} finally {
|
||||
setIsConsenting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
setIsConsenting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideKeyringConsent('declined');
|
||||
} catch {
|
||||
setError(t('keyring.consent.error'));
|
||||
} finally {
|
||||
setIsConsenting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
setIsRetrying(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryKeyringProbe();
|
||||
} catch {
|
||||
setError(t('keyring.consent.retryFailed'));
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const failureReason = keyringStatus.failureReason;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] bg-stone-950/80 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="keyring-consent-title"
|
||||
className="w-full max-w-lg rounded-2xl border border-amber-500/30 bg-stone-900 p-6 shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 id="keyring-consent-title" className="text-lg font-semibold text-white">
|
||||
{t('keyring.consent.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-300">{t('keyring.consent.description')}</p>
|
||||
|
||||
{failureReason && (
|
||||
<p className="mt-2 text-xs text-stone-400">
|
||||
{t('keyring.consent.reasonPrefix')} {failureReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="mt-3 text-xs text-ocean-400 hover:text-ocean-300 underline">
|
||||
{showDetails ? t('keyring.consent.hideDetails') : t('keyring.consent.showDetails')}
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mt-2 rounded-lg bg-stone-800/60 p-3 text-xs text-stone-400 leading-relaxed">
|
||||
<p className="font-medium text-stone-300 mb-1">{t('keyring.consent.tradeoffTitle')}</p>
|
||||
<p>{t('keyring.consent.tradeoffBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-coral-300">{error}</p>}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConsent}
|
||||
disabled={isConsenting || isRetrying}
|
||||
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
|
||||
{isConsenting ? t('common.loading') : t('keyring.consent.consentButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying || isConsenting}
|
||||
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
|
||||
{isRetrying ? t('keyring.consent.retrying') : t('keyring.consent.retryButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecline}
|
||||
disabled={isConsenting || isRetrying}
|
||||
className="rounded-lg border border-stone-700 px-4 py-2 text-sm text-stone-400 hover:bg-stone-800 disabled:opacity-60">
|
||||
{t('keyring.consent.declineButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyringConsentOverlay;
|
||||
@@ -51,7 +51,13 @@ describe('oauthAuthReadiness', () => {
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
@@ -112,7 +118,13 @@ describe('oauthAuthReadiness', () => {
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { decideKeyringConsent, retryKeyringProbe } from '../../../services/keyringApi';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const MODE_BADGE: Record<string, { label: string; className: string }> = {
|
||||
os_keyring: {
|
||||
label: 'keyring.settings.mode.osKeychain',
|
||||
className:
|
||||
'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30',
|
||||
},
|
||||
local_encrypted: {
|
||||
label: 'keyring.settings.mode.encryptedFile',
|
||||
className:
|
||||
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
|
||||
},
|
||||
consent_pending: {
|
||||
label: 'keyring.settings.mode.consentPending',
|
||||
className:
|
||||
'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800',
|
||||
},
|
||||
declined: {
|
||||
label: 'keyring.settings.mode.declined',
|
||||
className:
|
||||
'bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300 border-coral-200 dark:border-coral-500/30',
|
||||
},
|
||||
};
|
||||
|
||||
const SecurityPanel = () => {
|
||||
const { navigateBack, breadcrumbs } = useSettingsNavigation();
|
||||
const { snapshot } = useCoreState();
|
||||
const { t } = useT();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const keyringStatus = snapshot.keyringStatus;
|
||||
const modeBadge = MODE_BADGE[keyringStatus.activeMode] ?? MODE_BADGE.consent_pending;
|
||||
|
||||
const handleRetryProbe = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await retryKeyringProbe();
|
||||
} catch {
|
||||
setError(t('keyring.settings.retryFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConsentChange = async (mode: 'local_encrypted' | 'declined') => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideKeyringConsent(mode);
|
||||
} catch {
|
||||
setError(t('keyring.consent.error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t('keyring.settings.title')}
|
||||
onBack={navigateBack}
|
||||
breadcrumbs={breadcrumbs}
|
||||
/>
|
||||
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Storage mode */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
|
||||
{t('keyring.settings.storageMode')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${modeBadge.className}`}>
|
||||
{t(modeBadge.label)}
|
||||
</span>
|
||||
<span className="text-xs text-stone-500 dark:text-stone-400">
|
||||
{t('keyring.settings.backend')}: {keyringStatus.backendName}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Availability */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
|
||||
{t('keyring.settings.availability')}
|
||||
</h3>
|
||||
<div className="rounded-lg bg-stone-100 dark:bg-stone-800/60 p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${keyringStatus.available ? 'bg-sage-500' : 'bg-amber-500'}`}
|
||||
/>
|
||||
<span className="text-sm text-stone-700 dark:text-stone-200">
|
||||
{keyringStatus.available
|
||||
? t('keyring.settings.available')
|
||||
: t('keyring.settings.unavailable')}
|
||||
</span>
|
||||
</div>
|
||||
{keyringStatus.failureReason && (
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400 ml-4">
|
||||
{keyringStatus.failureReason}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetryProbe}
|
||||
disabled={isLoading}
|
||||
className="mt-3 rounded-lg border border-stone-300 dark:border-stone-600 px-3 py-1.5 text-xs text-stone-700 dark:text-stone-200 hover:bg-stone-200 dark:hover:bg-stone-700 disabled:opacity-60">
|
||||
{isLoading ? t('keyring.consent.retrying') : t('keyring.settings.retryButton')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Consent management (only when keyring is unavailable) */}
|
||||
{!keyringStatus.available && (
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
|
||||
{t('keyring.settings.consentTitle')}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400 mb-3">
|
||||
{t('keyring.settings.consentDescription')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keyringStatus.activeMode !== 'local_encrypted' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleConsentChange('local_encrypted')}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-ocean-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
|
||||
{t('keyring.settings.grantConsent')}
|
||||
</button>
|
||||
)}
|
||||
{keyringStatus.activeMode !== 'declined' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleConsentChange('declined')}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-stone-300 dark:border-stone-600 px-3 py-1.5 text-xs text-stone-700 dark:text-stone-200 hover:bg-stone-200 dark:hover:bg-stone-700 disabled:opacity-60">
|
||||
{t('keyring.settings.revokeConsent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-coral-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityPanel;
|
||||
@@ -11,7 +11,13 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -14,9 +14,22 @@ export interface CoreOnboardingTasks {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface KeyringConsentPreference {
|
||||
storageMode: string;
|
||||
consentedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface KeyringStatus {
|
||||
available: boolean;
|
||||
failureReason?: string | null;
|
||||
activeMode: string;
|
||||
backendName: string;
|
||||
}
|
||||
|
||||
export interface CoreLocalState {
|
||||
encryptionKey: string | null;
|
||||
onboardingTasks: CoreOnboardingTasks | null;
|
||||
keyringConsent: KeyringConsentPreference | null;
|
||||
}
|
||||
|
||||
export interface CoreRuntimeSnapshot {
|
||||
@@ -55,6 +68,7 @@ export interface CoreAppSnapshot {
|
||||
*/
|
||||
meetAutoOrchestratorHandoff: boolean;
|
||||
localState: CoreLocalState;
|
||||
keyringStatus: KeyringStatus;
|
||||
runtime: CoreRuntimeSnapshot;
|
||||
}
|
||||
|
||||
@@ -75,7 +89,13 @@ const emptySnapshot: CoreAppSnapshot = {
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
};
|
||||
|
||||
|
||||
@@ -4314,6 +4314,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'متوسط التجميع {avg} · التعدّي {transitivity}',
|
||||
'graphCohesion.title': 'تماسك الرسم البياني',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'التخزين الآمن غير متاح',
|
||||
'keyring.consent.description':
|
||||
'سلسلة مفاتيح نظام التشغيل غير متاحة. يحتاج OpenHuman إلى إذنك لتخزين الأسرار باستخدام التخزين المشفّر المحلي بدلاً من ذلك.',
|
||||
'keyring.consent.reasonPrefix': 'السبب:',
|
||||
'keyring.consent.showDetails': 'ماذا يعني هذا؟',
|
||||
'keyring.consent.hideDetails': 'إخفاء التفاصيل',
|
||||
'keyring.consent.tradeoffTitle': 'مقايضة أمنية',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'مع التخزين المشفّر المحلي، تُشفَّر أسرارك على القرص باستخدام مفتاح رئيسي مخزَّن بجانب البيانات. يُعدّ هذا أقل أماناً من OS Keychain الذي يستخدم حماية مدعومة بالأجهزة. قد تتضمن النسخ الاحتياطية أو مزامنة الملفات البيانات المشفّرة.',
|
||||
'keyring.consent.consentButton': 'استخدام التخزين المشفّر المحلي',
|
||||
'keyring.consent.retryButton': 'إعادة المحاولة مع OS Keychain',
|
||||
'keyring.consent.declineButton': 'رفض',
|
||||
'keyring.consent.retrying': 'جارٍ إعادة المحاولة…',
|
||||
'keyring.consent.error': 'فشل حفظ التفضيل. يرجى المحاولة مرة أخرى.',
|
||||
'keyring.consent.retryFailed': 'سلسلة المفاتيح لا تزال غير متاحة.',
|
||||
'keyring.settings.title': 'الأمان',
|
||||
'keyring.settings.storageMode': 'وضع تخزين الأسرار',
|
||||
'keyring.settings.mode.osKeychain': 'سلسلة مفاتيح النظام',
|
||||
'keyring.settings.mode.encryptedFile': 'مشفَّر محلياً',
|
||||
'keyring.settings.mode.consentPending': 'غير مُهيَّأ',
|
||||
'keyring.settings.mode.declined': 'مرفوض',
|
||||
'keyring.settings.availability': 'توفّر سلسلة المفاتيح',
|
||||
'keyring.settings.available': 'OS Keychain متاح',
|
||||
'keyring.settings.unavailable': 'OS Keychain غير متاح',
|
||||
'keyring.settings.backend': 'الواجهة الخلفية',
|
||||
'keyring.settings.retryButton': 'إعادة محاولة الكشف عن سلسلة المفاتيح',
|
||||
'keyring.settings.retryFailed': 'فشلت إعادة المحاولة. سلسلة المفاتيح لا تزال غير متاحة.',
|
||||
'keyring.settings.consentTitle': 'موافقة التخزين',
|
||||
'keyring.settings.consentDescription':
|
||||
'اختر كيفية تخزين الأسرار عندما لا تكون سلسلة مفاتيح نظام التشغيل متاحة.',
|
||||
'keyring.settings.grantConsent': 'السماح بالتخزين المشفّر المحلي',
|
||||
'keyring.settings.revokeConsent': 'رفض التخزين المحلي',
|
||||
'pages.settings.account.security': 'الأمان',
|
||||
'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4390,6 +4390,42 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'গড় ক্লাস্টারিং {avg} · সংক্রমণতা {transitivity}',
|
||||
'graphCohesion.title': 'গ্রাফ সংসক্তি',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
// Keyring consent & security
|
||||
'keyring.consent.title': 'নিরাপদ সঞ্চয়স্থান অনুপলব্ধ',
|
||||
'keyring.consent.description':
|
||||
'আপনার অপারেটিং সিস্টেমের কিচেন অ্যাক্সেসযোগ্য নয়। OpenHuman-এর পরিবর্তে স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করে গোপনীয়তা সংরক্ষণ করতে আপনার অনুমতি প্রয়োজন।',
|
||||
'keyring.consent.reasonPrefix': 'কারণ:',
|
||||
'keyring.consent.showDetails': 'এটার মানে কী?',
|
||||
'keyring.consent.hideDetails': 'বিবরণ লুকান',
|
||||
'keyring.consent.tradeoffTitle': 'নিরাপত্তা বিনিময়',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থানে, আপনার গোপনীয়তা ডিস্কে একটি মাস্টার কী দিয়ে এনক্রিপ্ট করা হয় যা ডেটার পাশাপাশি সংরক্ষিত থাকে। এটি OS কিচেনের চেয়ে কম নিরাপদ, যা হার্ডওয়্যার-সমর্থিত সুরক্ষা ব্যবহার করে। ব্যাকআপ বা ফাইল সিঙ্কিংয়ে এনক্রিপ্টেড ডেটা অন্তর্ভুক্ত হতে পারে।',
|
||||
'keyring.consent.consentButton': 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করুন',
|
||||
'keyring.consent.retryButton': 'OS Keychain পুনরায় চেষ্টা করুন',
|
||||
'keyring.consent.declineButton': 'প্রত্যাখ্যান করুন',
|
||||
'keyring.consent.retrying': 'পুনরায় চেষ্টা হচ্ছে…',
|
||||
'keyring.consent.error': 'পছন্দ সংরক্ষণ ব্যর্থ। অনুগ্রহ করে আবার চেষ্টা করুন।',
|
||||
'keyring.consent.retryFailed': 'কিচেন এখনও অনুপলব্ধ।',
|
||||
'keyring.settings.title': 'নিরাপত্তা',
|
||||
'keyring.settings.storageMode': 'গোপনীয়তা সঞ্চয়স্থান মোড',
|
||||
'keyring.settings.mode.osKeychain': 'OS কীচেইন',
|
||||
'keyring.settings.mode.encryptedFile': 'স্থানীয় এনক্রিপ্টেড',
|
||||
'keyring.settings.mode.consentPending': 'কনফিগার করা হয়নি',
|
||||
'keyring.settings.mode.declined': 'প্রত্যাখ্যান করা হয়েছে',
|
||||
'keyring.settings.availability': 'কিচেন প্রাপ্যতা',
|
||||
'keyring.settings.available': 'OS কিচেন উপলব্ধ',
|
||||
'keyring.settings.unavailable': 'OS কিচেন অনুপলব্ধ',
|
||||
'keyring.settings.backend': 'ব্যাকএন্ড',
|
||||
'keyring.settings.retryButton': 'কিচেন সনাক্তকরণ পুনরায় চেষ্টা করুন',
|
||||
'keyring.settings.retryFailed': 'পুনরায় চেষ্টা ব্যর্থ। কিচেন এখনও অনুপলব্ধ।',
|
||||
'keyring.settings.consentTitle': 'সঞ্চয়স্থান সম্মতি',
|
||||
'keyring.settings.consentDescription':
|
||||
'OS কিচেন অনুপলব্ধ থাকলে গোপনীয়তা কীভাবে সংরক্ষণ করা হবে তা বেছে নিন।',
|
||||
'keyring.settings.grantConsent': 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান অনুমতি দিন',
|
||||
'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন',
|
||||
'pages.settings.account.security': 'নিরাপত্তা',
|
||||
'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4506,6 +4506,42 @@ const messages: TranslationMap = {
|
||||
'Durchschnittliches Clustering {avg} · Transitivität {transitivity}',
|
||||
'graphCohesion.title': 'Graph-Kohäsion',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Sicherer Speicher nicht verfügbar',
|
||||
'keyring.consent.description':
|
||||
'Der Schlüsselbund Ihres Betriebssystems ist nicht erreichbar. OpenHuman benötigt Ihre Erlaubnis, Geheimnisse stattdessen in einem lokal verschlüsselten Speicher abzulegen.',
|
||||
'keyring.consent.reasonPrefix': 'Grund:',
|
||||
'keyring.consent.showDetails': 'Was bedeutet das?',
|
||||
'keyring.consent.hideDetails': 'Details ausblenden',
|
||||
'keyring.consent.tradeoffTitle': 'Sicherheitskompromiss',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'Bei lokal verschlüsseltem Speicher werden Ihre Geheimnisse mit einem Hauptschlüssel verschlüsselt, der neben den Daten gespeichert wird. Dies ist weniger sicher als der OS-Schlüsselbund, der hardwaregestützten Schutz bietet. Backups oder Dateisynchronisation können die verschlüsselten Daten enthalten.',
|
||||
'keyring.consent.consentButton': 'Lokal verschlüsselten Speicher verwenden',
|
||||
'keyring.consent.retryButton': 'OS Keychain erneut versuchen',
|
||||
'keyring.consent.declineButton': 'Ablehnen',
|
||||
'keyring.consent.retrying': 'Erneuter Versuch…',
|
||||
'keyring.consent.error': 'Einstellung konnte nicht gespeichert werden. Bitte erneut versuchen.',
|
||||
'keyring.consent.retryFailed': 'Schlüsselbund ist weiterhin nicht verfügbar.',
|
||||
'keyring.settings.title': 'Sicherheit',
|
||||
'keyring.settings.storageMode': 'Geheimnisspeicher-Modus',
|
||||
'keyring.settings.mode.osKeychain': 'OS-Schlüsselbund',
|
||||
'keyring.settings.mode.encryptedFile': 'Lokal verschlüsselt',
|
||||
'keyring.settings.mode.consentPending': 'Nicht konfiguriert',
|
||||
'keyring.settings.mode.declined': 'Abgelehnt',
|
||||
'keyring.settings.availability': 'Schlüsselbund-Verfügbarkeit',
|
||||
'keyring.settings.available': 'OS-Schlüsselbund ist verfügbar',
|
||||
'keyring.settings.unavailable': 'OS-Schlüsselbund ist nicht verfügbar',
|
||||
'keyring.settings.backend': 'Backend',
|
||||
'keyring.settings.retryButton': 'Schlüsselbund-Erkennung wiederholen',
|
||||
'keyring.settings.retryFailed':
|
||||
'Erneuter Versuch fehlgeschlagen. Schlüsselbund weiterhin nicht verfügbar.',
|
||||
'keyring.settings.consentTitle': 'Speicherzustimmung',
|
||||
'keyring.settings.consentDescription':
|
||||
'Wählen Sie, wie Geheimnisse gespeichert werden, wenn der OS-Schlüsselbund nicht verfügbar ist.',
|
||||
'keyring.settings.grantConsent': 'Lokal verschlüsselten Speicher erlauben',
|
||||
'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen',
|
||||
'pages.settings.account.security': 'Sicherheit',
|
||||
'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
+43
-7
@@ -941,7 +941,7 @@ const en: TranslationMap = {
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses, or disable search tools entirely. Managed uses OpenHuman’s backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.',
|
||||
"Pick the search engine the agent uses, or disable search tools entirely. Managed uses OpenHuman's backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.",
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineDisabledLabel': 'Disabled',
|
||||
'settings.search.engineDisabledDesc':
|
||||
@@ -2391,7 +2391,7 @@ const en: TranslationMap = {
|
||||
'composio.envVarOverrides': 'is set, it overrides this setting.',
|
||||
'composio.previewBadge': 'Preview',
|
||||
'composio.previewTooltip':
|
||||
'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.',
|
||||
"Agent integration coming soon — you can connect, but the agent can't use this toolkit yet.",
|
||||
|
||||
// Memory: day-of-week labels for heatmap
|
||||
'memory.day.sun': 'Sun',
|
||||
@@ -2500,7 +2500,7 @@ const en: TranslationMap = {
|
||||
'app.openhumanLink.notifications.send': 'Send test notification',
|
||||
'app.openhumanLink.notifications.sendFailed': "Couldn't send: {error}",
|
||||
'app.openhumanLink.notifications.sent':
|
||||
'Test notification sent. If you didn’t receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.',
|
||||
"Test notification sent. If you didn't receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.",
|
||||
'app.openhumanLink.skipForNow': 'Skip for now',
|
||||
'app.openhumanLink.telegramUnavailable': 'Telegram unavailable',
|
||||
'app.openhumanLink.title.accounts': 'Connect your apps',
|
||||
@@ -3709,7 +3709,7 @@ const en: TranslationMap = {
|
||||
'settings.skillsRunner.repoPicker.empty':
|
||||
'No repositories returned. Connect GitHub via Composio to populate this list.',
|
||||
'settings.skillsRunner.repoPicker.notConnected':
|
||||
'GitHub isn’t connected via Composio. Connect it under Skills → Composio first.',
|
||||
"GitHub isn't connected via Composio. Connect it under Skills → Composio first.",
|
||||
'settings.skillsRunner.repoPicker.privateTag': '(private)',
|
||||
'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…',
|
||||
'settings.skillsRunner.branchPicker.loading': 'Loading branches…',
|
||||
@@ -4406,7 +4406,7 @@ const en: TranslationMap = {
|
||||
'walletBalances.errorGeneric':
|
||||
'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.',
|
||||
'walletBalances.setupHint':
|
||||
'Your recovery phrase isn’t set up yet. Set it up to enable your wallet and see live balances.',
|
||||
"Your recovery phrase isn't set up yet. Set it up to enable your wallet and see live balances.",
|
||||
'walletBalances.setupCta': 'Set up recovery phrase',
|
||||
'walletBalances.notSetUp': 'Not set up',
|
||||
'walletBalances.send': 'Send',
|
||||
@@ -4613,11 +4613,47 @@ const en: TranslationMap = {
|
||||
'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)',
|
||||
'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.',
|
||||
'settings.agents.editor.toolsLoading': 'Loading tools…',
|
||||
'settings.agents.editor.toolsLoadError': 'Couldn’t load tools',
|
||||
'settings.agents.editor.toolsLoadError': "Couldn't load tools",
|
||||
'settings.agents.editor.toolsEmpty': 'No tools match your search.',
|
||||
'settings.agents.editor.toolsDone': 'Done',
|
||||
'settings.agents.editor.builtInReadonly':
|
||||
'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.',
|
||||
"Built-in agents can't be edited. You can enable, disable, or reset them from the agents list.",
|
||||
|
||||
// Keyring consent & security
|
||||
'keyring.consent.title': 'Secure Storage Unavailable',
|
||||
'keyring.consent.description':
|
||||
'Your operating system keychain is not accessible. OpenHuman needs your permission to store secrets using local encrypted storage instead.',
|
||||
'keyring.consent.reasonPrefix': 'Reason:',
|
||||
'keyring.consent.showDetails': 'What does this mean?',
|
||||
'keyring.consent.hideDetails': 'Hide details',
|
||||
'keyring.consent.tradeoffTitle': 'Security tradeoff',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'With local encrypted storage, your secrets are encrypted on disk using a master key stored alongside the data. This is less secure than the OS keychain, which uses hardware-backed protection. Backups or file syncing may include the encrypted data.',
|
||||
'keyring.consent.consentButton': 'Use Local Encrypted Storage',
|
||||
'keyring.consent.retryButton': 'Retry OS Keychain',
|
||||
'keyring.consent.declineButton': 'Skip',
|
||||
'keyring.consent.retrying': 'Retrying…',
|
||||
'keyring.consent.error': 'Failed to save preference. Please try again.',
|
||||
'keyring.consent.retryFailed': 'Keychain is still unavailable.',
|
||||
'keyring.settings.title': 'Security',
|
||||
'keyring.settings.storageMode': 'Secret storage mode',
|
||||
'keyring.settings.mode.osKeychain': 'OS Keychain',
|
||||
'keyring.settings.mode.encryptedFile': 'Local Encrypted',
|
||||
'keyring.settings.mode.consentPending': 'Not configured',
|
||||
'keyring.settings.mode.declined': 'Declined',
|
||||
'keyring.settings.availability': 'Keychain availability',
|
||||
'keyring.settings.available': 'OS keychain is available',
|
||||
'keyring.settings.unavailable': 'OS keychain is unavailable',
|
||||
'keyring.settings.backend': 'Backend',
|
||||
'keyring.settings.retryButton': 'Retry keychain detection',
|
||||
'keyring.settings.retryFailed': 'Retry failed. Keychain is still unavailable.',
|
||||
'keyring.settings.consentTitle': 'Storage consent',
|
||||
'keyring.settings.consentDescription':
|
||||
'Choose how secrets are stored when the OS keychain is not available.',
|
||||
'keyring.settings.grantConsent': 'Allow local encrypted storage',
|
||||
'keyring.settings.revokeConsent': 'Decline local storage',
|
||||
'pages.settings.account.security': 'Security',
|
||||
'pages.settings.account.securityDesc': 'Secret storage mode and keychain status',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -4473,6 +4473,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Agrupamiento promedio {avg} · transitividad {transitivity}',
|
||||
'graphCohesion.title': 'Cohesión del grafo',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Almacenamiento seguro no disponible',
|
||||
'keyring.consent.description':
|
||||
'El llavero de su sistema operativo no está accesible. OpenHuman necesita su permiso para almacenar secretos usando almacenamiento local cifrado.',
|
||||
'keyring.consent.reasonPrefix': 'Motivo:',
|
||||
'keyring.consent.showDetails': '¿Qué significa esto?',
|
||||
'keyring.consent.hideDetails': 'Ocultar detalles',
|
||||
'keyring.consent.tradeoffTitle': 'Compromiso de seguridad',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'Con el almacenamiento local cifrado, sus secretos se cifran en disco usando una clave maestra almacenada junto a los datos. Esto es menos seguro que el llavero del SO, que usa protección respaldada por hardware. Las copias de seguridad o la sincronización de archivos pueden incluir los datos cifrados.',
|
||||
'keyring.consent.consentButton': 'Usar almacenamiento local cifrado',
|
||||
'keyring.consent.retryButton': 'Reintentar OS Keychain',
|
||||
'keyring.consent.declineButton': 'Rechazar',
|
||||
'keyring.consent.retrying': 'Reintentando…',
|
||||
'keyring.consent.error': 'No se pudo guardar la preferencia. Inténtelo de nuevo.',
|
||||
'keyring.consent.retryFailed': 'El llavero sigue sin estar disponible.',
|
||||
'keyring.settings.title': 'Seguridad',
|
||||
'keyring.settings.storageMode': 'Modo de almacenamiento de secretos',
|
||||
'keyring.settings.mode.osKeychain': 'Llavero del SO',
|
||||
'keyring.settings.mode.encryptedFile': 'Local cifrado',
|
||||
'keyring.settings.mode.consentPending': 'No configurado',
|
||||
'keyring.settings.mode.declined': 'Rechazado',
|
||||
'keyring.settings.availability': 'Disponibilidad del llavero',
|
||||
'keyring.settings.available': 'El llavero del SO está disponible',
|
||||
'keyring.settings.unavailable': 'El llavero del SO no está disponible',
|
||||
'keyring.settings.backend': 'Motor',
|
||||
'keyring.settings.retryButton': 'Reintentar detección del llavero',
|
||||
'keyring.settings.retryFailed': 'Reintento fallido. El llavero sigue sin estar disponible.',
|
||||
'keyring.settings.consentTitle': 'Consentimiento de almacenamiento',
|
||||
'keyring.settings.consentDescription':
|
||||
'Elija cómo se almacenan los secretos cuando el llavero del SO no está disponible.',
|
||||
'keyring.settings.grantConsent': 'Permitir almacenamiento local cifrado',
|
||||
'keyring.settings.revokeConsent': 'Rechazar almacenamiento local',
|
||||
'pages.settings.account.security': 'Seguridad',
|
||||
'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4488,6 +4488,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Regroupement moyen {avg} · transitivité {transitivity}',
|
||||
'graphCohesion.title': 'Cohésion du graphe',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Stockage sécurisé indisponible',
|
||||
'keyring.consent.description':
|
||||
"Le trousseau de votre système d'exploitation n'est pas accessible. OpenHuman a besoin de votre autorisation pour stocker les secrets en utilisant un stockage local chiffré.",
|
||||
'keyring.consent.reasonPrefix': 'Raison :',
|
||||
'keyring.consent.showDetails': "Qu'est-ce que cela signifie ?",
|
||||
'keyring.consent.hideDetails': 'Masquer les détails',
|
||||
'keyring.consent.tradeoffTitle': 'Compromis de sécurité',
|
||||
'keyring.consent.tradeoffBody':
|
||||
"Avec le stockage local chiffré, vos secrets sont chiffrés sur disque à l'aide d'une clé maître stockée à côté des données. C'est moins sécurisé que le trousseau du système, qui utilise une protection matérielle. Les sauvegardes ou la synchronisation de fichiers peuvent inclure les données chiffrées.",
|
||||
'keyring.consent.consentButton': 'Utiliser le stockage local chiffré',
|
||||
'keyring.consent.retryButton': 'Réessayer OS Keychain',
|
||||
'keyring.consent.declineButton': 'Refuser',
|
||||
'keyring.consent.retrying': 'Nouvelle tentative…',
|
||||
'keyring.consent.error': "Impossible d'enregistrer la préférence. Veuillez réessayer.",
|
||||
'keyring.consent.retryFailed': 'Le trousseau est toujours indisponible.',
|
||||
'keyring.settings.title': 'Sécurité',
|
||||
'keyring.settings.storageMode': 'Mode de stockage des secrets',
|
||||
'keyring.settings.mode.osKeychain': 'Trousseau du système',
|
||||
'keyring.settings.mode.encryptedFile': 'Local chiffré',
|
||||
'keyring.settings.mode.consentPending': 'Non configuré',
|
||||
'keyring.settings.mode.declined': 'Refusé',
|
||||
'keyring.settings.availability': 'Disponibilité du trousseau',
|
||||
'keyring.settings.available': 'Le trousseau du système est disponible',
|
||||
'keyring.settings.unavailable': 'Le trousseau du système est indisponible',
|
||||
'keyring.settings.backend': 'Moteur',
|
||||
'keyring.settings.retryButton': 'Réessayer la détection du trousseau',
|
||||
'keyring.settings.retryFailed': 'Échec de la tentative. Le trousseau est toujours indisponible.',
|
||||
'keyring.settings.consentTitle': 'Consentement de stockage',
|
||||
'keyring.settings.consentDescription':
|
||||
"Choisissez comment les secrets sont stockés lorsque le trousseau du système n'est pas disponible.",
|
||||
'keyring.settings.grantConsent': 'Autoriser le stockage local chiffré',
|
||||
'keyring.settings.revokeConsent': 'Refuser le stockage local',
|
||||
'pages.settings.account.security': 'Sécurité',
|
||||
'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4398,6 +4398,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'औसत क्लस्टरिंग {avg} · सकर्मकता {transitivity}',
|
||||
'graphCohesion.title': 'ग्राफ संसक्ति',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'सुरक्षित भंडारण अनुपलब्ध',
|
||||
'keyring.consent.description':
|
||||
'आपके ऑपरेटिंग सिस्टम का कीचेन सुलभ नहीं है। OpenHuman को इसके बजाय स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करके रहस्य संग्रहीत करने के लिए आपकी अनुमति चाहिए।',
|
||||
'keyring.consent.reasonPrefix': 'कारण:',
|
||||
'keyring.consent.showDetails': 'इसका क्या मतलब है?',
|
||||
'keyring.consent.hideDetails': 'विवरण छिपाएं',
|
||||
'keyring.consent.tradeoffTitle': 'सुरक्षा समझौता',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'स्थानीय एन्क्रिप्टेड भंडारण में, आपके रहस्य डिस्क पर एक मास्टर कुंजी से एन्क्रिप्ट किए जाते हैं जो डेटा के साथ संग्रहीत होती है। यह OS कीचेन से कम सुरक्षित है, जो हार्डवेयर-समर्थित सुरक्षा का उपयोग करता है। बैकअप या फ़ाइल सिंकिंग में एन्क्रिप्टेड डेटा शामिल हो सकता है।',
|
||||
'keyring.consent.consentButton': 'स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करें',
|
||||
'keyring.consent.retryButton': 'OS Keychain पुनः प्रयास करें',
|
||||
'keyring.consent.declineButton': 'अस्वीकार करें',
|
||||
'keyring.consent.retrying': 'पुनः प्रयास हो रहा है…',
|
||||
'keyring.consent.error': 'प्राथमिकता सहेजने में विफल। कृपया पुनः प्रयास करें।',
|
||||
'keyring.consent.retryFailed': 'कीचेन अभी भी अनुपलब्ध है।',
|
||||
'keyring.settings.title': 'सुरक्षा',
|
||||
'keyring.settings.storageMode': 'रहस्य भंडारण मोड',
|
||||
'keyring.settings.mode.osKeychain': 'OS कीचेन',
|
||||
'keyring.settings.mode.encryptedFile': 'स्थानीय एन्क्रिप्टेड',
|
||||
'keyring.settings.mode.consentPending': 'कॉन्फ़िगर नहीं किया गया',
|
||||
'keyring.settings.mode.declined': 'अस्वीकृत',
|
||||
'keyring.settings.availability': 'कीचेन उपलब्धता',
|
||||
'keyring.settings.available': 'OS कीचेन उपलब्ध है',
|
||||
'keyring.settings.unavailable': 'OS कीचेन अनुपलब्ध है',
|
||||
'keyring.settings.backend': 'बैकएंड',
|
||||
'keyring.settings.retryButton': 'कीचेन पहचान पुनः प्रयास करें',
|
||||
'keyring.settings.retryFailed': 'पुनः प्रयास विफल। कीचेन अभी भी अनुपलब्ध है।',
|
||||
'keyring.settings.consentTitle': 'भंडारण सहमति',
|
||||
'keyring.settings.consentDescription':
|
||||
'चुनें कि OS कीचेन अनुपलब्ध होने पर रहस्य कैसे संग्रहीत किए जाएं।',
|
||||
'keyring.settings.grantConsent': 'स्थानीय एन्क्रिप्टेड भंडारण की अनुमति दें',
|
||||
'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें',
|
||||
'pages.settings.account.security': 'सुरक्षा',
|
||||
'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4407,6 +4407,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Pengelompokan rata-rata {avg} · transitivitas {transitivity}',
|
||||
'graphCohesion.title': 'Kohesi Graf',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Penyimpanan aman tidak tersedia',
|
||||
'keyring.consent.description':
|
||||
'Keychain sistem operasi Anda tidak dapat diakses. OpenHuman memerlukan izin Anda untuk menyimpan rahasia menggunakan penyimpanan lokal terenkripsi.',
|
||||
'keyring.consent.reasonPrefix': 'Alasan:',
|
||||
'keyring.consent.showDetails': 'Apa artinya ini?',
|
||||
'keyring.consent.hideDetails': 'Sembunyikan detail',
|
||||
'keyring.consent.tradeoffTitle': 'Kompromi keamanan',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'Dengan penyimpanan lokal terenkripsi, rahasia Anda dienkripsi di disk menggunakan kunci master yang disimpan bersama data. Ini kurang aman dibandingkan keychain OS yang menggunakan perlindungan berbasis perangkat keras. Pencadangan atau sinkronisasi file mungkin menyertakan data terenkripsi.',
|
||||
'keyring.consent.consentButton': 'Gunakan penyimpanan lokal terenkripsi',
|
||||
'keyring.consent.retryButton': 'Coba ulang OS Keychain',
|
||||
'keyring.consent.declineButton': 'Tolak',
|
||||
'keyring.consent.retrying': 'Mencoba ulang…',
|
||||
'keyring.consent.error': 'Gagal menyimpan preferensi. Silakan coba lagi.',
|
||||
'keyring.consent.retryFailed': 'Keychain masih tidak tersedia.',
|
||||
'keyring.settings.title': 'Keamanan',
|
||||
'keyring.settings.storageMode': 'Mode penyimpanan rahasia',
|
||||
'keyring.settings.mode.osKeychain': 'Keychain OS',
|
||||
'keyring.settings.mode.encryptedFile': 'Lokal terenkripsi',
|
||||
'keyring.settings.mode.consentPending': 'Belum dikonfigurasi',
|
||||
'keyring.settings.mode.declined': 'Ditolak',
|
||||
'keyring.settings.availability': 'Ketersediaan keychain',
|
||||
'keyring.settings.available': 'OS keychain tersedia',
|
||||
'keyring.settings.unavailable': 'OS keychain tidak tersedia',
|
||||
'keyring.settings.backend': 'Backend',
|
||||
'keyring.settings.retryButton': 'Coba ulang deteksi keychain',
|
||||
'keyring.settings.retryFailed': 'Percobaan ulang gagal. Keychain masih tidak tersedia.',
|
||||
'keyring.settings.consentTitle': 'Persetujuan penyimpanan',
|
||||
'keyring.settings.consentDescription':
|
||||
'Pilih bagaimana rahasia disimpan saat keychain OS tidak tersedia.',
|
||||
'keyring.settings.grantConsent': 'Izinkan penyimpanan lokal terenkripsi',
|
||||
'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal',
|
||||
'pages.settings.account.security': 'Keamanan',
|
||||
'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4465,6 +4465,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Raggruppamento medio {avg} · transitività {transitivity}',
|
||||
'graphCohesion.title': 'Coesione del grafo',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Archivio sicuro non disponibile',
|
||||
'keyring.consent.description':
|
||||
"Il portachiavi del sistema operativo non è accessibile. OpenHuman necessita del tuo permesso per archiviare i segreti utilizzando l'archiviazione locale crittografata.",
|
||||
'keyring.consent.reasonPrefix': 'Motivo:',
|
||||
'keyring.consent.showDetails': 'Cosa significa?',
|
||||
'keyring.consent.hideDetails': 'Nascondi dettagli',
|
||||
'keyring.consent.tradeoffTitle': 'Compromesso di sicurezza',
|
||||
'keyring.consent.tradeoffBody':
|
||||
"Con l'archiviazione locale crittografata, i tuoi segreti vengono crittografati su disco utilizzando una chiave master archiviata insieme ai dati. Questo è meno sicuro del portachiavi del SO, che utilizza protezione hardware. I backup o la sincronizzazione dei file possono includere i dati crittografati.",
|
||||
'keyring.consent.consentButton': 'Usa archiviazione locale crittografata',
|
||||
'keyring.consent.retryButton': 'Riprova OS Keychain',
|
||||
'keyring.consent.declineButton': 'Rifiuta',
|
||||
'keyring.consent.retrying': 'Nuovo tentativo…',
|
||||
'keyring.consent.error': 'Impossibile salvare la preferenza. Riprova.',
|
||||
'keyring.consent.retryFailed': 'Il portachiavi è ancora non disponibile.',
|
||||
'keyring.settings.title': 'Sicurezza',
|
||||
'keyring.settings.storageMode': 'Modalità archiviazione segreti',
|
||||
'keyring.settings.mode.osKeychain': 'Portachiavi del SO',
|
||||
'keyring.settings.mode.encryptedFile': 'Locale crittografato',
|
||||
'keyring.settings.mode.consentPending': 'Non configurato',
|
||||
'keyring.settings.mode.declined': 'Rifiutato',
|
||||
'keyring.settings.availability': 'Disponibilità portachiavi',
|
||||
'keyring.settings.available': 'Il portachiavi del SO è disponibile',
|
||||
'keyring.settings.unavailable': 'Il portachiavi del SO non è disponibile',
|
||||
'keyring.settings.backend': 'Motore',
|
||||
'keyring.settings.retryButton': 'Riprova rilevamento portachiavi',
|
||||
'keyring.settings.retryFailed': 'Tentativo fallito. Il portachiavi è ancora non disponibile.',
|
||||
'keyring.settings.consentTitle': 'Consenso archiviazione',
|
||||
'keyring.settings.consentDescription':
|
||||
'Scegli come vengono archiviati i segreti quando il portachiavi del SO non è disponibile.',
|
||||
'keyring.settings.grantConsent': 'Consenti archiviazione locale crittografata',
|
||||
'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale',
|
||||
'pages.settings.account.security': 'Sicurezza',
|
||||
'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4357,6 +4357,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': '평균 군집계수 {avg} · 전이성 {transitivity}',
|
||||
'graphCohesion.title': '그래프 응집도',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': '보안 저장소를 사용할 수 없음',
|
||||
'keyring.consent.description':
|
||||
'운영 체제 키체인에 접근할 수 없습니다. OpenHuman이 로컬 암호화 저장소를 사용하여 비밀을 저장하려면 귀하의 허가가 필요합니다.',
|
||||
'keyring.consent.reasonPrefix': '이유:',
|
||||
'keyring.consent.showDetails': '이것은 무엇을 의미하나요?',
|
||||
'keyring.consent.hideDetails': '세부 정보 숨기기',
|
||||
'keyring.consent.tradeoffTitle': '보안 절충',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'로컬 암호화 저장소에서는 데이터와 함께 저장된 마스터 키를 사용하여 비밀이 디스크에 암호화됩니다. 이는 하드웨어 기반 보호를 사용하는 OS 키체인보다 덜 안전합니다. 백업이나 파일 동기화에 암호화된 데이터가 포함될 수 있습니다.',
|
||||
'keyring.consent.consentButton': '로컬 암호화 저장소 사용',
|
||||
'keyring.consent.retryButton': 'OS Keychain 재시도',
|
||||
'keyring.consent.declineButton': '거부',
|
||||
'keyring.consent.retrying': '재시도 중…',
|
||||
'keyring.consent.error': '설정을 저장하지 못했습니다. 다시 시도해 주세요.',
|
||||
'keyring.consent.retryFailed': '키체인이 여전히 사용할 수 없습니다.',
|
||||
'keyring.settings.title': '보안',
|
||||
'keyring.settings.storageMode': '비밀 저장 모드',
|
||||
'keyring.settings.mode.osKeychain': 'OS 키체인',
|
||||
'keyring.settings.mode.encryptedFile': '로컬 암호화',
|
||||
'keyring.settings.mode.consentPending': '구성되지 않음',
|
||||
'keyring.settings.mode.declined': '거부됨',
|
||||
'keyring.settings.availability': '키체인 가용성',
|
||||
'keyring.settings.available': 'OS 키체인 사용 가능',
|
||||
'keyring.settings.unavailable': 'OS 키체인 사용 불가',
|
||||
'keyring.settings.backend': '백엔드',
|
||||
'keyring.settings.retryButton': '키체인 감지 재시도',
|
||||
'keyring.settings.retryFailed': '재시도 실패. 키체인이 여전히 사용할 수 없습니다.',
|
||||
'keyring.settings.consentTitle': '저장소 동의',
|
||||
'keyring.settings.consentDescription':
|
||||
'OS 키체인을 사용할 수 없을 때 비밀을 저장하는 방법을 선택하세요.',
|
||||
'keyring.settings.grantConsent': '로컬 암호화 저장소 허용',
|
||||
'keyring.settings.revokeConsent': '로컬 저장소 거부',
|
||||
'pages.settings.account.security': '보안',
|
||||
'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4464,6 +4464,42 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Średnia klasteryzacja {avg} · tranzytywność {transitivity}',
|
||||
'graphCohesion.title': 'Spójność grafu',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Bezpieczne przechowywanie niedostępne',
|
||||
'keyring.consent.description':
|
||||
'Pęk kluczy systemu operacyjnego jest niedostępny. OpenHuman potrzebuje Twojej zgody na przechowywanie sekretów w lokalnym zaszyfrowanym magazynie.',
|
||||
'keyring.consent.reasonPrefix': 'Powód:',
|
||||
'keyring.consent.showDetails': 'Co to oznacza?',
|
||||
'keyring.consent.hideDetails': 'Ukryj szczegóły',
|
||||
'keyring.consent.tradeoffTitle': 'Kompromis bezpieczeństwa',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'W przypadku lokalnego zaszyfrowanego magazynu sekrety są szyfrowane na dysku przy użyciu klucza głównego przechowywanego obok danych. Jest to mniej bezpieczne niż pęk kluczy systemu, który wykorzystuje ochronę sprzętową. Kopie zapasowe lub synchronizacja plików mogą zawierać zaszyfrowane dane.',
|
||||
'keyring.consent.consentButton': 'Użyj lokalnego zaszyfrowanego magazynu',
|
||||
'keyring.consent.retryButton': 'Ponów próbę OS Keychain',
|
||||
'keyring.consent.declineButton': 'Odrzuć',
|
||||
'keyring.consent.retrying': 'Ponawiam próbę…',
|
||||
'keyring.consent.error': 'Nie udało się zapisać preferencji. Spróbuj ponownie.',
|
||||
'keyring.consent.retryFailed': 'Pęk kluczy jest nadal niedostępny.',
|
||||
'keyring.settings.title': 'Bezpieczeństwo',
|
||||
'keyring.settings.storageMode': 'Tryb przechowywania sekretów',
|
||||
'keyring.settings.mode.osKeychain': 'Pęk kluczy systemu',
|
||||
'keyring.settings.mode.encryptedFile': 'Lokalnie zaszyfrowany',
|
||||
'keyring.settings.mode.consentPending': 'Nie skonfigurowano',
|
||||
'keyring.settings.mode.declined': 'Odrzucono',
|
||||
'keyring.settings.availability': 'Dostępność pęku kluczy',
|
||||
'keyring.settings.available': 'Pęk kluczy systemu jest dostępny',
|
||||
'keyring.settings.unavailable': 'Pęk kluczy systemu jest niedostępny',
|
||||
'keyring.settings.backend': 'Silnik',
|
||||
'keyring.settings.retryButton': 'Ponów wykrywanie pęku kluczy',
|
||||
'keyring.settings.retryFailed':
|
||||
'Ponowna próba nie powiodła się. Pęk kluczy jest nadal niedostępny.',
|
||||
'keyring.settings.consentTitle': 'Zgoda na przechowywanie',
|
||||
'keyring.settings.consentDescription':
|
||||
'Wybierz sposób przechowywania sekretów, gdy pęk kluczy systemu jest niedostępny.',
|
||||
'keyring.settings.grantConsent': 'Zezwól na lokalne zaszyfrowane przechowywanie',
|
||||
'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania',
|
||||
'pages.settings.account.security': 'Bezpieczeństwo',
|
||||
'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4462,6 +4462,41 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Agrupamento médio {avg} · transitividade {transitivity}',
|
||||
'graphCohesion.title': 'Coesão do grafo',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Armazenamento seguro indisponível',
|
||||
'keyring.consent.description':
|
||||
'O chaveiro do sistema operacional não está acessível. O OpenHuman precisa da sua permissão para armazenar segredos usando armazenamento local criptografado.',
|
||||
'keyring.consent.reasonPrefix': 'Motivo:',
|
||||
'keyring.consent.showDetails': 'O que isso significa?',
|
||||
'keyring.consent.hideDetails': 'Ocultar detalhes',
|
||||
'keyring.consent.tradeoffTitle': 'Compromisso de segurança',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'Com o armazenamento local criptografado, seus segredos são criptografados no disco usando uma chave mestra armazenada junto aos dados. Isso é menos seguro que o chaveiro do SO, que usa proteção por hardware. Backups ou sincronização de arquivos podem incluir os dados criptografados.',
|
||||
'keyring.consent.consentButton': 'Usar armazenamento local criptografado',
|
||||
'keyring.consent.retryButton': 'Tentar novamente OS Keychain',
|
||||
'keyring.consent.declineButton': 'Recusar',
|
||||
'keyring.consent.retrying': 'Tentando novamente…',
|
||||
'keyring.consent.error': 'Falha ao salvar preferência. Tente novamente.',
|
||||
'keyring.consent.retryFailed': 'O chaveiro ainda está indisponível.',
|
||||
'keyring.settings.title': 'Segurança',
|
||||
'keyring.settings.storageMode': 'Modo de armazenamento de segredos',
|
||||
'keyring.settings.mode.osKeychain': 'Chaveiro do SO',
|
||||
'keyring.settings.mode.encryptedFile': 'Local criptografado',
|
||||
'keyring.settings.mode.consentPending': 'Não configurado',
|
||||
'keyring.settings.mode.declined': 'Recusado',
|
||||
'keyring.settings.availability': 'Disponibilidade do chaveiro',
|
||||
'keyring.settings.available': 'O chaveiro do SO está disponível',
|
||||
'keyring.settings.unavailable': 'O chaveiro do SO está indisponível',
|
||||
'keyring.settings.backend': 'Motor',
|
||||
'keyring.settings.retryButton': 'Tentar novamente detecção do chaveiro',
|
||||
'keyring.settings.retryFailed': 'Tentativa falhou. O chaveiro ainda está indisponível.',
|
||||
'keyring.settings.consentTitle': 'Consentimento de armazenamento',
|
||||
'keyring.settings.consentDescription':
|
||||
'Escolha como os segredos são armazenados quando o chaveiro do SO não está disponível.',
|
||||
'keyring.settings.grantConsent': 'Permitir armazenamento local criptografado',
|
||||
'keyring.settings.revokeConsent': 'Recusar armazenamento local',
|
||||
'pages.settings.account.security': 'Segurança',
|
||||
'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4432,6 +4432,42 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': 'Средняя кластеризация {avg} · транзитивность {transitivity}',
|
||||
'graphCohesion.title': 'Связность графа',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': 'Безопасное хранилище недоступно',
|
||||
'keyring.consent.description':
|
||||
'Связка ключей вашей операционной системы недоступна. OpenHuman необходимо ваше разрешение на хранение секретов в локальном зашифрованном хранилище.',
|
||||
'keyring.consent.reasonPrefix': 'Причина:',
|
||||
'keyring.consent.showDetails': 'Что это значит?',
|
||||
'keyring.consent.hideDetails': 'Скрыть подробности',
|
||||
'keyring.consent.tradeoffTitle': 'Компромисс безопасности',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'При локальном зашифрованном хранении ваши секреты шифруются на диске с помощью мастер-ключа, который хранится рядом с данными. Это менее безопасно, чем связка ключей ОС, которая использует аппаратную защиту. Резервные копии или синхронизация файлов могут включать зашифрованные данные.',
|
||||
'keyring.consent.consentButton': 'Использовать локальное зашифрованное хранилище',
|
||||
'keyring.consent.retryButton': 'Повторить OS Keychain',
|
||||
'keyring.consent.declineButton': 'Отклонить',
|
||||
'keyring.consent.retrying': 'Повторная попытка…',
|
||||
'keyring.consent.error': 'Не удалось сохранить настройку. Попробуйте снова.',
|
||||
'keyring.consent.retryFailed': 'Связка ключей по-прежнему недоступна.',
|
||||
'keyring.settings.title': 'Безопасность',
|
||||
'keyring.settings.storageMode': 'Режим хранения секретов',
|
||||
'keyring.settings.mode.osKeychain': 'Связка ключей ОС',
|
||||
'keyring.settings.mode.encryptedFile': 'Локальное шифрование',
|
||||
'keyring.settings.mode.consentPending': 'Не настроено',
|
||||
'keyring.settings.mode.declined': 'Отклонено',
|
||||
'keyring.settings.availability': 'Доступность связки ключей',
|
||||
'keyring.settings.available': 'Связка ключей ОС доступна',
|
||||
'keyring.settings.unavailable': 'Связка ключей ОС недоступна',
|
||||
'keyring.settings.backend': 'Движок',
|
||||
'keyring.settings.retryButton': 'Повторить обнаружение связки ключей',
|
||||
'keyring.settings.retryFailed':
|
||||
'Повторная попытка не удалась. Связка ключей по-прежнему недоступна.',
|
||||
'keyring.settings.consentTitle': 'Согласие на хранение',
|
||||
'keyring.settings.consentDescription':
|
||||
'Выберите способ хранения секретов, когда связка ключей ОС недоступна.',
|
||||
'keyring.settings.grantConsent': 'Разрешить локальное зашифрованное хранилище',
|
||||
'keyring.settings.revokeConsent': 'Отклонить локальное хранилище',
|
||||
'pages.settings.account.security': 'Безопасность',
|
||||
'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -4179,6 +4179,40 @@ const messages: TranslationMap = {
|
||||
'graphCohesion.summaryCaption': '平均聚类系数 {avg} · 传递性 {transitivity}',
|
||||
'graphCohesion.title': '图的凝聚度',
|
||||
'memory.tab.cohesion': 'Cohesion',
|
||||
|
||||
'keyring.consent.title': '安全存储不可用',
|
||||
'keyring.consent.description':
|
||||
'您的操作系统密钥链不可访问。OpenHuman 需要您的许可,以便使用本地加密存储来保存密钥。',
|
||||
'keyring.consent.reasonPrefix': '原因:',
|
||||
'keyring.consent.showDetails': '这是什么意思?',
|
||||
'keyring.consent.hideDetails': '隐藏详情',
|
||||
'keyring.consent.tradeoffTitle': '安全权衡',
|
||||
'keyring.consent.tradeoffBody':
|
||||
'使用本地加密存储时,您的密钥使用与数据一起存储的主密钥进行磁盘加密。这不如使用硬件保护的操作系统密钥链安全。备份或文件同步可能包含加密数据。',
|
||||
'keyring.consent.consentButton': '使用本地加密存储',
|
||||
'keyring.consent.retryButton': '重试 OS Keychain',
|
||||
'keyring.consent.declineButton': '拒绝',
|
||||
'keyring.consent.retrying': '正在重试…',
|
||||
'keyring.consent.error': '保存偏好失败,请重试。',
|
||||
'keyring.consent.retryFailed': '密钥链仍然不可用。',
|
||||
'keyring.settings.title': '安全',
|
||||
'keyring.settings.storageMode': '密钥存储模式',
|
||||
'keyring.settings.mode.osKeychain': '操作系统密钥链',
|
||||
'keyring.settings.mode.encryptedFile': '本地加密',
|
||||
'keyring.settings.mode.consentPending': '未配置',
|
||||
'keyring.settings.mode.declined': '已拒绝',
|
||||
'keyring.settings.availability': '密钥链可用性',
|
||||
'keyring.settings.available': '操作系统密钥链可用',
|
||||
'keyring.settings.unavailable': '操作系统密钥链不可用',
|
||||
'keyring.settings.backend': '后端',
|
||||
'keyring.settings.retryButton': '重试密钥链检测',
|
||||
'keyring.settings.retryFailed': '重试失败。密钥链仍然不可用。',
|
||||
'keyring.settings.consentTitle': '存储同意',
|
||||
'keyring.settings.consentDescription': '选择操作系统密钥链不可用时如何存储密钥。',
|
||||
'keyring.settings.grantConsent': '允许本地加密存储',
|
||||
'keyring.settings.revokeConsent': '拒绝本地存储',
|
||||
'pages.settings.account.security': '安全',
|
||||
'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态',
|
||||
};
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -40,6 +40,7 @@ import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePan
|
||||
import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel';
|
||||
import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel';
|
||||
import SearchPanel from '../components/settings/panels/SearchPanel';
|
||||
import SecurityPanel from '../components/settings/panels/SecurityPanel';
|
||||
import SkillsRunnerPanel from '../components/settings/panels/SkillsRunnerPanel';
|
||||
import TaskSourcesPanel from '../components/settings/panels/TaskSourcesPanel';
|
||||
import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel';
|
||||
@@ -90,6 +91,16 @@ const PrivacyIcon = (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const SecurityIcon = (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const MigrationIcon = (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -265,6 +276,13 @@ const Settings = () => {
|
||||
route: 'privacy',
|
||||
icon: PrivacyIcon,
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
title: t('pages.settings.account.security'),
|
||||
description: t('pages.settings.account.securityDesc'),
|
||||
route: 'security',
|
||||
icon: SecurityIcon,
|
||||
},
|
||||
{
|
||||
id: 'migration',
|
||||
title: t('pages.settings.account.migration'),
|
||||
@@ -557,6 +575,7 @@ const Settings = () => {
|
||||
{/* BillingPanel intentionally uses its own wider layout. */}
|
||||
<Route path="billing" element={<BillingPanel />} />
|
||||
<Route path="privacy" element={wrapSettingsPage(<PrivacyPanel />)} />
|
||||
<Route path="security" element={wrapSettingsPage(<SecurityPanel />)} />
|
||||
<Route path="migration" element={wrapSettingsPage(<MigrationPanel />)} />
|
||||
<Route path="wallet-balances" element={wrapSettingsPage(<WalletBalancesPanel />)} />
|
||||
{/* Features leaf panels */}
|
||||
|
||||
@@ -114,7 +114,13 @@ async function setupLayout() {
|
||||
onboardingCompleted: false,
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
isBootstrapping: false,
|
||||
|
||||
@@ -223,6 +223,13 @@ function normalizeSnapshot(
|
||||
localState: {
|
||||
encryptionKey: result.localState.encryptionKey ?? null,
|
||||
onboardingTasks: result.localState.onboardingTasks ?? null,
|
||||
keyringConsent: result.localState.keyringConsent ?? null,
|
||||
},
|
||||
keyringStatus: result.keyringStatus ?? {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: {
|
||||
screenIntelligence: result.runtime?.screenIntelligence ?? null,
|
||||
|
||||
@@ -75,7 +75,13 @@ function resetCoreStateStore() {
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
|
||||
@@ -102,7 +102,13 @@ function resetCoreStateStore() {
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { decideKeyringConsent, fetchKeyringStatus, retryKeyringProbe } from '../keyringApi';
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
const { callCoreRpc } = await import('../coreRpcClient');
|
||||
const mockRpc = vi.mocked(callCoreRpc);
|
||||
|
||||
describe('keyringApi', () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('fetchKeyringStatus calls correct RPC method', async () => {
|
||||
const status = { available: true, activeMode: 'os_keyring', backendName: 'os' };
|
||||
mockRpc.mockResolvedValueOnce({ result: status });
|
||||
|
||||
const result = await fetchKeyringStatus();
|
||||
expect(result).toEqual(status);
|
||||
expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.keyring_consent_status' });
|
||||
});
|
||||
|
||||
it('decideKeyringConsent sends mode parameter', async () => {
|
||||
const pref = { storageMode: 'local_encrypted', consentedAtMs: 123 };
|
||||
mockRpc.mockResolvedValueOnce({ result: pref });
|
||||
|
||||
const result = await decideKeyringConsent('local_encrypted');
|
||||
expect(result).toEqual(pref);
|
||||
expect(mockRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.keyring_consent_decide',
|
||||
params: { mode: 'local_encrypted' },
|
||||
});
|
||||
});
|
||||
|
||||
it('retryKeyringProbe calls correct RPC method', async () => {
|
||||
const status = { available: false, activeMode: 'consent_pending', backendName: 'os' };
|
||||
mockRpc.mockResolvedValueOnce({ result: status });
|
||||
|
||||
const result = await retryKeyringProbe();
|
||||
expect(result).toEqual(status);
|
||||
expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.keyring_consent_retry_probe' });
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,13 @@ function makeSnapshotResult(overrides: Record<string, unknown> = {}) {
|
||||
currentUser: null,
|
||||
onboardingCompleted: false,
|
||||
analyticsEnabled: true,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: {}, localAi: {}, autocomplete: {}, service: {} },
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -15,9 +15,22 @@ export interface OnboardingTasks {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface KeyringConsentPreference {
|
||||
storageMode: string;
|
||||
consentedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface KeyringStatus {
|
||||
available: boolean;
|
||||
failureReason?: string | null;
|
||||
activeMode: string;
|
||||
backendName: string;
|
||||
}
|
||||
|
||||
export interface UpdateCoreLocalStateParams {
|
||||
encryptionKey?: string | null;
|
||||
onboardingTasks?: OnboardingTasks | null;
|
||||
keyringConsent?: KeyringConsentPreference | null;
|
||||
}
|
||||
|
||||
interface AppStateSnapshotResult {
|
||||
@@ -39,7 +52,12 @@ interface AppStateSnapshotResult {
|
||||
* never observe `undefined` here.
|
||||
*/
|
||||
meetAutoOrchestratorHandoff?: boolean;
|
||||
localState: { encryptionKey?: string | null; onboardingTasks?: OnboardingTasks | null };
|
||||
localState: {
|
||||
encryptionKey?: string | null;
|
||||
onboardingTasks?: OnboardingTasks | null;
|
||||
keyringConsent?: KeyringConsentPreference | null;
|
||||
};
|
||||
keyringStatus?: KeyringStatus;
|
||||
runtime: {
|
||||
screenIntelligence: AccessibilityStatus;
|
||||
localAi: LocalAiStatus;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { callCoreRpc } from './coreRpcClient';
|
||||
import type { KeyringConsentPreference, KeyringStatus } from './coreStateApi';
|
||||
|
||||
export const fetchKeyringStatus = async (): Promise<KeyringStatus> => {
|
||||
const response = await callCoreRpc<{ result: KeyringStatus }>({
|
||||
method: 'openhuman.keyring_consent_status',
|
||||
});
|
||||
return response.result;
|
||||
};
|
||||
|
||||
export const decideKeyringConsent = async (
|
||||
mode: 'local_encrypted' | 'declined'
|
||||
): Promise<KeyringConsentPreference> => {
|
||||
const response = await callCoreRpc<{ result: KeyringConsentPreference }>({
|
||||
method: 'openhuman.keyring_consent_decide',
|
||||
params: { mode },
|
||||
});
|
||||
return response.result;
|
||||
};
|
||||
|
||||
export const retryKeyringProbe = async (): Promise<KeyringStatus> => {
|
||||
const response = await callCoreRpc<{ result: KeyringStatus }>({
|
||||
method: 'openhuman.keyring_consent_retry_probe',
|
||||
});
|
||||
return response.result;
|
||||
};
|
||||
@@ -22,7 +22,13 @@ function makeCoreState(token: string | null, userId: string | null = null): Core
|
||||
chatOnboardingCompleted: false,
|
||||
analyticsEnabled: false,
|
||||
meetAutoOrchestratorHandoff: false,
|
||||
localState: { encryptionKey: null, onboardingTasks: null },
|
||||
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
|
||||
keyringStatus: {
|
||||
available: true,
|
||||
failureReason: null,
|
||||
activeMode: 'os_keyring',
|
||||
backendName: 'os',
|
||||
},
|
||||
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
|
||||
},
|
||||
teams: [],
|
||||
|
||||
@@ -101,6 +101,35 @@ Current desktop builds migrate that material into the OS keyring and keep the en
|
||||
|
||||
***
|
||||
|
||||
## Consent flow when the keyring is unavailable
|
||||
|
||||
When the OS keyring is unreachable — for example on Linux without a Secret Service daemon, or macOS when keychain access is denied — OpenHuman **stops and asks** before falling back to local encrypted storage.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Detection** — On startup the core probes the OS keychain. If the probe fails, it classifies the reason (no daemon, locked, denied) and reports a structured `KeyringStatus` via the `openhuman.keyring_consent_status` RPC and the app snapshot.
|
||||
|
||||
2. **Consent prompt** — The first time a secret must be read or written and no consent has been recorded, a modal overlay explains what happened, what "store locally" means, and what the risks are. The user can:
|
||||
- **Use Local Encrypted Storage** — consent to ChaCha20-Poly1305 encrypted files (master key also on disk).
|
||||
- **Retry OS Keychain** — re-probe (useful after granting OS permission).
|
||||
- **Skip** — decline local storage; features that need secrets will be unavailable.
|
||||
|
||||
3. **Persisted preference** — The choice is recorded in `app-state.json` (`keyringConsent` field) and cached in-process. The app re-probes on each launch and re-prompts if the keyring becomes available after a local-only session.
|
||||
|
||||
4. **Settings visibility** — **Settings → Security** shows the active storage mode, keychain availability, failure reason, and buttons to retry or change consent.
|
||||
|
||||
### Unified fallback policy
|
||||
|
||||
Auth profiles, config secrets, wallet mnemonic, and the `secrets.enc` backend all call `keyring_consent::policy::check_secret_access()` instead of raw `is_available()`. This ensures no code path silently switches storage modes.
|
||||
|
||||
| Policy decision | Meaning |
|
||||
| --- | --- |
|
||||
| `Proceed` | OS keyring available, or user consented to local encrypted |
|
||||
| `ConsentRequired` | Keyring unavailable, no consent yet — block and prompt |
|
||||
| `Declined` | User refused local storage — skip the secret operation |
|
||||
|
||||
***
|
||||
|
||||
## Platform note
|
||||
|
||||
This page describes **desktop** OpenHuman: the Tauri app on macOS, Windows, and Linux.
|
||||
|
||||
@@ -139,6 +139,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(crate::openhuman::doctor::all_doctor_registered_controllers());
|
||||
// Secret storage and encryption
|
||||
controllers.extend(crate::openhuman::encryption::all_encryption_registered_controllers());
|
||||
// Keyring consent — user approval before local secret storage fallback
|
||||
controllers
|
||||
.extend(crate::openhuman::keyring_consent::all_keyring_consent_registered_controllers());
|
||||
// Security policy metadata
|
||||
controllers.extend(crate::openhuman::security::all_security_registered_controllers());
|
||||
// Interactive approval workflow (#1339 — gate external-effect tool calls)
|
||||
@@ -312,6 +315,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::health::all_health_controller_schemas());
|
||||
schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas());
|
||||
schemas.extend(crate::openhuman::encryption::all_encryption_controller_schemas());
|
||||
schemas.extend(crate::openhuman::keyring_consent::all_keyring_consent_controller_schemas());
|
||||
schemas.extend(crate::openhuman::security::all_security_controller_schemas());
|
||||
schemas.extend(crate::openhuman::approval::all_approval_controller_schemas());
|
||||
schemas.extend(crate::openhuman::artifacts::all_artifacts_controller_schemas());
|
||||
|
||||
@@ -652,6 +652,17 @@ pub enum DomainEvent {
|
||||
/// A component restart was observed.
|
||||
HealthRestarted { component: String },
|
||||
|
||||
// ── Keyring ─────────────────────────────────────────────────────────
|
||||
/// The OS keyring is unavailable and no user consent for local fallback
|
||||
/// has been recorded. Published once (deduplicated) when a secret
|
||||
/// operation hits the consent gate. The frontend surfaces a consent
|
||||
/// dialog in response.
|
||||
KeyringConsentRequired,
|
||||
/// A secret field failed to decrypt (rotated master key, corrupted
|
||||
/// ciphertext, keychain reset). Published so the frontend can surface
|
||||
/// a recovery prompt instead of silently clearing the field.
|
||||
KeyringDecryptFailed { field_name: String, reason: String },
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────
|
||||
/// The local app session is no longer valid — typically detected when
|
||||
/// the backend returns 401 to an LLM inference call or a JSON-RPC
|
||||
@@ -789,6 +800,8 @@ impl DomainEvent {
|
||||
| Self::HealthChanged { .. }
|
||||
| Self::HealthRestarted { .. } => "system",
|
||||
|
||||
Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring",
|
||||
|
||||
Self::SessionExpired { .. } => "auth",
|
||||
|
||||
Self::TaskSourceFetched { .. }
|
||||
@@ -879,6 +892,8 @@ impl DomainEvent {
|
||||
Self::AutonomyConfigChanged => "AutonomyConfigChanged",
|
||||
Self::HealthChanged { .. } => "HealthChanged",
|
||||
Self::HealthRestarted { .. } => "HealthRestarted",
|
||||
Self::KeyringConsentRequired => "KeyringConsentRequired",
|
||||
Self::KeyringDecryptFailed { .. } => "KeyringDecryptFailed",
|
||||
Self::SessionExpired { .. } => "SessionExpired",
|
||||
Self::ApprovalRequested { .. } => "ApprovalRequested",
|
||||
Self::ApprovalDecided { .. } => "ApprovalDecided",
|
||||
|
||||
@@ -79,6 +79,8 @@ pub struct StoredAppState {
|
||||
pub encryption_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub onboarding_tasks: Option<StoredOnboardingTasks>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub keyring_consent: Option<crate::openhuman::keyring_consent::ConsentPreference>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -102,6 +104,7 @@ pub struct AppStateSnapshot {
|
||||
/// issue #1299.
|
||||
pub meet_auto_orchestrator_handoff: bool,
|
||||
pub local_state: StoredAppState,
|
||||
pub keyring_status: crate::openhuman::keyring_consent::KeyringStatus,
|
||||
pub runtime: RuntimeSnapshot,
|
||||
}
|
||||
|
||||
@@ -121,6 +124,8 @@ pub struct StoredAppStatePatch {
|
||||
pub encryption_key: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub onboarding_tasks: Option<Option<StoredOnboardingTasks>>,
|
||||
#[serde(default)]
|
||||
pub keyring_consent: Option<Option<crate::openhuman::keyring_consent::ConsentPreference>>,
|
||||
}
|
||||
|
||||
fn app_state_path(config: &Config) -> Result<PathBuf, String> {
|
||||
@@ -257,7 +262,7 @@ fn save_stored_app_state_unlocked(config: &Config, state: &StoredAppState) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_stored_app_state(config: &Config, state: &StoredAppState) -> Result<(), String> {
|
||||
pub fn save_app_state(config: &Config, state: &StoredAppState) -> Result<(), String> {
|
||||
let _guard = APP_STATE_FILE_LOCK.lock();
|
||||
save_stored_app_state_unlocked(config, state)
|
||||
}
|
||||
@@ -649,6 +654,7 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
|
||||
let t_local_state = Instant::now();
|
||||
let local_state = load_stored_app_state(&config)?;
|
||||
crate::openhuman::keyring_consent::policy::initialize(local_state.keyring_consent.clone());
|
||||
let local_state_ms = t_local_state.elapsed().as_millis();
|
||||
|
||||
let total_ms = t_total.elapsed().as_millis();
|
||||
@@ -671,6 +677,8 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
runtime.service.state
|
||||
);
|
||||
|
||||
let keyring_status = crate::openhuman::keyring_consent::policy::current_status();
|
||||
|
||||
Ok(RpcOutcome::new(
|
||||
AppStateSnapshot {
|
||||
auth,
|
||||
@@ -681,6 +689,7 @@ pub async fn snapshot() -> Result<RpcOutcome<AppStateSnapshot>, String> {
|
||||
analytics_enabled: config.observability.analytics_enabled,
|
||||
meet_auto_orchestrator_handoff: config.meet.auto_orchestrator_handoff,
|
||||
local_state,
|
||||
keyring_status,
|
||||
runtime,
|
||||
},
|
||||
vec!["core app state snapshot fetched".to_string()],
|
||||
@@ -773,12 +782,17 @@ pub async fn update_local_state(
|
||||
current.onboarding_tasks = onboarding_tasks;
|
||||
}
|
||||
|
||||
if let Some(keyring_consent) = patch.keyring_consent {
|
||||
current.keyring_consent = keyring_consent;
|
||||
}
|
||||
|
||||
save_stored_app_state_unlocked(&config, ¤t)?;
|
||||
|
||||
debug!(
|
||||
"{LOG_PREFIX} local state updated encryption_key={} onboarding_tasks={}",
|
||||
"{LOG_PREFIX} local state updated encryption_key={} onboarding_tasks={} keyring_consent={}",
|
||||
current.encryption_key.is_some(),
|
||||
current.onboarding_tasks.is_some()
|
||||
current.onboarding_tasks.is_some(),
|
||||
current.keyring_consent.is_some(),
|
||||
);
|
||||
|
||||
Ok(RpcOutcome::new(
|
||||
|
||||
@@ -126,9 +126,10 @@ fn save_and_reload_stored_app_state_round_trips() {
|
||||
connected_sources: vec!["telegram".into()],
|
||||
updated_at_ms: Some(42),
|
||||
}),
|
||||
keyring_consent: None,
|
||||
};
|
||||
|
||||
save_stored_app_state(&cfg, &state).expect("save app state");
|
||||
save_app_state(&cfg, &state).expect("save app state");
|
||||
let reloaded = load_stored_app_state(&cfg).expect("reload app state");
|
||||
assert_eq!(reloaded.encryption_key, Some("enc-key".into()));
|
||||
let tasks = reloaded.onboarding_tasks.expect("onboarding tasks");
|
||||
|
||||
@@ -14,6 +14,8 @@ struct UpdateLocalStateParams {
|
||||
encryption_key: Option<Option<String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_patch")]
|
||||
onboarding_tasks: Option<Option<super::ops::StoredOnboardingTasks>>,
|
||||
#[serde(default, deserialize_with = "deserialize_nullable_patch")]
|
||||
keyring_consent: Option<Option<crate::openhuman::keyring_consent::ConsentPreference>>,
|
||||
}
|
||||
|
||||
pub fn all_app_state_controller_schemas() -> Vec<ControllerSchema> {
|
||||
@@ -63,6 +65,10 @@ pub fn app_state_schemas(function: &str) -> ControllerSchema {
|
||||
"onboardingTasks",
|
||||
"Set or clear locally stored onboarding task progress.",
|
||||
),
|
||||
optional_json(
|
||||
"keyringConsent",
|
||||
"Set or clear the user's keyring consent preference.",
|
||||
),
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
@@ -101,6 +107,7 @@ fn handle_update_local_state(params: Map<String, Value>) -> ControllerFuture {
|
||||
crate::openhuman::app_state::update_local_state(StoredAppStatePatch {
|
||||
encryption_key: payload.encryption_key,
|
||||
onboarding_tasks: payload.onboarding_tasks,
|
||||
keyring_consent: payload.keyring_consent,
|
||||
})
|
||||
.await?
|
||||
.into_cli_compatible_json()
|
||||
@@ -159,7 +166,7 @@ mod tests {
|
||||
let s = app_state_schemas("update_local_state");
|
||||
assert_eq!(s.namespace, "app_state");
|
||||
assert_eq!(s.function, "update_local_state");
|
||||
assert_eq!(s.inputs.len(), 2);
|
||||
assert_eq!(s.inputs.len(), 3);
|
||||
for input in &s.inputs {
|
||||
assert!(!input.required, "input '{}' should be optional", input.name);
|
||||
}
|
||||
|
||||
@@ -409,6 +409,10 @@ fn decrypt_optional_secret(
|
||||
log::warn!(
|
||||
"[config] Failed to decrypt {field_name} — field cleared (key inaccessible): {e}"
|
||||
);
|
||||
crate::openhuman::keyring_consent::policy::notify_decrypt_failure(
|
||||
field_name,
|
||||
&e.to_string(),
|
||||
);
|
||||
*value = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,19 +204,35 @@ pub struct AuthProfilesStore {
|
||||
impl AuthProfilesStore {
|
||||
pub fn new(state_dir: &Path, encrypt_secrets: bool) -> Self {
|
||||
let user_id = user_id_from_state_dir(state_dir);
|
||||
let use_keychain = crate::openhuman::keyring::is_available();
|
||||
let policy = crate::openhuman::keyring_consent::policy::check_secret_access();
|
||||
let use_keychain = policy == crate::openhuman::keyring_consent::PolicyDecision::Proceed
|
||||
&& crate::openhuman::keyring::is_available();
|
||||
log::debug!(
|
||||
"[auth] AuthProfilesStore::new state_dir={} user_id={user_id} use_keychain={use_keychain}",
|
||||
"[auth] AuthProfilesStore::new state_dir={} user_id={user_id} use_keychain={use_keychain} policy={policy:?}",
|
||||
state_dir.display()
|
||||
);
|
||||
if !use_keychain {
|
||||
// Surface the consequence of a failed keychain probe at info: auth
|
||||
// secrets will be read/written via the encrypted JSON fallback, not
|
||||
// the OS keychain. This is the state change that drove the
|
||||
// "logged out / no backend session token" confusion.
|
||||
log::info!(
|
||||
"[auth] keychain unavailable (is_available=false) — using encrypted JSON for auth profiles user_id={user_id}"
|
||||
);
|
||||
match policy {
|
||||
crate::openhuman::keyring_consent::PolicyDecision::Proceed => {
|
||||
if !use_keychain {
|
||||
// OS keychain unavailable despite Proceed policy (probe failed).
|
||||
log::info!(
|
||||
"[auth] OS keychain unavailable — using encrypted JSON for auth profiles user_id={user_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::openhuman::keyring_consent::PolicyDecision::ConsentRequired => {
|
||||
log::warn!(
|
||||
"[auth] keyring consent has not been given — secrets will NOT be persisted \
|
||||
to the OS keychain until the user grants consent. \
|
||||
Falling back to encrypted JSON for auth profiles user_id={user_id}"
|
||||
);
|
||||
}
|
||||
crate::openhuman::keyring_consent::PolicyDecision::Declined => {
|
||||
log::warn!(
|
||||
"[auth] user explicitly declined OS keychain storage — \
|
||||
using encrypted JSON for auth profiles user_id={user_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
path: state_dir.join(PROFILES_FILENAME),
|
||||
|
||||
@@ -43,7 +43,8 @@ pub use encrypted_file_backend::init_master_key;
|
||||
pub use encrypted_store::SecretStore;
|
||||
pub use error::KeyringError;
|
||||
pub use ops::{
|
||||
delete, get, get_or_create_random, is_available, migrate_from_file, set, MigrationOutcome,
|
||||
backend_name, delete, get, get_or_create_random, is_available, migrate_from_file,
|
||||
reset_availability_cache, set, MigrationOutcome,
|
||||
};
|
||||
pub use store::init_workspace;
|
||||
|
||||
|
||||
@@ -3,20 +3,26 @@
|
||||
//! All public functions delegate to the active backend selected by [`crate::openhuman::keyring::store`].
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chacha20poly1305::aead::{rand_core::RngCore, OsRng};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::openhuman::keyring::error::KeyringError;
|
||||
use crate::openhuman::keyring::store::backend;
|
||||
|
||||
// Cached result of the one-time keychain probe. Running the probe on every
|
||||
// `is_available()` call means 4 OS-keychain round-trips (delete / set / get /
|
||||
// delete) per call, which triggers repeated macOS access-permission dialogs
|
||||
// and starves callers that poll frequently (e.g. snapshot, wallet guards).
|
||||
// The backend selection is already frozen in a OnceLock, so the probe result
|
||||
// is stable for the lifetime of the process — caching it here is safe.
|
||||
static AVAILABILITY_CACHE: OnceLock<bool> = OnceLock::new();
|
||||
// Cached result of the keychain probe. A single Mutex<Option<bool>> is used
|
||||
// instead of a separate AtomicBool + RwLock pair to eliminate the race where
|
||||
// thread A sets AVAILABILITY_PROBED=true before writing the result, causing
|
||||
// thread B to read None from the cache and incorrectly return false.
|
||||
//
|
||||
// With a single Mutex the first thread to acquire it runs the probe and stores
|
||||
// the result; all other threads block on the Mutex until the result is ready,
|
||||
// then read it on the same lock acquisition.
|
||||
//
|
||||
// The Mutex is never held across async suspension points so contention is
|
||||
// bounded to the probe duration (a single keychain round-trip on the first
|
||||
// call, a pointer read on every subsequent call).
|
||||
static AVAILABILITY_CACHE: Mutex<Option<bool>> = Mutex::new(None);
|
||||
|
||||
// ── Outcome type ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -97,7 +103,29 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> {
|
||||
/// the macOS access-permission dialogs they trigger) when polled by
|
||||
/// wallet guards or snapshot loops.
|
||||
pub fn is_available() -> bool {
|
||||
*AVAILABILITY_CACHE.get_or_init(probe_availability)
|
||||
let mut cached = AVAILABILITY_CACHE.lock();
|
||||
if let Some(val) = *cached {
|
||||
return val;
|
||||
}
|
||||
// First caller: run the probe under the lock so concurrent callers block
|
||||
// until the result is ready rather than racing on a separate atomic flag.
|
||||
let result = probe_availability();
|
||||
*cached = Some(result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Reset the cached probe result so the next [`is_available`] call re-runs
|
||||
/// the OS keychain probe. Used by the retry-probe flow when the user grants
|
||||
/// keychain access from Settings.
|
||||
pub fn reset_availability_cache() {
|
||||
log::info!("[keyring] reset_availability_cache: clearing cached probe result");
|
||||
*AVAILABILITY_CACHE.lock() = None;
|
||||
}
|
||||
|
||||
/// Returns the name of the active keyring backend (e.g. `"os"`, `"file"`,
|
||||
/// `"encrypted_file"`).
|
||||
pub fn backend_name() -> String {
|
||||
backend().name().to_string()
|
||||
}
|
||||
|
||||
fn probe_availability() -> bool {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Keyring consent domain — explicit user consent before falling back to local
|
||||
//! encrypted storage when the OS keyring is unavailable.
|
||||
|
||||
pub mod ops;
|
||||
pub mod policy;
|
||||
mod schemas;
|
||||
pub mod types;
|
||||
|
||||
pub use schemas::{
|
||||
all_keyring_consent_controller_schemas, all_keyring_consent_registered_controllers,
|
||||
};
|
||||
pub use types::{ConsentPreference, KeyringStatus, PolicyDecision, StorageMode};
|
||||
@@ -0,0 +1,88 @@
|
||||
//! RPC handler implementations for keyring consent.
|
||||
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
use super::policy;
|
||||
use super::types::{ConsentPreference, KeyringStatus};
|
||||
|
||||
const LOG_PREFIX: &str = "[keyring_consent]";
|
||||
|
||||
pub async fn keyring_status() -> Result<RpcOutcome<KeyringStatus>, String> {
|
||||
let status = policy::current_status();
|
||||
log::debug!(
|
||||
"{LOG_PREFIX} keyring_status available={} mode={} backend={}",
|
||||
status.available,
|
||||
status.active_mode,
|
||||
status.backend_name,
|
||||
);
|
||||
Ok(RpcOutcome::single_log(status, "keyring status fetched"))
|
||||
}
|
||||
|
||||
pub async fn keyring_consent_decide(mode: String) -> Result<RpcOutcome<ConsentPreference>, String> {
|
||||
if mode != "local_encrypted" && mode != "declined" {
|
||||
return Err(format!(
|
||||
"invalid mode '{mode}': expected 'local_encrypted' or 'declined'"
|
||||
));
|
||||
}
|
||||
log::info!("{LOG_PREFIX} keyring_consent_decide mode={mode}");
|
||||
|
||||
// Build the preference value without touching the in-memory cache yet.
|
||||
let pref = policy::build_consent_preference(&mode);
|
||||
|
||||
// Persist to disk first. If this fails we return an error without
|
||||
// updating the cache, so cache and disk stay consistent.
|
||||
persist_consent(&pref).await?;
|
||||
|
||||
// Only update the in-memory cache after a successful persist.
|
||||
policy::apply_consent(&pref);
|
||||
|
||||
Ok(RpcOutcome::single_log(
|
||||
pref,
|
||||
format!("keyring consent recorded: {mode}"),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn keyring_retry_probe() -> Result<RpcOutcome<KeyringStatus>, String> {
|
||||
log::info!("{LOG_PREFIX} keyring_retry_probe");
|
||||
let status = policy::retry_probe();
|
||||
Ok(RpcOutcome::single_log(
|
||||
status,
|
||||
"keyring probe retried".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn persist_consent(pref: &ConsentPreference) -> Result<(), String> {
|
||||
let patch = crate::openhuman::app_state::StoredAppStatePatch {
|
||||
keyring_consent: Some(Some(pref.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
crate::openhuman::app_state::update_local_state(patch).await?;
|
||||
log::debug!("{LOG_PREFIX} consent persisted to app state");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn keyring_status_returns_ok() {
|
||||
let result = keyring_status().await;
|
||||
assert!(result.is_ok());
|
||||
let outcome = result.unwrap();
|
||||
assert!(!outcome.value.backend_name.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keyring_consent_decide_rejects_invalid_mode() {
|
||||
let result = keyring_consent_decide("invalid".to_string()).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("invalid mode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keyring_retry_probe_returns_ok() {
|
||||
let result = keyring_retry_probe().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Unified keyring fallback policy gate.
|
||||
//!
|
||||
//! All code paths that read or write secrets should call [`check_secret_access`]
|
||||
//! instead of raw `keyring::is_available()`. This centralises the consent check
|
||||
//! so the app never silently falls back to local encrypted storage without the
|
||||
//! user's explicit agreement.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use super::types::{
|
||||
ConsentPreference, KeyringFailureReason, KeyringStatus, PolicyDecision, StorageMode,
|
||||
};
|
||||
|
||||
const LOG_PREFIX: &str = "[keyring_consent]";
|
||||
|
||||
static CONSENT_EVENT_PUBLISHED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Process-wide cached consent preference. Updated by [`record_consent`] and
|
||||
/// [`initialize`]. Read by [`check_secret_access`] and [`current_status`] so
|
||||
/// they never touch disk on the hot path.
|
||||
static CONSENT_CACHE: RwLock<Option<ConsentPreference>> = RwLock::new(None);
|
||||
|
||||
/// Pre-populate the consent cache from persisted app state. Call once at core
|
||||
/// startup after config is loadable.
|
||||
pub fn initialize(consent: Option<ConsentPreference>) {
|
||||
info!(
|
||||
"{LOG_PREFIX} initialize cached_consent={}",
|
||||
consent.as_ref().map_or("none", |p| p.storage_mode.as_str()),
|
||||
);
|
||||
*CONSENT_CACHE.write() = consent;
|
||||
}
|
||||
|
||||
/// Check whether the caller is allowed to proceed with secret storage.
|
||||
pub fn check_secret_access() -> PolicyDecision {
|
||||
if crate::openhuman::keyring::is_available() {
|
||||
return PolicyDecision::Proceed;
|
||||
}
|
||||
|
||||
let cached = CONSENT_CACHE.read().clone();
|
||||
match cached {
|
||||
Some(ref pref) if pref.storage_mode == "local_encrypted" => {
|
||||
debug!("{LOG_PREFIX} check_secret_access: consent=local_encrypted, proceeding");
|
||||
PolicyDecision::Proceed
|
||||
}
|
||||
Some(ref pref) if pref.storage_mode == "declined" => {
|
||||
debug!("{LOG_PREFIX} check_secret_access: consent=declined");
|
||||
PolicyDecision::Declined
|
||||
}
|
||||
_ => {
|
||||
debug!("{LOG_PREFIX} check_secret_access: keyring unavailable, no consent recorded");
|
||||
if !CONSENT_EVENT_PUBLISHED.swap(true, Ordering::SeqCst) {
|
||||
info!("{LOG_PREFIX} publishing KeyringConsentRequired event");
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::KeyringConsentRequired,
|
||||
);
|
||||
}
|
||||
PolicyDecision::ConsentRequired
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the current keyring status for RPC / snapshot consumption.
|
||||
pub fn current_status() -> KeyringStatus {
|
||||
let available = crate::openhuman::keyring::is_available();
|
||||
let backend_name = crate::openhuman::keyring::backend_name();
|
||||
|
||||
let (active_mode, failure_reason) = if available {
|
||||
(StorageMode::OsKeyring, None)
|
||||
} else {
|
||||
let reason = classify_failure_reason(&backend_name);
|
||||
let cached = CONSENT_CACHE.read().clone();
|
||||
let mode = match cached {
|
||||
Some(ref p) if p.storage_mode == "local_encrypted" => StorageMode::LocalEncrypted,
|
||||
Some(ref p) if p.storage_mode == "declined" => StorageMode::Declined,
|
||||
_ => StorageMode::ConsentPending,
|
||||
};
|
||||
(mode, Some(reason))
|
||||
};
|
||||
|
||||
KeyringStatus {
|
||||
available,
|
||||
failure_reason,
|
||||
active_mode,
|
||||
backend_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a consent preference value without touching the in-memory cache.
|
||||
///
|
||||
/// Callers that need to persist before caching should use this together with
|
||||
/// [`apply_consent`]: build → persist → apply. This ordering ensures the cache
|
||||
/// and disk never diverge (if persistence fails the cache is not updated).
|
||||
pub fn build_consent_preference(mode: &str) -> ConsentPreference {
|
||||
let now_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
ConsentPreference {
|
||||
storage_mode: mode.to_string(),
|
||||
consented_at_ms: Some(now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a previously-built consent preference to the in-memory cache.
|
||||
///
|
||||
/// Call this only after the preference has been successfully persisted to disk.
|
||||
pub fn apply_consent(pref: &ConsentPreference) {
|
||||
info!(
|
||||
"{LOG_PREFIX} apply_consent mode={} at_ms={}",
|
||||
pref.storage_mode,
|
||||
pref.consented_at_ms.unwrap_or(0),
|
||||
);
|
||||
*CONSENT_CACHE.write() = Some(pref.clone());
|
||||
CONSENT_EVENT_PUBLISHED.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Record the user's consent decision: update the in-memory cache and return
|
||||
/// the preference for the RPC caller to persist via `update_local_state`.
|
||||
///
|
||||
/// Prefer the [`build_consent_preference`] + [`apply_consent`] pair when you
|
||||
/// need to guarantee persistence happens before the cache is updated.
|
||||
pub fn record_consent(mode: &str) -> ConsentPreference {
|
||||
let pref = build_consent_preference(mode);
|
||||
info!(
|
||||
"{LOG_PREFIX} record_consent mode={mode} at_ms={}",
|
||||
pref.consented_at_ms.unwrap_or(0)
|
||||
);
|
||||
apply_consent(&pref);
|
||||
pref
|
||||
}
|
||||
|
||||
/// Reset the cached keyring probe and re-run it.
|
||||
pub fn retry_probe() -> KeyringStatus {
|
||||
info!("{LOG_PREFIX} retry_probe: resetting availability cache");
|
||||
crate::openhuman::keyring::reset_availability_cache();
|
||||
CONSENT_EVENT_PUBLISHED.store(false, Ordering::SeqCst);
|
||||
current_status()
|
||||
}
|
||||
|
||||
/// Publish a decrypt-failure event for frontend notification.
|
||||
pub fn notify_decrypt_failure(field_name: &str, reason: &str) {
|
||||
warn!("{LOG_PREFIX} decrypt failure field={field_name} reason={reason}");
|
||||
crate::core::event_bus::publish_global(
|
||||
crate::core::event_bus::DomainEvent::KeyringDecryptFailed {
|
||||
field_name: field_name.to_string(),
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn classify_failure_reason(backend_name: &str) -> KeyringFailureReason {
|
||||
match backend_name {
|
||||
"os" => {
|
||||
if cfg!(target_os = "linux") {
|
||||
KeyringFailureReason::NoSecretService
|
||||
} else if cfg!(target_os = "macos") {
|
||||
KeyringFailureReason::AccessDenied
|
||||
} else {
|
||||
KeyringFailureReason::Unknown("OS keyring probe failed".to_string())
|
||||
}
|
||||
}
|
||||
"encrypted_file" => KeyringFailureReason::MasterKeyUnavailable,
|
||||
_ => KeyringFailureReason::Unknown(format!("Backend '{backend_name}' unavailable")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify_failure_linux() {
|
||||
if cfg!(target_os = "linux") {
|
||||
let reason = classify_failure_reason("os");
|
||||
assert_eq!(reason, KeyringFailureReason::NoSecretService);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_failure_macos() {
|
||||
if cfg!(target_os = "macos") {
|
||||
let reason = classify_failure_reason("os");
|
||||
assert_eq!(reason, KeyringFailureReason::AccessDenied);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_failure_encrypted_file() {
|
||||
let reason = classify_failure_reason("encrypted_file");
|
||||
assert_eq!(reason, KeyringFailureReason::MasterKeyUnavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_failure_unknown() {
|
||||
let reason = classify_failure_reason("weird_backend");
|
||||
assert!(matches!(reason, KeyringFailureReason::Unknown(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_consent_updates_cache() {
|
||||
let pref = record_consent("local_encrypted");
|
||||
assert_eq!(pref.storage_mode, "local_encrypted");
|
||||
assert!(pref.consented_at_ms.is_some());
|
||||
|
||||
let cached = CONSENT_CACHE.read().clone();
|
||||
assert!(cached.is_some());
|
||||
assert_eq!(cached.unwrap().storage_mode, "local_encrypted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_populates_cache() {
|
||||
let pref = ConsentPreference {
|
||||
storage_mode: "declined".to_string(),
|
||||
consented_at_ms: Some(12345),
|
||||
};
|
||||
initialize(Some(pref.clone()));
|
||||
let cached = CONSENT_CACHE.read().clone();
|
||||
assert_eq!(cached.unwrap().storage_mode, "declined");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Controller registration for keyring consent RPC methods.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::{ControllerFuture, RegisteredController};
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
|
||||
pub fn all_keyring_consent_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
keyring_consent_schema("status"),
|
||||
keyring_consent_schema("decide"),
|
||||
keyring_consent_schema("retry_probe"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_keyring_consent_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: keyring_consent_schema("status"),
|
||||
handler: handle_status,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: keyring_consent_schema("decide"),
|
||||
handler: handle_decide,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: keyring_consent_schema("retry_probe"),
|
||||
handler: handle_retry_probe,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn keyring_consent_schema(function: &str) -> ControllerSchema {
|
||||
match function {
|
||||
"status" => ControllerSchema {
|
||||
namespace: "keyring_consent",
|
||||
function: "status",
|
||||
description: "Returns the current keyring availability, failure reason, active storage mode, and backend name.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Structured keyring status.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"decide" => ControllerSchema {
|
||||
namespace: "keyring_consent",
|
||||
function: "decide",
|
||||
description: "Record the user's consent decision for local secret storage fallback.",
|
||||
inputs: vec![FieldSchema {
|
||||
name: "mode",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Either 'local_encrypted' (consent to local storage) or 'declined' (refuse local storage).",
|
||||
required: true,
|
||||
}],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Persisted consent preference.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"retry_probe" => ControllerSchema {
|
||||
namespace: "keyring_consent",
|
||||
function: "retry_probe",
|
||||
description: "Reset the cached keyring probe and re-test OS keyring availability.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "result",
|
||||
ty: TypeSchema::Json,
|
||||
comment: "Updated keyring status after re-probe.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_ => ControllerSchema {
|
||||
namespace: "keyring_consent",
|
||||
function: "unknown",
|
||||
description: "Unknown keyring_consent controller.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error details.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_status(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
super::ops::keyring_status()
|
||||
.await?
|
||||
.into_cli_compatible_json()
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_decide(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let mode = params
|
||||
.get("mode")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("missing required param 'mode'")?
|
||||
.to_string();
|
||||
super::ops::keyring_consent_decide(mode)
|
||||
.await?
|
||||
.into_cli_compatible_json()
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_retry_probe(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
super::ops::keyring_retry_probe()
|
||||
.await?
|
||||
.into_cli_compatible_json()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn schemas_and_controllers_match() {
|
||||
let s = all_keyring_consent_controller_schemas();
|
||||
let c = all_keyring_consent_registered_controllers();
|
||||
assert_eq!(s.len(), c.len());
|
||||
for (schema, ctrl) in s.iter().zip(c.iter()) {
|
||||
assert_eq!(schema.function, ctrl.schema.function);
|
||||
assert_eq!(schema.namespace, ctrl.schema.namespace);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_schemas_use_keyring_consent_namespace() {
|
||||
for s in all_keyring_consent_controller_schemas() {
|
||||
assert_eq!(s.namespace, "keyring_consent");
|
||||
assert!(!s.description.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_schema_requires_mode() {
|
||||
let s = keyring_consent_schema("decide");
|
||||
assert_eq!(s.inputs.len(), 1);
|
||||
assert!(s.inputs[0].required);
|
||||
assert_eq!(s.inputs[0].name, "mode");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Types for the keyring consent domain.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StorageMode {
|
||||
OsKeyring,
|
||||
LocalEncrypted,
|
||||
ConsentPending,
|
||||
Declined,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StorageMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::OsKeyring => write!(f, "os_keyring"),
|
||||
Self::LocalEncrypted => write!(f, "local_encrypted"),
|
||||
Self::ConsentPending => write!(f, "consent_pending"),
|
||||
Self::Declined => write!(f, "declined"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KeyringFailureReason {
|
||||
NoSecretService,
|
||||
KeychainLocked,
|
||||
AccessDenied,
|
||||
MasterKeyUnavailable,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KeyringFailureReason {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoSecretService => write!(f, "No Secret Service daemon available"),
|
||||
Self::KeychainLocked => write!(f, "OS keychain is locked"),
|
||||
Self::AccessDenied => write!(f, "Access to OS keychain was denied"),
|
||||
Self::MasterKeyUnavailable => write!(f, "Master encryption key unavailable"),
|
||||
Self::Unknown(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyringStatus {
|
||||
pub available: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub failure_reason: Option<KeyringFailureReason>,
|
||||
pub active_mode: StorageMode,
|
||||
pub backend_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsentPreference {
|
||||
#[serde(default)]
|
||||
pub storage_mode: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub consented_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PolicyDecision {
|
||||
Proceed,
|
||||
ConsentRequired,
|
||||
Declined,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn storage_mode_serialization_roundtrip() {
|
||||
let modes = [
|
||||
StorageMode::OsKeyring,
|
||||
StorageMode::LocalEncrypted,
|
||||
StorageMode::ConsentPending,
|
||||
StorageMode::Declined,
|
||||
];
|
||||
for mode in modes {
|
||||
let json = serde_json::to_string(&mode).unwrap();
|
||||
let deserialized: StorageMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(mode, deserialized);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_mode_display() {
|
||||
assert_eq!(StorageMode::OsKeyring.to_string(), "os_keyring");
|
||||
assert_eq!(StorageMode::ConsentPending.to_string(), "consent_pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_reason_display() {
|
||||
assert_eq!(
|
||||
KeyringFailureReason::NoSecretService.to_string(),
|
||||
"No Secret Service daemon available"
|
||||
);
|
||||
assert_eq!(
|
||||
KeyringFailureReason::Unknown("custom".to_string()).to_string(),
|
||||
"custom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyring_status_serialization() {
|
||||
let status = KeyringStatus {
|
||||
available: false,
|
||||
failure_reason: Some(KeyringFailureReason::NoSecretService),
|
||||
active_mode: StorageMode::ConsentPending,
|
||||
backend_name: "os".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&status).unwrap();
|
||||
assert_eq!(json["available"], false);
|
||||
assert_eq!(json["activeMode"], "consent_pending");
|
||||
assert_eq!(json["failureReason"], "no_secret_service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyring_status_omits_none_failure_reason() {
|
||||
let status = KeyringStatus {
|
||||
available: true,
|
||||
failure_reason: None,
|
||||
active_mode: StorageMode::OsKeyring,
|
||||
backend_name: "os".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&status).unwrap();
|
||||
assert!(!json.as_object().unwrap().contains_key("failureReason"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_preference_defaults() {
|
||||
let pref = ConsentPreference::default();
|
||||
assert_eq!(pref.storage_mode, "");
|
||||
assert!(pref.consented_at_ms.is_none());
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ pub mod inference;
|
||||
pub mod integrations;
|
||||
pub mod javascript;
|
||||
pub mod keyring;
|
||||
pub mod keyring_consent;
|
||||
pub mod learning;
|
||||
pub mod mcp_audit;
|
||||
pub mod mcp_client;
|
||||
|
||||
@@ -52,8 +52,11 @@ fn wallet_user_id(config: &Config) -> String {
|
||||
///
|
||||
/// Returns `None` if the keychain is unavailable or the entry does not exist.
|
||||
fn keychain_load_mnemonic(config: &Config) -> Option<String> {
|
||||
if !crate::openhuman::keyring::is_available() {
|
||||
log::debug!("{LOG_PREFIX} keychain unavailable, skipping mnemonic load");
|
||||
let policy = crate::openhuman::keyring_consent::policy::check_secret_access();
|
||||
if policy != crate::openhuman::keyring_consent::PolicyDecision::Proceed
|
||||
|| !crate::openhuman::keyring::is_available()
|
||||
{
|
||||
log::debug!("{LOG_PREFIX} keychain unavailable or consent pending, skipping mnemonic load policy={policy:?}");
|
||||
return None;
|
||||
}
|
||||
let user_id = wallet_user_id(config);
|
||||
@@ -77,8 +80,11 @@ fn keychain_load_mnemonic(config: &Config) -> Option<String> {
|
||||
///
|
||||
/// Returns `true` if the write succeeded.
|
||||
fn keychain_save_mnemonic(config: &Config, encrypted_mnemonic: &str) -> bool {
|
||||
if !crate::openhuman::keyring::is_available() {
|
||||
log::debug!("{LOG_PREFIX} keychain unavailable, skipping mnemonic save");
|
||||
let policy = crate::openhuman::keyring_consent::policy::check_secret_access();
|
||||
if policy != crate::openhuman::keyring_consent::PolicyDecision::Proceed
|
||||
|| !crate::openhuman::keyring::is_available()
|
||||
{
|
||||
log::debug!("{LOG_PREFIX} keychain unavailable or consent pending, skipping mnemonic save policy={policy:?}");
|
||||
return false;
|
||||
}
|
||||
let user_id = wallet_user_id(config);
|
||||
@@ -96,7 +102,10 @@ fn keychain_save_mnemonic(config: &Config, encrypted_mnemonic: &str) -> bool {
|
||||
|
||||
/// Whether a keychain entry exists for the encrypted mnemonic.
|
||||
fn keychain_has_mnemonic(config: &Config) -> bool {
|
||||
if !crate::openhuman::keyring::is_available() {
|
||||
let policy = crate::openhuman::keyring_consent::policy::check_secret_access();
|
||||
if policy != crate::openhuman::keyring_consent::PolicyDecision::Proceed
|
||||
|| !crate::openhuman::keyring::is_available()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let user_id = wallet_user_id(config);
|
||||
|
||||
@@ -245,6 +245,7 @@ async fn round19_app_state_local_state_snapshot_and_corruption_edges() {
|
||||
.expect("seed local app session");
|
||||
|
||||
let updated = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" round19-key ".to_string())),
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: true,
|
||||
@@ -284,6 +285,7 @@ async fn round19_app_state_local_state_snapshot_and_corruption_edges() {
|
||||
assert!(snap.meet_auto_orchestrator_handoff);
|
||||
|
||||
let cleared = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" ".to_string())),
|
||||
onboarding_tasks: Some(None),
|
||||
})
|
||||
|
||||
@@ -142,6 +142,7 @@ async fn round24_app_state_update_and_snapshot_preserve_local_state() {
|
||||
};
|
||||
|
||||
let updated = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" round24-key ".into())),
|
||||
onboarding_tasks: Some(Some(tasks.clone())),
|
||||
})
|
||||
@@ -166,6 +167,7 @@ async fn round24_app_state_update_and_snapshot_preserve_local_state() {
|
||||
assert!(snapshot.session_token.is_none());
|
||||
|
||||
let cleared = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" ".into())),
|
||||
onboarding_tasks: Some(None),
|
||||
})
|
||||
|
||||
@@ -154,6 +154,7 @@ async fn round26_app_state_quarantines_corrupt_local_file_and_preserves_patch_no
|
||||
);
|
||||
|
||||
let unchanged = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: None,
|
||||
onboarding_tasks: None,
|
||||
})
|
||||
|
||||
@@ -220,6 +220,7 @@ async fn round14_snapshot_preserves_rich_local_state_with_backend_or_stored_user
|
||||
.expect("seed app session profile");
|
||||
|
||||
let updated = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" round14-key ".to_string())),
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: true,
|
||||
|
||||
@@ -440,6 +440,7 @@ async fn round15_app_state_corruption_clear_and_snapshot_local_session_paths() {
|
||||
.expect("state dir");
|
||||
std::fs::write(harness.app_state_file(), "{bad-json").expect("write corrupt app state");
|
||||
let recovered = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" round15-secret ".to_string())),
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: true,
|
||||
@@ -466,6 +467,7 @@ async fn round15_app_state_corruption_clear_and_snapshot_local_session_paths() {
|
||||
assert!(quarantined, "corrupt app state should be quarantined");
|
||||
|
||||
let cleared = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(None),
|
||||
onboarding_tasks: Some(None),
|
||||
})
|
||||
|
||||
@@ -511,6 +511,7 @@ async fn round18_app_state_snapshot_uses_local_session_cache_and_patch_edges() {
|
||||
.expect("store local app session");
|
||||
|
||||
let first = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" ".to_string())),
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: true,
|
||||
@@ -528,6 +529,7 @@ async fn round18_app_state_snapshot_uses_local_session_cache_and_patch_edges() {
|
||||
assert!(first.onboarding_tasks.is_some());
|
||||
|
||||
let cleared = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(None),
|
||||
onboarding_tasks: Some(None),
|
||||
})
|
||||
|
||||
@@ -340,6 +340,7 @@ async fn round17_ops_trigger_history_app_state_and_profiles_cover_local_edges()
|
||||
std::fs::create_dir_all(&state_dir).expect("state dir");
|
||||
std::fs::write(state_dir.join("app-state.json"), "{not-json").expect("corrupt state");
|
||||
let updated = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" round17-key ".into())),
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: false,
|
||||
|
||||
@@ -283,6 +283,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_
|
||||
let harness = round13_setup();
|
||||
|
||||
let updated = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" raw-key ".to_string())),
|
||||
onboarding_tasks: Some(Some(Default::default())),
|
||||
})
|
||||
@@ -293,6 +294,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_
|
||||
assert!(updated.onboarding_tasks.is_some());
|
||||
|
||||
let cleared_key = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" ".to_string())),
|
||||
onboarding_tasks: None,
|
||||
})
|
||||
@@ -303,6 +305,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_
|
||||
assert!(cleared_key.onboarding_tasks.is_some());
|
||||
|
||||
let cleared_tasks = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: None,
|
||||
onboarding_tasks: Some(None),
|
||||
})
|
||||
@@ -313,6 +316,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_
|
||||
assert!(cleared_tasks.onboarding_tasks.is_none());
|
||||
|
||||
let unchanged = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: None,
|
||||
onboarding_tasks: None,
|
||||
})
|
||||
|
||||
@@ -248,6 +248,7 @@ async fn round20_app_state_quarantines_directory_state_and_uses_stored_user_on_h
|
||||
.expect("seed app session");
|
||||
|
||||
let first = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: None,
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: false,
|
||||
|
||||
@@ -851,6 +851,7 @@ async fn round16_app_state_config_and_session_snapshot_edges() {
|
||||
std::fs::write(harness.app_state_file(), "{broken").expect("write corrupt app state");
|
||||
|
||||
let stored = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(Some(" round16-key ".to_string())),
|
||||
onboarding_tasks: Some(Some(StoredOnboardingTasks {
|
||||
accessibility_permission_granted: true,
|
||||
@@ -919,6 +920,7 @@ async fn round16_app_state_config_and_session_snapshot_edges() {
|
||||
);
|
||||
|
||||
let cleared = update_local_state(StoredAppStatePatch {
|
||||
keyring_consent: None,
|
||||
encryption_key: Some(None),
|
||||
onboarding_tasks: Some(None),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user