fix(config): heal and diagnose an unreadable config.toml instead of dead-ending sign-in (#5190)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-07-25 08:51:57 +03:00
committed by GitHub
co-authored by Steven Enamakel
parent 10944725ed
commit 9f22ae4086
32 changed files with 847 additions and 34 deletions
+8
View File
@@ -110,6 +110,14 @@ RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint-core.sh \
# The entrypoint runs as root so it can chown the mounted volume, then execs
# gosu to drop to the openhuman user before starting the binary.
#
# CAUTION: because the image default user is root, `docker exec <ctr> ...` lands
# as root and does NOT run the entrypoint — so running `openhuman-core` that way
# creates a root-owned `config.toml` (the core writes it at mode 0600), which
# uid 10001 then cannot read on the next start. Use
# `docker exec -u openhuman <ctr> openhuman-core ...` for any CLI poking around.
# The entrypoint heals a workspace already in that state, but prevention is
# cheaper than a restart loop.
USER root
# Default workspace directory
@@ -67,6 +67,7 @@ describe('OAuthProviderButton', () => {
vi.mocked(getDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -123,6 +124,7 @@ describe('OAuthProviderButton', () => {
vi.mocked(getDeepLinkAuthState).mockReturnValue({
isProcessing: true,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
+3
View File
@@ -3205,6 +3205,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'المتابعة محليًا',
'welcome.continueLocallyExperimental': 'المتابعة محليًا (تجريبي)',
'welcome.localSessionStarting': 'بدء الجلسة المحلية...',
'welcome.coreConfigUnreadable':
'تعذّر على بيئة التشغيل قراءة ملف الإعدادات الخاص بها. قد يكون الملف config.toml مملوكًا لحساب مستخدم آخر، أو غير متاح لعملية التشغيل لسبب آخر. أعد تشغيل بيئة التشغيل، وإن لم يُجدِ ذلك فأصلح ملكية مجلد العمل أو أعد إنشاء وحدة التخزين الخاصة به.',
'welcome.localSessionErrorFallback': 'تعذّر بدء جلسة محلية.',
'welcome.localSessionDesc': 'يستخدم ملف تعريف محلي غير متصل ويتخطى TinyHumans OAuth.',
'chat.agentChatDesc': 'فتح جلسة محادثة مباشرة مع الوكيل.',
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3280,6 +3280,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'স্থানীয়ভাবে চালিয়ে যান',
'welcome.continueLocallyExperimental': 'লোকালি চালিয়ে যান (প্রায়োগিক)',
'welcome.localSessionStarting': 'স্থানীয় অধিবেশন শুরু করা হচ্ছে...',
'welcome.coreConfigUnreadable':
'রানটাইম তার কনফিগারেশন ফাইল পড়তে পারেনি। config.toml অন্য কোনো ব্যবহারকারী অ্যাকাউন্টের হতে পারে, অথবা অন্য কোনো কারণে রানটাইম প্রক্রিয়ার জন্য দুর্গম হতে পারে। রানটাইম পুনরায় চালু করুন, আর তাতে কাজ না হলে ওয়ার্কস্পেসের মালিকানা ঠিক করুন বা এর ভলিউম নতুন করে তৈরি করুন।',
'welcome.localSessionErrorFallback': 'স্থানীয় সেশন শুরু করা যায়নি।',
'welcome.localSessionDesc': 'একটি অফলাইন skips__ BR__Ans স্থানীয় প্রোফাইল ব্যবহার করে।',
'chat.agentChatDesc': 'এজেন্টের সাথে সরাসরি চ্যাট সেশন খুলুন।',
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3371,6 +3371,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Lokal fortfahren',
'welcome.continueLocallyExperimental': 'Lokal fortfahren (Experimentell)',
'welcome.localSessionStarting': 'Lokal starten Sitzung...',
'welcome.coreConfigUnreadable':
'Die Laufzeitumgebung konnte ihre Konfigurationsdatei nicht lesen. config.toml gehört möglicherweise einem anderen Benutzerkonto oder ist für den Laufzeitprozess aus einem anderen Grund nicht zugänglich. Starten Sie die Laufzeitumgebung neu, und reparieren Sie andernfalls die Besitzrechte des Arbeitsverzeichnisses oder erstellen Sie dessen Volume neu.',
'welcome.localSessionErrorFallback': 'Lokale Sitzung konnte nicht gestartet werden.',
'welcome.localSessionDesc':
'Verwendet ein lokales Offline-Profil und überspringt TinyHumans OAuth.',
'chat.agentChatDesc': 'Öffne eine direkte Chat-Sitzung mit dem Agenten.',
+3
View File
@@ -3533,6 +3533,9 @@ const en: TranslationMap = {
'welcome.continueLocally': 'Continue locally',
'welcome.continueLocallyExperimental': 'Continue Locally (Experimental)',
'welcome.localSessionStarting': 'Starting local session...',
'welcome.coreConfigUnreadable':
'The runtime could not read its configuration file. config.toml may belong to a different user account, or be inaccessible to the runtime process for another reason. Restart the runtime, and if that does not help, repair the workspace ownership or re-create its volume.',
'welcome.localSessionErrorFallback': 'Could not start a local session.',
'welcome.localSessionDesc': 'Uses an offline local profile and skips TinyHumans OAuth.',
// Chat: Agent chat panel description
+3
View File
@@ -3341,6 +3341,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Continuar localmente',
'welcome.continueLocallyExperimental': 'Continuar localmente (Experimental)',
'welcome.localSessionStarting': 'Iniciando sesión local...',
'welcome.coreConfigUnreadable':
'El entorno de ejecución no pudo leer su archivo de configuración. Es posible que config.toml pertenezca a otra cuenta de usuario o que resulte inaccesible para el proceso por otro motivo. Reinicia el entorno de ejecución y, si eso no ayuda, repara la propiedad del espacio de trabajo o vuelve a crear su volumen.',
'welcome.localSessionErrorFallback': 'No se pudo iniciar una sesión local.',
'welcome.localSessionDesc': 'Utiliza un perfil local sin conexión y omite TinyHumans OAuth.',
'chat.agentChatDesc': 'Abre una sesión de chat directo con el agente.',
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3365,6 +3365,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Continuer localement',
'welcome.continueLocallyExperimental': 'Continuer en local (Expérimental)',
'welcome.localSessionStarting': 'Démarrage de la session locale...',
'welcome.coreConfigUnreadable':
"L'environnement d'exécution n'a pas pu lire son fichier de configuration. config.toml appartient peut-être à un autre compte utilisateur, ou reste inaccessible au processus pour une autre raison. Redémarrez l'environnement d'exécution puis, si cela ne suffit pas, corrigez les droits du répertoire de travail ou recréez son volume.",
'welcome.localSessionErrorFallback': 'Impossible de démarrer une session locale.',
'welcome.localSessionDesc': 'Utilise un profil local hors ligne et ignore TinyHumans OAuth.',
'chat.agentChatDesc': "Ouvrir une session de chat direct avec l'agent.",
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3276,6 +3276,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'स्थानीय स्तर पर जारी रखें',
'welcome.continueLocallyExperimental': 'लोकल रूप से जारी रखें (प्रायोगिक)',
'welcome.localSessionStarting': 'स्थानीय सत्र प्रारंभ हो रहा है...',
'welcome.coreConfigUnreadable':
'रनटाइम अपनी कॉन्फ़िगरेशन फ़ाइल नहीं पढ़ सका। हो सकता है config.toml किसी दूसरे उपयोगकर्ता खाते का हो, या किसी अन्य कारण से रनटाइम प्रक्रिया के लिए दुर्गम हो। रनटाइम को पुनः आरंभ करें, और यदि इससे मदद न मिले तो वर्कस्पेस का स्वामित्व ठीक करें या उसका वॉल्यूम दोबारा बनाएँ।',
'welcome.localSessionErrorFallback': 'स्थानीय सत्र शुरू नहीं किया जा सका।',
'welcome.localSessionDesc':
'ऑफ़लाइन स्थानीय प्रोफ़ाइल का उपयोग करता है और TinyHumans OAuth को छोड़ देता है।',
'chat.agentChatDesc': 'एजेंट के साथ डायरेक्ट चैट सेशन खोलें।',
+3
View File
@@ -3290,6 +3290,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Lanjutkan secara lokal',
'welcome.continueLocallyExperimental': 'Lanjutkan Secara Lokal (Eksperimental)',
'welcome.localSessionStarting': 'Memulai sesi lokal...',
'welcome.coreConfigUnreadable':
'Runtime tidak dapat membaca berkas konfigurasinya. config.toml mungkin milik akun pengguna lain, atau tidak dapat diakses oleh proses runtime karena alasan lain. Mulai ulang runtime, dan jika belum teratasi, perbaiki kepemilikan direktori kerja atau buat ulang volumenya.',
'welcome.localSessionErrorFallback': 'Tidak dapat memulai sesi lokal.',
'welcome.localSessionDesc': 'Menggunakan profil lokal offline dan melewati TinyHumans OAuth.',
'chat.agentChatDesc': 'Buka sesi chat langsung dengan agen.',
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3339,6 +3339,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Continua localmente',
'welcome.continueLocallyExperimental': 'Continua Localmente (Sperimentale)',
'welcome.localSessionStarting': 'Avvio sessione locale...',
'welcome.coreConfigUnreadable':
'Il runtime non è riuscito a leggere il proprio file di configurazione. config.toml potrebbe appartenere a un altro account utente oppure essere inaccessibile al processo per un altro motivo. Riavvia il runtime e, se non basta, correggi la proprietà della cartella di lavoro o ricrea il suo volume.',
'welcome.localSessionErrorFallback': 'Impossibile avviare una sessione locale.',
'welcome.localSessionDesc': 'Utilizza un profilo locale offline e salta TinyHumans OAuth.',
'chat.agentChatDesc': "Apri una sessione di chat diretta con l'agente.",
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3243,6 +3243,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': '로컬에서 계속',
'welcome.continueLocallyExperimental': '로컬에서 계속(실험적)',
'welcome.localSessionStarting': '로컬 세션 시작 중...',
'welcome.coreConfigUnreadable':
'런타임이 자체 구성 파일을 읽지 못했습니다. config.toml이 다른 사용자 계정 소유이거나 다른 이유로 런타임 프로세스가 접근할 수 없는 상태일 수 있습니다. 런타임을 다시 시작하고, 그래도 해결되지 않으면 작업 디렉터리의 소유권을 복구하거나 볼륨을 다시 만드세요.',
'welcome.localSessionErrorFallback': '로컬 세션을 시작할 수 없습니다.',
'welcome.localSessionDesc': '오프라인 로컬 프로필을 사용하고 TinyHumans를 건너뜁니다. OAuth.',
'chat.agentChatDesc': '에이전트와 직접 채팅 세션을 엽니다.',
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3316,6 +3316,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Kontynuuj lokalnie',
'welcome.continueLocallyExperimental': 'Kontynuuj lokalnie (Eksperymentalne)',
'welcome.localSessionStarting': 'Rozpoczynanie sesji lokalnej...',
'welcome.coreConfigUnreadable':
'Środowisko uruchomieniowe nie mogło odczytać swojego pliku konfiguracyjnego. Plik config.toml może należeć do innego konta użytkownika lub być niedostępny dla procesu z innego powodu. Uruchom ponownie środowisko, a jeśli to nie pomoże, napraw właściciela katalogu roboczego lub utwórz jego wolumin od nowa.',
'welcome.localSessionErrorFallback': 'Nie udało się rozpocząć sesji lokalnej.',
'welcome.localSessionDesc':
'Używa lokalnego profilu offline i pomija logowanie OAuth TinyHumans.',
'chat.agentChatDesc': 'Otwórz bezpośrednią sesję czatu z agentem.',
+3
View File
@@ -3333,6 +3333,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Continuar localmente',
'welcome.continueLocallyExperimental': 'Continuar Localmente (Experimental)',
'welcome.localSessionStarting': 'Iniciando sessão local...',
'welcome.coreConfigUnreadable':
'O runtime não conseguiu ler o seu ficheiro de configuração. O config.toml pode pertencer a outra conta de utilizador ou estar inacessível ao processo por outro motivo. Reinicie o runtime e, se isso não resolver, corrija a propriedade da pasta de trabalho ou recrie o seu volume.',
'welcome.localSessionErrorFallback': 'Não foi possível iniciar uma sessão local.',
'welcome.localSessionDesc': 'Usa um perfil local offline e ignora TinyHumans OAuth.',
'chat.agentChatDesc': 'Abrir uma sessão de chat direto com o agente.',
'chat.modelPlaceholder': 'gpt-4o',
+3
View File
@@ -3305,6 +3305,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': 'Продолжить локально',
'welcome.continueLocallyExperimental': 'Продолжить локально (Экспериментально)',
'welcome.localSessionStarting': 'Запуск локального сеанса...',
'welcome.coreConfigUnreadable':
'Среде выполнения не удалось прочитать свой файл конфигурации. Возможно, config.toml принадлежит другой учётной записи или недоступен процессу по иной причине. Перезапустите среду выполнения, а если это не поможет, исправьте владельца рабочего каталога или создайте его том заново.',
'welcome.localSessionErrorFallback': 'Не удалось запустить локальный сеанс.',
'welcome.localSessionDesc':
'Использует автономный локальный профиль и пропускает TinyHumans OAuth.',
'chat.agentChatDesc': 'Открыть прямой чат с агентом.',
+3
View File
@@ -3105,6 +3105,9 @@ const messages: TranslationMap = {
'welcome.continueLocally': '本地继续',
'welcome.continueLocallyExperimental': '本地继续(实验性)',
'welcome.localSessionStarting': '正在启动本地会话...',
'welcome.coreConfigUnreadable':
'运行时无法读取自己的配置文件。config.toml 可能归属于其他用户账户,或因其他原因无法被运行时进程访问。请重启运行时;若仍未解决,请修复工作目录的归属权或重新创建其数据卷。',
'welcome.localSessionErrorFallback': '无法启动本地会话。',
'welcome.localSessionDesc': '使用离线本地配置文件,跳过 TinyHumans OAuth。',
'chat.agentChatDesc': '与智能体进行直接对话。',
'chat.modelPlaceholder': 'gpt-4o',
+20 -4
View File
@@ -15,6 +15,10 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
import { resolveTheme, setThemeMode, type ThemeMode } from '../store/themeSlice';
import { clearAllAppData } from '../utils/clearAllAppData';
import { clearStoredCoreMode, clearStoredCoreToken, storeRpcUrl } from '../utils/configPersistence';
import {
CORE_CONFIG_UNREADABLE_I18N_KEY,
isCoreConfigUnreadableError,
} from '../utils/coreConfigFailure';
import { PRIVACY_POLICY_URL, TERMS_OF_USE_URL } from '../utils/links';
import { createLocalSessionToken, LOCAL_SESSION_USER } from '../utils/localSession';
import { openUrl } from '../utils/openUrl';
@@ -26,7 +30,12 @@ const Welcome = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { storeSessionToken } = useCoreState();
const { isProcessing, errorMessage, requiresAppDataReset } = useDeepLinkAuthState();
const { isProcessing, errorMessage, errorMessageKey, requiresAppDataReset } =
useDeepLinkAuthState();
// Deep-link auth runs outside React and cannot translate its own copy, so it
// hands over a key for the failures whose copy is localized. Everything else
// still carries a literal message.
const deepLinkError = errorMessageKey ? t(errorMessageKey) : errorMessage;
const themeMode = useAppSelector(state => state.theme?.mode ?? 'system') as ThemeMode;
const resolvedTheme = resolveTheme(themeMode);
const isDark = resolvedTheme === 'dark';
@@ -72,7 +81,14 @@ const Welcome = () => {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log('[welcome] local session login failed: %s', message);
setLocalLoginError(message || 'Could not start a local session.');
// A config-read denial is permanent and host-side; showing the raw
// anyhow chain (absolute path + `os error 13`) gives the user nothing to
// act on. Unrecognized failures keep their original message.
setLocalLoginError(
isCoreConfigUnreadableError(message)
? t(CORE_CONFIG_UNREADABLE_I18N_KEY)
: message || t('welcome.localSessionErrorFallback')
);
setIsLocalSigningIn(false);
}
};
@@ -140,11 +156,11 @@ const Welcome = () => {
{t('welcome.subtitle')}
</p>
{errorMessage ? (
{deepLinkError ? (
<div
role="alert"
className="mb-5 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
<p>{errorMessage}</p>
<p>{deepLinkError}</p>
{requiresAppDataReset ? (
<div className="mt-3 space-y-2">
<Button
+35
View File
@@ -114,6 +114,7 @@ describe('Welcome auth entrypoint', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -162,6 +163,7 @@ describe('Welcome auth entrypoint', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: true,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
@@ -174,6 +176,7 @@ describe('Welcome auth entrypoint', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: 'OAuth failed',
errorMessageKey: null,
requiresAppDataReset: false,
});
@@ -192,6 +195,7 @@ describe('Welcome — decryption-failure recovery action', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: "Sign-in failed because OpenHuman couldn't decrypt locally stored data.",
errorMessageKey: null,
requiresAppDataReset: true,
});
});
@@ -243,6 +247,7 @@ describe('Welcome — Select runtime button', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
vi.mocked(clearCoreRpcUrlCache).mockReset();
@@ -292,6 +297,7 @@ describe('Welcome — OAuth buttons presence', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -314,6 +320,7 @@ describe('Welcome — OAuth buttons presence', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: true,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
renderWithProviders(<Welcome />);
@@ -329,6 +336,7 @@ describe('Welcome — local login', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -374,4 +382,31 @@ describe('Welcome — local login', () => {
expect(screen.getByText(/token save failed/)).toBeInTheDocument();
});
});
// The reported sign-in failure: the core (typically a container runtime whose
// workspace volume carries a config.toml owned by another uid) cannot read
// its own config, and the raw anyhow chain — absolute path and errno included
// — was painted straight into this screen with nothing the user could act on.
it('replaces an unreadable-core-config error with actionable, path-free copy', async () => {
mockStoreSessionToken.mockRejectedValueOnce(
new Error(
'Failed to read config file: /home/openhuman/.openhuman/config.toml ' +
'[config owner mismatch] (file uid=0 gid=0 mode=0600; process euid=10001 egid=10001): ' +
'Permission denied (os error 13)'
)
);
renderWithProviders(<Welcome />);
fireEvent.click(screen.getByRole('button', { name: /Continue locally/i }));
await waitFor(() => {
expect(screen.getByText(/could not read its configuration file/i)).toBeInTheDocument();
});
// The raw chain must not leak: the path belongs to the runtime host, and
// neither it nor the errno is actionable for the person signing in.
expect(screen.queryByText(/\/home\/openhuman/)).not.toBeInTheDocument();
expect(screen.queryByText(/os error 13/)).not.toBeInTheDocument();
// Resolved through i18n, so a bare key must never reach the DOM.
expect(screen.queryByText('welcome.coreConfigUnreadable')).not.toBeInTheDocument();
});
});
@@ -24,6 +24,7 @@ describe('deepLinkAuthState transitions', () => {
expect(getDeepLinkAuthState()).toEqual({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -36,6 +37,7 @@ describe('deepLinkAuthState transitions', () => {
expect(getDeepLinkAuthState()).toEqual({
isProcessing: true,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -46,6 +48,7 @@ describe('deepLinkAuthState transitions', () => {
expect(getDeepLinkAuthState()).toEqual({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -56,6 +59,7 @@ describe('deepLinkAuthState transitions', () => {
expect(getDeepLinkAuthState()).toEqual({
isProcessing: false,
errorMessage: 'token expired',
errorMessageKey: null,
requiresAppDataReset: false,
});
});
@@ -65,9 +69,38 @@ describe('deepLinkAuthState transitions', () => {
expect(getDeepLinkAuthState()).toEqual({
isProcessing: false,
errorMessage: 'cannot decrypt',
errorMessageKey: null,
requiresAppDataReset: true,
});
});
// Deep-link auth runs outside React and cannot call `useT()`, so failures
// whose copy is localized hand over an i18n key for the rendering component
// to resolve. Everything else keeps a literal message.
it('failDeepLinkAuthProcessing carries an i18n key for localized failures', () => {
failDeepLinkAuthProcessing('', { messageKey: 'welcome.coreConfigUnreadable' });
expect(getDeepLinkAuthState()).toEqual({
isProcessing: false,
errorMessage: '',
errorMessageKey: 'welcome.coreConfigUnreadable',
requiresAppDataReset: false,
});
});
it('clears a stale i18n key on the next transition', () => {
failDeepLinkAuthProcessing('', { messageKey: 'welcome.coreConfigUnreadable' });
beginDeepLinkAuthProcessing();
expect(getDeepLinkAuthState().errorMessageKey).toBeNull();
failDeepLinkAuthProcessing('', { messageKey: 'welcome.coreConfigUnreadable' });
completeDeepLinkAuthProcessing();
expect(getDeepLinkAuthState().errorMessageKey).toBeNull();
// A later literal-message failure must not inherit the previous key.
failDeepLinkAuthProcessing('', { messageKey: 'welcome.coreConfigUnreadable' });
failDeepLinkAuthProcessing('Sign-in failed. Please try again.');
expect(getDeepLinkAuthState().errorMessageKey).toBeNull();
});
});
describe('deepLinkAuthState subscribers', () => {
@@ -120,6 +153,7 @@ describe('useDeepLinkAuthState hook', () => {
expect(result.current).toEqual({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
@@ -129,6 +163,7 @@ describe('useDeepLinkAuthState hook', () => {
expect(result.current).toEqual({
isProcessing: true,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
@@ -138,6 +173,7 @@ describe('useDeepLinkAuthState hook', () => {
expect(result.current).toEqual({
isProcessing: false,
errorMessage: 'denied',
errorMessageKey: null,
requiresAppDataReset: false,
});
});
+21 -3
View File
@@ -3,6 +3,12 @@ import { useSyncExternalStore } from 'react';
interface DeepLinkAuthState {
isProcessing: boolean;
errorMessage: string | null;
// i18n key to render INSTEAD of `errorMessage`, for failures whose copy is
// translated. This module is reached from non-React code (the deep-link
// listener), so it cannot call `useT()` itself; carrying the key lets the
// rendering component resolve it in the user's locale. Null keeps the
// existing behaviour of rendering `errorMessage` verbatim.
errorMessageKey: string | null;
// Set when sign-in fails because the local core could not decrypt persisted
// secrets — typically the encryption key on disk no longer matches the
// ciphertext (key rotated, profile copied between machines, tampered/corrupt
@@ -14,6 +20,7 @@ interface DeepLinkAuthState {
const initialState: DeepLinkAuthState = {
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
};
@@ -41,20 +48,31 @@ export const subscribeDeepLinkAuthState = (listener: () => void): (() => void) =
};
export const beginDeepLinkAuthProcessing = (): void => {
setDeepLinkAuthState({ isProcessing: true, errorMessage: null, requiresAppDataReset: false });
setDeepLinkAuthState({
isProcessing: true,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
};
export const completeDeepLinkAuthProcessing = (): void => {
setDeepLinkAuthState({ isProcessing: false, errorMessage: null, requiresAppDataReset: false });
setDeepLinkAuthState({
isProcessing: false,
errorMessage: null,
errorMessageKey: null,
requiresAppDataReset: false,
});
};
export const failDeepLinkAuthProcessing = (
message: string,
options: { requiresAppDataReset?: boolean } = {}
options: { requiresAppDataReset?: boolean; messageKey?: string } = {}
): void => {
setDeepLinkAuthState({
isProcessing: false,
errorMessage: message,
errorMessageKey: options.messageKey ?? null,
requiresAppDataReset: Boolean(options.requiresAppDataReset),
});
};
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import en from '../../lib/i18n/en';
import { CORE_CONFIG_UNREADABLE_I18N_KEY, isCoreConfigUnreadableError } from '../coreConfigFailure';
// The verbatim chain a container core emits when its workspace volume carries a
// config.toml owned by a different uid than the runtime process.
const REPORTED =
'Failed to read config file: /home/openhuman/.openhuman/config.toml ' +
'[config owner mismatch] (file uid=0 gid=0 mode=0600; process euid=10001 egid=10001): ' +
'Permission denied (os error 13)';
describe('isCoreConfigUnreadableError', () => {
it('matches the reported container failure', () => {
expect(isCoreConfigUnreadableError(REPORTED)).toBe(true);
});
it('matches the pre-ownership-diagnostics shape still shipping in older cores', () => {
expect(
isCoreConfigUnreadableError(
'Failed to read config file: /home/openhuman/.openhuman/config.toml: Permission denied (os error 13)'
)
).toBe(true);
});
it('matches the Windows denial and the snapshot-reload context line', () => {
expect(
isCoreConfigUnreadableError(
'Failed to read config file: C:\\Users\\u\\.openhuman\\users\\local\\config.toml: Access is denied. (os error 5)'
)
).toBe(true);
expect(
isCoreConfigUnreadableError(
'reading config.toml from /home/openhuman/.openhuman/config.toml: Permission denied (os error 13)'
)
).toBe(true);
});
it('does not match an unrelated errno that merely shares a prefix', () => {
// `os error 13` unanchored also matches `os error 130`, and `os error 5`
// matches `os error 50`/`512`. The loader always emits the parenthesised
// form, so the classifier keys on that.
for (const errno of ['os error 130', 'os error 50', 'os error 512']) {
expect(
isCoreConfigUnreadableError(
`Failed to read config file: /home/openhuman/.openhuman/config.toml: Some other failure (${errno})`
)
).toBe(false);
}
});
it('requires BOTH the config-read context and a denial signal', () => {
// Permission failure from another subsystem keeps its own message.
expect(isCoreConfigUnreadableError('opening keychain failed: Permission denied')).toBe(false);
// Config-read failure that is not a denial (missing file, parse) is a
// different fault with a different remedy.
expect(
isCoreConfigUnreadableError(
'Failed to read config file: /home/openhuman/.openhuman/config.toml: No such file or directory (os error 2)'
)
).toBe(false);
});
it('normalises its own input, so callers may pass a raw or lowered message', () => {
expect(isCoreConfigUnreadableError(REPORTED.toLowerCase())).toBe(true);
expect(isCoreConfigUnreadableError(REPORTED.toUpperCase())).toBe(true);
});
it('is safe on empty input', () => {
expect(isCoreConfigUnreadableError(null)).toBe(false);
expect(isCoreConfigUnreadableError(undefined)).toBe(false);
expect(isCoreConfigUnreadableError('')).toBe(false);
});
});
describe('CORE_CONFIG_UNREADABLE_I18N_KEY', () => {
it('resolves to real English copy so the UI never renders a bare key', () => {
const copy = (en as Record<string, string>)[CORE_CONFIG_UNREADABLE_I18N_KEY];
expect(copy).toBeTruthy();
// `toBeTruthy` alone would pass on a value that is literally the key, which
// is exactly the "bare key reached the UI" case this test exists to rule out.
expect(copy).not.toBe(CORE_CONFIG_UNREADABLE_I18N_KEY);
// The person reading the sign-in screen cannot act on a container path, a
// uid, or an errno, and the path is the runtime host's, not theirs.
expect(copy).not.toMatch(/\/home\/openhuman|os error|uid=/);
// Hedged, not asserted: the classifier also matches denials with no
// ownership marker (older cores, Windows DACLs), where a flat "is owned by
// another user" claim would be wrong.
expect(copy).toMatch(/may/i);
// Repo i18n rule: no em dashes in translation values.
expect(copy).not.toContain('\u2014');
});
});
@@ -125,6 +125,8 @@ describe('desktopDeepLinkListener', () => {
expect(windowControls.setFocus).toHaveBeenCalledTimes(1);
expect(getDeepLinkAuthState()).toEqual({
isProcessing: false,
// Literal copy, not a key: only the localized failures carry one.
errorMessageKey: null,
errorMessage:
'Twitter/X sign-in failed before OpenHuman received authorization. Check the Twitter Developer Portal app settings: OAuth 2.0 must be enabled, callback URL must match the backend redirect URL exactly, and the client ID, client secret, and requested scopes must match the OpenHuman backend configuration.',
requiresAppDataReset: false,
@@ -287,6 +289,32 @@ describe('desktopDeepLinkListener', () => {
expect(state.requiresAppDataReset).toBe(false);
expect(state.errorMessage).toContain('did not respond');
expect(state.errorMessage).toContain('restart');
expect(state.errorMessageKey).toBeNull();
});
// The core cannot read its own config.toml: permanent, host-side, and
// identical for every config-dependent RPC. It previously fell through to the
// generic "Please try again", which is advice that can never work. The copy is
// localized, and this module cannot call useT(), so it hands the rendering
// component an i18n key instead of a literal.
it('surfaces an unreadable core config as a translatable key, not a retry prompt', async () => {
vi.mocked(storeSession).mockRejectedValueOnce(
new Error(
'Failed to read config file: /home/openhuman/.openhuman/config.toml ' +
'[config owner mismatch] (file uid=0 gid=0 mode=0600; process euid=10001 egid=10001): ' +
'Permission denied (os error 13)'
)
);
vi.mocked(getCurrent).mockResolvedValue([authDeepLinkWithState('token=abc&key=auth')]);
await setupDesktopDeepLinkListener();
await waitForAuthSettled();
const state = getDeepLinkAuthState();
expect(state.errorMessageKey).toBe('welcome.coreConfigUnreadable');
expect(state.errorMessage).not.toBe('Sign-in failed. Please try again.');
expect(state.requiresAppDataReset).toBe(false);
});
it('injection #1: store-time /auth/me failure bounces to signin — no session applied, no /home nav', async () => {
@@ -468,6 +496,19 @@ describe('classifyAuthStoreFailure', () => {
expect(classifyAuthStoreFailure(bare)).toBe('auth_me_other');
expect(classifyAuthStoreFailure(bare)).not.toBe('other');
});
// A core that cannot read its own config.toml fails EVERY config-dependent
// RPC the same way. Bucketing it as 'other' surfaced "Sign-in failed. Please
// try again." for a fault no amount of retrying clears.
it('classifies an unreadable core config as its own permanent kind', () => {
const reported =
'Failed to read config file: /home/openhuman/.openhuman/config.toml ' +
'[config owner mismatch] (file uid=0 gid=0 mode=0600; process euid=10001 egid=10001): ' +
'Permission denied (os error 13)';
expect(classifyAuthStoreFailure(reported)).toBe('config_unreadable');
expect(classifyAuthStoreFailure(reported)).not.toBe('other');
});
});
describe('authStoreFailureUserMessage (issue #3025)', () => {
+59
View File
@@ -0,0 +1,59 @@
/**
* Recognise the one core failure that reads like a transient sign-in glitch but
* is a permanent, environment-level fault: the core cannot open its own
* `config.toml`.
*
* The Rust chain looks like
* `Failed to read config file: /home/openhuman/.openhuman/config.toml
* [config owner mismatch] (file uid=0 …): Permission denied (os error 13)`
* and, before this module existed, was painted verbatim into the Welcome
* screen (absolute path included) with no classification and no recovery hint,
* while the OAuth path bucketed it as `'other'` -> "Sign-in failed. Please try
* again." presenting a permanent fault as a retryable one.
*
* Every config-dependent RPC fails the same way, so no amount of retrying
* helps; the fix is always on the runtime host.
*/
/** Context lines the Rust loader wraps a config read in. */
const CONFIG_READ_ANCHOR = /failed to read config file|reading config\.toml from/;
/**
* OS denial signals, unix and Windows.
*
* The errno alternatives are parenthesised because that is the exact shape
* `io::Error`'s Display always produces (`… (os error 13)`), and an unanchored
* `os error 5` would also match `os error 50` / `os error 512`.
*/
const PERMISSION_SIGNAL = /permission denied|access is denied|\(os error 13\)|\(os error 5\)/;
/** Marker the core appends when the file's uid differs from the process euid. */
const OWNER_MISMATCH_MARKER = 'config owner mismatch';
/**
* True when `message` is a core config-read denial rather than an unrelated
* permission error. Requires BOTH the config-read context and a denial signal
* so a permission failure from any other subsystem keeps its own message.
*
* Accepts a raw (un-lowercased) message; callers need not normalise.
*/
export const isCoreConfigUnreadableError = (message: string | null | undefined): boolean => {
const lowered = (message ?? '').toLowerCase();
if (!CONFIG_READ_ANCHOR.test(lowered)) {
return false;
}
return PERMISSION_SIGNAL.test(lowered) || lowered.includes(OWNER_MISMATCH_MARKER);
};
/**
* i18n key for the user-facing copy. The message itself lives in the locale
* files rather than here so every language gets it and the i18n gates can see
* it; this module only decides *whether* the failure applies.
*
* The copy is deliberately hedged. A denial carrying the ownership marker is
* definitely a uid mismatch, but the classifier also matches the bare
* permission shape emitted by cores predating that marker, and on Windows the
* same shape can come from a DACL or an antivirus lock. Asserting "is owned by
* another user" would send those users chasing the wrong remedy.
*/
export const CORE_CONFIG_UNREADABLE_I18N_KEY = 'welcome.coreConfigUnreadable';
+20 -1
View File
@@ -11,6 +11,7 @@ import {
failDeepLinkAuthProcessing,
} from '../store/deepLinkAuthState';
import { getStoredCoreMode } from './configPersistence';
import { CORE_CONFIG_UNREADABLE_I18N_KEY, isCoreConfigUnreadableError } from './coreConfigFailure';
import { BILLING_DASHBOARD_URL } from './links';
import {
evaluateOAuthAppVersionGate,
@@ -357,7 +358,14 @@ const handleAuthDeepLink = async (parsed: URL, requireStateNonce = true) => {
fingerprint: ['deep-link-auth', 'session-store-failed', kind],
});
console.warn('[DeepLink][auth] session store failed — staying on signin (kind=%s)', kind);
failDeepLinkAuthProcessing(authStoreFailureUserMessage(kind, getStoredCoreMode()));
// `config_unreadable` copy is translated, and this module cannot call
// `useT()`. Hand the key to the store and let the rendering component
// resolve it in the user's locale; every other kind keeps its literal.
if (kind === 'config_unreadable') {
failDeepLinkAuthProcessing('', { messageKey: CORE_CONFIG_UNREADABLE_I18N_KEY });
} else {
failDeepLinkAuthProcessing(authStoreFailureUserMessage(kind, getStoredCoreMode()));
}
}
}
};
@@ -384,6 +392,12 @@ const isDecryptionFailure = (message: string): boolean => {
*/
export const classifyAuthStoreFailure = (message: string): string => {
const m = message.toLowerCase();
// Most specific first: the core could not read its own config.toml. Checked
// ahead of the transport buckets because it is permanent and host-side —
// bucketing it as `'other'` told the user to "try again" forever. Passed the
// raw message: the predicate normalises its own input, and every other call
// site hands it a raw one.
if (isCoreConfigUnreadableError(message)) return 'config_unreadable';
if (/timed out|timeout|operation timed out|deadline/.test(m)) return 'auth_me_timeout';
if (/\b401\b|unauthorized/.test(m)) return 'auth_me_unauthorized';
if (/\b50[234]\b|bad gateway|service unavailable|gateway timeout/.test(m))
@@ -410,6 +424,11 @@ export const authStoreFailureUserMessage = (
kind: string,
mode: 'local' | 'cloud' | null
): string => {
// NOTE: `config_unreadable` never reaches here — its copy is translated and
// is resolved from `CORE_CONFIG_UNREADABLE_I18N_KEY` at the rendering
// component instead (see the catch block in `handleAuthDeepLink`). It is
// mode-independent anyway: an unreadable config.toml is a property of
// whichever core answered, embedded or remote, and retrying never clears it.
if (mode !== 'cloud') {
return (
'Sign-in could not be completed right now. The session store did not respond in time ' +
+14
View File
@@ -27,6 +27,20 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
# The entrypoint starts as root so it can repair the ownership of a volume
# Docker created as root:root, then drops to the `openhuman` user. Both
# steps need capabilities that `cap_drop: ALL` removes:
# CHOWN — heal mis-owned workspace files (scripts/docker-entrypoint-core.sh)
# SETUID/SETGID — gosu's privilege drop; without them the container
# crash-loops before openhuman-core ever starts.
# Deny-by-default is preserved: these three are the entire grant.
# Alternative if you prefer zero capabilities: pin `user: "10001:10001"`,
# which makes the entrypoint exec the binary directly and skip the heal —
# at the cost of no longer being able to repair a root-owned volume.
cap_add:
- CHOWN
- SETUID
- SETGID
tmpfs:
- /tmp
ports:
+32 -1
View File
@@ -641,7 +641,21 @@ If you switch between building from `Dockerfile` (which creates the
uses UID 1000), files already written to the persistent volume will be owned
by the old UID and produce `Permission denied (os error 13)` on startup.
Fix by SSH-ing in and re-owning the workspace:
The same thing happens to a Docker named volume that outlives an image
upgrade, and to any workspace a **root** `docker exec` wrote into — `docker
exec` does not run the entrypoint, so it lands as root and leaves a
`root:root` `config.toml` behind (the core writes it at mode 0600, so the
runtime user then cannot open it at all).
`scripts/docker-entrypoint-core.sh` now repairs this automatically: it chowns
the workspace **recursively** on every start, skipping entries already owned
correctly. If the repair cannot run — `cap_drop: ALL` without `cap_add: CHOWN`
— the entrypoint refuses to start rather than booting a container that answers
`/health` with 200 while every config RPC returns `Permission denied
(os error 13)`, and prints the exact `chown` to run. Look for
`[docker-entrypoint] pre-heal` / `FATAL` lines in the container log.
Fix an older container by SSH-ing in and re-owning the workspace:
```bash
fly ssh console --config .fly/fly.toml
@@ -650,6 +664,23 @@ exit
fly machine restart --config .fly/fly.toml
```
The Docker equivalent. Derive the ids from the container rather than hard-coding
them, so this stays correct whichever image you are running:
```bash
docker exec -u 0 openhuman-core sh -c \
'chown -Rh "$(id -u openhuman):$(id -g openhuman)" /home/openhuman/.openhuman'
docker restart openhuman-core
```
To see which UID owns what before repairing. Note `docker exec` defaults to
**root**, so ask about the runtime user explicitly rather than trusting a bare
`id`:
```bash
docker exec openhuman-core sh -c 'id openhuman; ls -ln /home/openhuman/.openhuman/config.toml'
```
---
## Smoke test
+165 -20
View File
@@ -8,22 +8,37 @@
#
# Fix (gosu pattern):
# 1. Start as root so we can chown the mount point(s).
# 2. mkdir -p + chown the workspace directory *before* any application code
# runs, so the openhuman user owns it regardless of whether Docker created
# the volume as root.
# 2. mkdir -p + chown the workspace directory *and everything inside it*
# before any application code runs, so the openhuman user owns it
# regardless of whether Docker created the volume as root.
# 3. exec gosu openhuman to drop privileges and hand off to the binary.
#
# This is idempotent: if the directory already exists with the right ownership
# (image-baked or a re-used volume that was healed on a previous run) the chown
# is a no-op. No manual "docker volume rm" is required when upgrading from a
# previously broken image.
# The chown MUST be recursive. Healing only the directory inode leaves any
# file inside it — most importantly `config.toml`, which the core writes at
# mode 0600 — owned by whichever uid created it. A workspace volume that was
# seeded by an older image, restored from a backup, or written by a root
# `docker exec` therefore produced:
#
# Requirements: gosu must be installed in the image (see Dockerfile).
# Failed to read config file: /home/openhuman/.openhuman/config.toml:
# Permission denied (os error 13)
#
# on every config-dependent RPC (sign-in included) while the container kept
# answering /health with 200.
#
# This is idempotent: files already owned by the openhuman user are skipped, so
# a healthy re-used volume costs one `find` traversal. No manual
# "docker volume rm" is required when upgrading from a previously broken image.
#
# Requirements: gosu must be installed in the image (see Dockerfile), and the
# container needs CAP_CHOWN + CAP_SETUID + CAP_SETGID. A capability-denied
# chown is a warning, not a fatal error — the readability preflight below is
# what decides whether the core can actually start.
# POSIX sh — no bashisms.
set -e
OPENHUMAN_USER="openhuman"
OPENHUMAN_UID="$(id -u "${OPENHUMAN_USER}" 2>/dev/null || echo '')"
OPENHUMAN_GID="$(id -g "${OPENHUMAN_USER}" 2>/dev/null || echo '')"
# The workspace path the core will actually write to.
# Prefer the env var if set; otherwise fall back to the image default.
@@ -33,22 +48,152 @@ WORKSPACE_DIR="${OPENHUMAN_WORKSPACE:-/home/openhuman/.openhuman}"
HOME_OPENHUMAN_DIR="/home/openhuman/.openhuman"
echo "[docker-entrypoint] uid=$(id -u), gid=$(id -g), user=$(id -un 2>/dev/null || echo unknown)"
echo "[docker-entrypoint] chowning workspace dirs for ${OPENHUMAN_USER} (uid=${OPENHUMAN_UID})"
echo "[docker-entrypoint] target user=${OPENHUMAN_USER} uid=${OPENHUMAN_UID} gid=${OPENHUMAN_GID}"
echo "[docker-entrypoint] WORKSPACE_DIR=${WORKSPACE_DIR}"
echo "[docker-entrypoint] HOME_OPENHUMAN_DIR=${HOME_OPENHUMAN_DIR}"
# Ensure workspace dir exists and is owned by the openhuman user.
mkdir -p "${WORKSPACE_DIR}"
chown "${OPENHUMAN_USER}:${OPENHUMAN_USER}" "${WORKSPACE_DIR}"
echo "[docker-entrypoint] chown ${WORKSPACE_DIR} -> ${OPENHUMAN_USER}:${OPENHUMAN_USER} done"
# Make DIR and every entry under it owned by the openhuman user.
heal_dir() {
_dir="$1"
# If WORKSPACE_DIR and HOME_OPENHUMAN_DIR differ, heal the home dir too
# (core.token always lands in $HOME/.openhuman regardless of OPENHUMAN_WORKSPACE).
if [ "${WORKSPACE_DIR}" != "${HOME_OPENHUMAN_DIR}" ]; then
mkdir -p "${HOME_OPENHUMAN_DIR}"
chown "${OPENHUMAN_USER}:${OPENHUMAN_USER}" "${HOME_OPENHUMAN_DIR}"
echo "[docker-entrypoint] chown ${HOME_OPENHUMAN_DIR} -> ${OPENHUMAN_USER}:${OPENHUMAN_USER} done"
if ! mkdir -p "${_dir}" 2>/dev/null; then
echo "[docker-entrypoint] WARN mkdir -p ${_dir} failed (read-only filesystem?)"
fi
# Numeric owner/mode of the dir and of config.toml, before any repair.
# These three numbers are what turn a bare "Permission denied" into a
# diagnosis, and they are the first thing to ask a reporter for.
ls -ldn "${_dir}" 2>/dev/null | sed 's/^/[docker-entrypoint] pre-heal /' || true
if [ -e "${_dir}/config.toml" ]; then
ls -ln "${_dir}/config.toml" 2>/dev/null \
| sed 's/^/[docker-entrypoint] pre-heal /' || true
fi
# Only touch entries that are actually mis-owned: on a healthy volume this
# is a no-op traversal, and it avoids rewriting ctime on every file in a
# large workspace (memory DBs, caches) at each container start.
#
# `-h` (lchown) is load-bearing, not a nicety. Plain `chown` DEREFERENCES
# symlinks, and this runs as root over a directory the (unprivileged) core
# can write: a symlink planted at `${_dir}/x -> /etc/shadow` would otherwise
# hand uid 10001 ownership of the target on the next container start.
# `find` itself does not follow symlinks (-P is the default), so `-h`
# closes the last dereference. Hardlinks are not a path here: the workspace
# volume is its own filesystem, and hardlinks cannot cross devices.
# Numeric uid:gid rather than `openhuman:openhuman`: the name pair only
# works while the primary group happens to share the user's name, and it
# keeps this symmetric with the numeric remedy printed on failure below.
if find "${_dir}" ! -user "${OPENHUMAN_USER}" -exec \
chown -h "${OPENHUMAN_UID}:${OPENHUMAN_GID}" {} + 2>/dev/null; then
echo "[docker-entrypoint] heal ${_dir} -> ${OPENHUMAN_UID}:${OPENHUMAN_GID} done"
else
echo "[docker-entrypoint] WARN chown under ${_dir} failed — no CAP_CHOWN? (cap_drop: ALL)"
echo "[docker-entrypoint] WARN files not owned by uid=${OPENHUMAN_UID} will be unreadable"
fi
}
# Every directory the core may resolve a config.toml out of, deduplicated.
#
# WORKSPACE_DIR OPENHUMAN_WORKSPACE, the primary candidate.
# HOME_OPENHUMAN_DIR core.token always lands in $HOME/.openhuman, whatever
# OPENHUMAN_WORKSPACE says.
# LEGACY_DIR `resolve_config_dir_for_workspace`
# (src/openhuman/config/schema/load/dirs.rs) falls back to
# `<parent-of-workspace>/.openhuman` when the workspace
# itself holds no config.toml. For the image default the
# three collapse to one path; a custom OPENHUMAN_WORKSPACE
# makes them diverge, and healing only the first left the
# actually-resolved config untouched.
#
# Paths are assumed free of whitespace (they are container paths baked into the
# image or set via env); POSIX sh has no arrays to do better cheaply.
CONFIG_DIRS=""
add_config_dir() {
_candidate="$1"
if [ -z "${_candidate}" ]; then
return 0
fi
for _existing in ${CONFIG_DIRS}; do
if [ "${_existing}" = "${_candidate}" ]; then
return 0
fi
done
CONFIG_DIRS="${CONFIG_DIRS} ${_candidate}"
return 0
}
add_config_dir "${WORKSPACE_DIR}"
add_config_dir "${HOME_OPENHUMAN_DIR}"
# The legacy candidate is DERIVED, not configured, so it is only a candidate
# when it already exists: `heal_dir` runs `mkdir -p`, and materializing an empty
# sibling `.openhuman` that the core may never resolve into is a side effect a
# healthy start should not have.
LEGACY_DIR="$(dirname "${WORKSPACE_DIR}")/.openhuman"
if [ -d "${LEGACY_DIR}" ]; then
add_config_dir "${LEGACY_DIR}"
fi
echo "[docker-entrypoint] dropping privileges -> exec gosu ${OPENHUMAN_USER} openhuman-core $*"
# An explicit non-root `user:` / `--user` means the operator has already pinned
# the runtime identity. We cannot chown without CAP_CHOWN, and gosu needs
# CAP_SETUID/CAP_SETGID we do not have, so the heal is genuinely unavailable
# here. The *diagnosis* is not: `test -r` as ourselves needs no privileges, and
# skipping it would reproduce exactly the silent dead-end this script exists to
# prevent (core boots, every config RPC returns EACCES, nothing in the log).
if [ "$(id -u)" -ne 0 ]; then
for _dir in ${CONFIG_DIRS}; do
if [ -e "${_dir}/config.toml" ] && [ ! -r "${_dir}/config.toml" ]; then
echo "[docker-entrypoint] FATAL ${_dir}/config.toml is not readable by uid=$(id -u)" >&2
ls -ln "${_dir}/config.toml" >&2 || true
echo "[docker-entrypoint] FATAL running with a pinned non-root user, so the workspace cannot be healed in-container" >&2
echo "[docker-entrypoint] FATAL remedy: chown -Rh $(id -u):$(id -g) ${_dir} on the host, or drop the pinned 'user:' so the entrypoint can repair it" >&2
exit 1
fi
done
echo "[docker-entrypoint] already running non-root — skipping heal, exec direct"
exec openhuman-core "$@"
fi
# Only the root path needs these: the heal chowns to them and gosu drops to
# them. A pinned non-root run never touches them, so it must not be blocked by
# an image that happens not to define the user.
if [ -z "${OPENHUMAN_UID}" ] || [ -z "${OPENHUMAN_GID}" ]; then
echo "[docker-entrypoint] FATAL user '${OPENHUMAN_USER}' does not exist in this image" >&2
echo "[docker-entrypoint] FATAL the image must create it (see Dockerfile: groupadd/useradd)" >&2
exit 1
fi
for _dir in ${CONFIG_DIRS}; do
heal_dir "${_dir}"
done
# Preflight 1: prove gosu can actually drop privileges before we blame the
# workspace for anything. Without CAP_SETUID/CAP_SETGID (e.g. `cap_drop: ALL`
# with no matching `cap_add`) every gosu call fails, and attributing that to
# file ownership would send the operator chasing the wrong `chown`.
if ! gosu "${OPENHUMAN_USER}" true 2>/dev/null; then
echo "[docker-entrypoint] FATAL cannot drop privileges to ${OPENHUMAN_USER} via gosu" >&2
echo "[docker-entrypoint] FATAL the container needs CAP_SETUID + CAP_SETGID (and CAP_CHOWN to heal a root-owned volume)" >&2
echo "[docker-entrypoint] FATAL with 'cap_drop: ALL', add: cap_add: [CHOWN, SETUID, SETGID] — or pin 'user: \"${OPENHUMAN_UID}:${OPENHUMAN_GID}\"'" >&2
exit 1
fi
# Preflight 2: refuse to boot a container that would answer /health with 200
# while every config-dependent RPC returns EACCES. A restart loop carrying a
# one-line remedy in `docker logs` is far easier to diagnose than a green
# container whose sign-in screen dead-ends on an os-error-13 string.
#
# Checked across every candidate dir, not just the workspace: which one the core
# resolves depends on where a config.toml actually exists, and an unreadable
# config in any directory we manage is broken regardless of which one wins.
for _dir in ${CONFIG_DIRS}; do
if [ -e "${_dir}/config.toml" ] \
&& ! gosu "${OPENHUMAN_USER}" test -r "${_dir}/config.toml"; then
echo "[docker-entrypoint] FATAL ${_dir}/config.toml is not readable by ${OPENHUMAN_USER} (uid=${OPENHUMAN_UID})" >&2
ls -ln "${_dir}/config.toml" >&2 || true
echo "[docker-entrypoint] FATAL remedy: docker exec -u 0 <container> chown -Rh ${OPENHUMAN_UID}:${OPENHUMAN_GID} ${_dir}" >&2
exit 1
fi
done
echo "[docker-entrypoint] dropping privileges -> exec gosu ${OPENHUMAN_USER} openhuman-core"
exec gosu "${OPENHUMAN_USER}" openhuman-core "$@"
+51
View File
@@ -794,6 +794,21 @@ fn is_config_read_io_failure_message(lower: &str) -> bool {
if lower.contains("is a directory") || lower.contains("not a file") {
return false;
}
// The file is owned by a uid other than the one reading it. That is an
// OpenHuman defect, not user-environment state: *we* pick the runtime uid
// (Dockerfile) and *we* write the config at 0600 (`impl_load.rs`), and the
// container entrypoint chowns only the workspace directory — so a
// stale-uid `config.toml` inside a reused volume denies every config RPC
// (including the sign-in `auth_store_session`) while `/health` stays green.
// There IS a local lever here, so it must keep paging. Genuine ACL /
// antivirus / OneDrive denials carry no marker and stay demoted.
//
// Referencing the loader's constant (rather than a copied literal) makes
// the producer/consumer coupling a compile-time one: the marker is already
// lowercase, so it matches `lower` verbatim.
if lower.contains(crate::openhuman::config::schema::CONFIG_OWNER_MISMATCH_MARKER) {
return false;
}
lower.contains("access is denied")
|| lower.contains("permission denied")
|| lower.contains("being used by another process")
@@ -4695,6 +4710,42 @@ mod tests {
);
}
/// A denial caused by the config being owned by a *different uid than the
/// process reading it* is an OpenHuman defect, not user-environment state:
/// we pick the container runtime uid and we write the file at 0600, and the
/// entrypoint historically chowned only the workspace directory — so a
/// reused volume denied every config RPC (including the sign-in
/// `auth_store_session`) while `/health` stayed green and Sentry saw
/// nothing. It must keep paging.
#[test]
fn does_not_demote_config_read_denial_on_owner_mismatch() {
let marker = crate::openhuman::config::schema::CONFIG_OWNER_MISMATCH_MARKER;
assert_ne!(
expected_error_kind(&format!(
"Failed to read config file: /home/openhuman/.openhuman/config.toml {marker} \
(file uid=0 gid=0 mode=0600; process euid=10001 egid=10001): \
Permission denied (os error 13)"
)),
Some(ExpectedErrorKind::ConfigReadIoFailure),
);
}
/// The complement: without the marker the file is ours and readable-in-
/// principle, so the denial really is an ACL / antivirus / OneDrive
/// condition with no local lever, and stays demoted. Guarding this pins
/// that the exclusion above is keyed on the marker, not on "permission".
#[test]
fn still_demotes_config_read_denial_without_owner_mismatch() {
assert_eq!(
expected_error_kind(
"Failed to read config file: /home/u/.openhuman/users/local/config.toml \
(file uid=501 gid=20 mode=0000; process euid=501 egid=20): \
Permission denied (os error 13)"
),
Some(ExpectedErrorKind::ConfigReadIoFailure),
);
}
#[test]
fn does_not_demote_config_read_notfound_or_unkeyed_failures() {
// NotFound AFTER the `exists()` gate is a TOCTOU race / app defect —
+110 -5
View File
@@ -35,9 +35,16 @@ static WARNED_CONFIG_READ_FAILURE: OnceLock<Mutex<bool>> = OnceLock::new();
/// permanently corrupted file does not flood telemetry (#5167).
async fn read_config_with_recovery_or_default(config_path: &Path) -> Result<(String, bool)> {
let reads = || async {
fs::read_to_string(config_path)
.await
.with_context(|| format!("Failed to read config file: {}", config_path.display()))
match fs::read_to_string(config_path).await {
Ok(contents) => Ok(contents),
Err(error) => {
let ownership = describe_config_ownership(config_path).await;
Err(anyhow::Error::new(error).context(format!(
"Failed to read config file: {}{ownership}",
config_path.display()
)))
}
}
};
let result =
@@ -57,6 +64,11 @@ async fn read_config_with_recovery_or_default(config_path: &Path) -> Result<(Str
});
if !is_content_corruption {
tracing::warn!(
path = %config_path.display(),
error = %format!("{e:#}"),
"[config] failed to read config file"
);
return Err(e);
}
@@ -196,6 +208,56 @@ async fn parse_toml_off_worker(contents: String) -> Result<Config, String> {
}
}
/// Marker appended to a config-read failure when the file is owned by a
/// different uid than the process trying to read it.
///
/// `core::observability::expected_error_kind` keys on this to keep the failure
/// paging instead of demoting it as an unpreventable user-environment denial:
/// a uid mismatch on a file *we* created with mode 0600 is an OpenHuman defect
/// (typically a container whose entrypoint chowned only the workspace
/// directory, leaving a stale-uid `config.toml` inside it), not an ACL the user
/// has to fix for us.
pub(crate) const CONFIG_OWNER_MISMATCH_MARKER: &str = "[config owner mismatch]";
/// Numeric ownership/permission facts about the config file, appended to the
/// read-failure context.
///
/// An `EACCES` on a file whose `exists()` check just succeeded is fully
/// explained by four numbers we can always obtain: the file's uid/gid, its
/// mode, and the process's effective uid/gid. Without them the operator sees
/// only "Permission denied (os error 13)" and cannot tell a foreign-owner
/// container volume (our bug) from a genuine ACL / antivirus denial (theirs).
///
/// Numeric ids and mode bits are not PII, so this is safe both in the local log
/// and in the error chain that reaches the client.
#[cfg(unix)]
async fn describe_config_ownership(path: &Path) -> String {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let Ok(meta) = fs::metadata(path).await else {
return String::new();
};
// SAFETY: `geteuid`/`getegid` take no arguments, mutate no process state,
// and are documented as always succeeding.
let (euid, egid) = unsafe { (libc::geteuid(), libc::getegid()) };
let mismatch = if meta.uid() == euid {
String::new()
} else {
format!("{CONFIG_OWNER_MISMATCH_MARKER} ")
};
format!(
" {mismatch}(file uid={} gid={} mode={:04o}; process euid={euid} egid={egid})",
meta.uid(),
meta.gid(),
meta.permissions().mode() & 0o777,
)
}
#[cfg(not(unix))]
async fn describe_config_ownership(_path: &Path) -> String {
String::new()
}
impl Config {
pub async fn load_or_init() -> Result<Self> {
let (default_openhuman_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
@@ -248,9 +310,23 @@ impl Config {
if config_path.exists() {
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
use std::{
fs::Permissions,
os::unix::fs::{MetadataExt, PermissionsExt},
};
if let Ok(meta) = fs::metadata(&config_path).await {
if meta.permissions().mode() & 0o004 != 0 {
// SAFETY: `geteuid` takes no arguments, mutates no process
// state, and is documented as always succeeding.
let euid = unsafe { libc::geteuid() };
// Only harden a file we own. Chmod-ing a foreign-owned
// config either fails with EPERM (log noise) or — with
// CAP_FOWNER, e.g. a root-run CLI inside a container —
// succeeds and strips the read bit that was the only thing
// letting the *other* uid (the one that actually serves
// requests) open it. Leaving a foreign 0644 config alone is
// strictly safer: it still loads, and the entrypoint owns
// repairing ownership.
if meta.uid() == euid && meta.permissions().mode() & 0o004 != 0 {
let warned = WARNED_WORLD_READABLE_CONFIGS
.get_or_init(|| Mutex::new(HashSet::new()));
let already_fixed = warned
@@ -570,6 +646,35 @@ impl Config {
temp_path.display()
)
})?;
// Harden BEFORE any secret bytes are written. `create_new` opens at
// `0o666 & ~umask` (0644 under the usual 022), and the atomic rename
// below carries the *temp file's* mode onto the live config — so
// without this every save silently re-widened a config that holds
// `enc2:` provider keys and channel tokens back to world-readable, and
// `fs::copy` propagated the same mode onto `config.toml.bak`. The
// load-time auto-fix only ever repaired it on the next startup.
// Same pattern as `keyring::backend`.
//
// Non-fatal by design: filesystems that do not implement chmod
// (CIFS/SMB, exFAT, some FUSE mounts) would otherwise turn a save that
// has always worked into a hard failure. A failed hardening leaves the
// file exactly as permissive as it was before this call existed, so
// warn and continue rather than regress writability — matching the
// best-effort `let _ = set_permissions(..)` on the first-init path.
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
if let Err(e) = fs::set_permissions(&temp_path, Permissions::from_mode(0o600)).await {
tracing::warn!(
path = %temp_path.display(),
error = %e,
"[security][config] could not restrict config file to 0600; \
it may be readable by other local users"
);
}
}
temp_file
.write_all(toml_str.as_bytes())
.await
+7
View File
@@ -17,6 +17,13 @@ pub use dirs::{
// can access it because they are a submodule and use `use super::*`.
pub(super) use migrate::redact_url_for_log;
// The wire contract between the config loader (which appends the marker to a
// read failure) and `core::observability::expected_error_kind` (which keys on
// it to keep an ownership mismatch paging instead of demoting it as
// user-environment state). Exported so the classifier's tests can assert
// against the real constant rather than a copied literal.
pub(crate) use impl_load::CONFIG_OWNER_MISMATCH_MARKER;
// Items needed by load_tests.rs (loaded as `mod tests` below).
// Tests are a submodule of `load`, so `super::*` == this module's namespace.
#[cfg(test)]
+86
View File
@@ -1772,6 +1772,92 @@ async fn load_or_init_read_failure_embeds_path_in_error_context() {
);
}
/// A read denial on an existing config is fully explained by the file's
/// uid/gid/mode and the process's euid — numbers the loader already has in
/// hand. Without them a report of
/// `Permission denied (os error 13)` cannot distinguish a mis-owned container
/// volume (our defect) from a host ACL (the user's), which is exactly the
/// ambiguity that made the sign-in failure undiagnosable.
#[cfg(unix)]
#[tokio::test]
async fn load_or_init_read_failure_reports_file_and_process_ownership() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let config_path = root.join("config.toml");
std::fs::write(&config_path, "default_temperature = 0.5\n").unwrap();
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&config_path).is_ok() {
return; // running as root — permissions are ignored, assertion is moot
}
let env = MapEnv::default().with("OPENHUMAN_WORKSPACE", root.to_str().unwrap());
let err = Config::load_or_init_with_env_lookup(root, &root.join("workspace"), &env)
.await
.expect_err("reading an unreadable config.toml must fail");
let msg = format!("{err:#}");
for needle in ["file uid=", "gid=", "mode=0000", "process euid="] {
assert!(
msg.contains(needle),
"error must carry `{needle}` so the denial is diagnosable: {msg}"
);
}
// We created the file, so it is NOT an ownership mismatch — the marker
// must stay off, or every ordinary ACL denial would start paging.
assert!(
!msg.contains(CONFIG_OWNER_MISMATCH_MARKER),
"a config we own must not be reported as an ownership mismatch: {msg}"
);
}
/// `Config::save` writes the live config through a temp file + atomic rename,
/// so the temp file's mode becomes the config's mode. It used to be created at
/// `0o666 & ~umask` (0644), silently re-widening a file that holds `enc2:`
/// provider keys and channel tokens on every settings change, and propagating
/// the same mode onto `config.toml.bak` via `fs::copy`.
#[cfg(unix)]
#[tokio::test]
async fn save_keeps_config_and_backup_owner_only_readable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.toml");
let config = Config {
config_path: config_path.clone(),
workspace_dir: tmp.path().join("workspace"),
..Default::default()
};
config.save().await.expect("first save");
let mode = std::fs::metadata(&config_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "config.toml must be owner-only after save");
// The second save is the one that creates the .bak (from the temp file).
config.save().await.expect("second save");
let backup_mode = std::fs::metadata(config_path.with_extension("toml.bak"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(
backup_mode, 0o600,
"config.toml.bak holds the same secrets and must be owner-only too"
);
let mode_after = std::fs::metadata(&config_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode_after, 0o600, "an overwriting save must not re-widen");
}
// ── non-UTF-8 (binary) config recovery (#5167) ──────────────────────────
/// Helper: write binary (non-UTF-8) bytes to a file.
+5
View File
@@ -29,6 +29,11 @@ pub use load::{
default_projects_dir, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
resolve_action_dir, user_openhuman_dir, write_active_user_id, PRE_LOGIN_USER_ID,
};
// Contract shared with `core::observability::expected_error_kind`: the loader
// appends this marker to a config-read failure when the file's owner differs
// from the reading process, and the classifier keys on it to keep that case
// paging. Exported so the classifier tests pin the real constant.
pub(crate) use load::CONFIG_OWNER_MISMATCH_MARKER;
pub mod claude_agent_sdk;
pub use claude_agent_sdk::ClaudeAgentSdkConfig;
mod local_ai;