From 3edee1a8c1725664378aa32ba836f622a958ce64 Mon Sep 17 00:00:00 2001 From: Mike Pawlowski Date: Tue, 24 Mar 2026 07:47:55 -0700 Subject: [PATCH 1/4] fix: resolved Langflow data directory permission error on macOS with Podman Issue - #1233 Summary - Added chmod 777 langflow-data to the ensure-langflow-data Makefile target so all make dev-* targets pre-create the directory with world-writable permissions, matching the behaviour already present in test-ci - Added pre-creation of the Langflow data directory with os.chmod(0o777) in setup_host_directories() so the TUI (uvx openrag) path no longer relies on Docker/Podman to create the directory on-the-fly with incorrect ownership - Added _resolve_langflow_data_path() helper to read the configured LANGFLOW_DATA_PATH from the TUI .env, honouring user-customised absolute paths and falling back to ~/.openrag/data/langflow-data for empty or relative values; relative paths emit a warning and fall back to the default since CWD is unpredictable in the TUI context Factory Reset - Added deletion of the Langflow data directory to _factory_reset_with_data_clear() in the TUI monitor screen, mirroring the factory-reset Makefile target which already removed ./langflow-data - Shared _resolve_langflow_data_path() used in factory reset to target the same directory that was pre-created at startup, including a safety guard that refused deletion of any path resolving outside the user's home directory Legacy Data Migration - Added langflow-data to migrate_legacy_data_directories() migration mappings so users upgrading from a Makefile-based setup had their ./langflow-data directory copied to ~/.openrag/data/langflow-data on first TUI startup - Added langflow_data_path to both .env update blocks in migrate_legacy_data_directories() (fresh-install path and post-copy path) so LANGFLOW_DATA_PATH was normalised to the absolute TUI default alongside all other data paths --- Makefile | 1 + src/tui/main.py | 49 +++++++++++++++++++++++++++++++++++++- src/tui/screens/monitor.py | 31 +++++++++++++++++++----- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 9cebd1b8f..42715c82e 100644 --- a/Makefile +++ b/Makefile @@ -322,6 +322,7 @@ help_utils: ## Show utility commands ensure-langflow-data: ## Create the langflow-data directory if it does not exist @mkdir -p langflow-data + @chmod 777 langflow-data dev: ensure-langflow-data ## Start full stack with GPU support @echo "$(YELLOW)Starting OpenRAG with GPU support...$(NC)" diff --git a/src/tui/main.py b/src/tui/main.py index 3ab8e45ff..56b32d985 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -562,7 +562,7 @@ def migrate_legacy_data_directories(): """Migrate data from CWD-based directories to ~/.openrag/. This is a one-time migration for users upgrading from the old layout. - Migrates: documents, flows, keys, config, opensearch-data + Migrates: documents, flows, keys, config, opensearch-data, langflow-data Prompts user for confirmation before migrating. If user declines, exits with a message to downgrade to v1.52 or earlier. @@ -585,6 +585,7 @@ def migrate_legacy_data_directories(): (cwd / "keys", target_base / "keys", "keys"), (cwd / "config", target_base / "config", "config"), (cwd / "opensearch-data", target_base / "data" / "opensearch-data", "OpenSearch data"), + (cwd / "langflow-data", target_base / "data" / "langflow-data", "Langflow data"), ] # Check which sources exist and need migration @@ -608,6 +609,7 @@ def migrate_legacy_data_directories(): env_manager.config.openrag_config_path = f"{home}/.openrag/config" env_manager.config.openrag_data_path = f"{home}/.openrag/data" env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data" + env_manager.config.langflow_data_path = f"{home}/.openrag/data/langflow-data" env_manager.save_env() logger.info("Updated .env file with centralized paths") except Exception as e: @@ -687,6 +689,7 @@ def migrate_legacy_data_directories(): env_manager.config.openrag_config_path = f"{home}/.openrag/config" env_manager.config.openrag_data_path = f"{home}/.openrag/data" env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data" + env_manager.config.langflow_data_path = f"{home}/.openrag/data/langflow-data" env_manager.save_env() print(" Updated .env with centralized paths") logger.info("Updated .env file with centralized paths") @@ -753,6 +756,7 @@ def setup_host_directories(): - ~/.openrag/config/ (for configuration) - ~/.openrag/data/ (for backend data: conversations, OAuth tokens, etc.) - ~/.openrag/data/opensearch-data/ (for OpenSearch index) + - LANGFLOW_DATA_PATH (for Langflow database and state) """ base_dir = Path.home() / ".openrag" directories = [ @@ -768,10 +772,53 @@ def setup_host_directories(): directory.mkdir(parents=True, exist_ok=True) logger.debug(f"Ensured directory exists: {directory}") + # Resolve the configured LANGFLOW_DATA_PATH so we pre-create the exact + # directory that Docker/Podman will mount, regardless of user customisation. + langflow_data_dir = _resolve_langflow_data_path(base_dir) + langflow_data_dir.mkdir(parents=True, exist_ok=True) + logger.debug(f"Ensured directory exists: {langflow_data_dir}") + + # langflow-data must be world-writable so the Langflow container user (uid 1000) + # can write into it on macOS where Podman's :U uid-remapping does not reliably + # update host directory ownership through the VM layer. + os.chmod(langflow_data_dir, 0o777) + # Generate JWT keys on host to avoid container permission issues generate_jwt_keys(base_dir / "keys") +def _resolve_langflow_data_path(base_dir: Path) -> Path: + """Return the absolute path for the Langflow data directory. + + Reads LANGFLOW_DATA_PATH from the TUI .env file when available; falls back + to the default location (~/.openrag/data/langflow-data). + + Relative paths are not valid in the TUI context because the process working + directory is unpredictable. If a relative path is found the default is used + and a warning is logged so the user can correct their configuration. + """ + default = base_dir / "data" / "langflow-data" + try: + from .managers.env_manager import EnvManager + env_manager = EnvManager() + env_manager.load_existing_env() + raw = env_manager.config.langflow_data_path + if not raw: + return default + expanded = raw.replace("$HOME", str(Path.home())) + resolved = Path(expanded).expanduser() + if not resolved.is_absolute(): + logger.warning( + f"LANGFLOW_DATA_PATH='{raw}' is a relative path, which is not supported " + f"in the TUI. Using default: {default}" + ) + return default + return resolved + except Exception as e: + logger.debug(f"Could not read LANGFLOW_DATA_PATH from env, using default: {e}") + return default + + def _run_tui_app(): """Run the existing Textual TUI application.""" app = None diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index b39bf91f1..5c2b6fd81 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -538,25 +538,28 @@ def expand_path(path_str: str) -> Path: async def _factory_reset_with_data_clear(self) -> AsyncIterator[tuple[bool, str]]: """Generator that stops services and clears opensearch data.""" + import shutil + # First stop all services async for success, message in self.container_manager.reset_services(): yield success, message if not success and "failed" in message.lower(): return - - # Now clear opensearch-data using container - yield False, "Clearing OpenSearch data..." - # Get opensearch data path from env config + + # Get data paths from env config from ..managers.env_manager import EnvManager env_manager = EnvManager() env_manager.load_existing_env() + + # Clear opensearch-data using container + yield False, "Clearing OpenSearch data..." opensearch_data_path = Path(env_manager.config.opensearch_data_path.replace("$HOME", str(Path.home()))).expanduser() if opensearch_data_path.exists(): async for success, message in self.container_manager.clear_opensearch_data_volume(): yield success, message if not success and "failed" in message.lower(): return - + # Recreate empty opensearch-data directory try: opensearch_data_path.mkdir(parents=True, exist_ok=True) @@ -564,7 +567,23 @@ async def _factory_reset_with_data_clear(self) -> AsyncIterator[tuple[bool, str] except Exception as e: yield False, f"Error recreating opensearch-data directory: {e}" return - + + # Delete langflow-data directory (mirrors Makefile factory-reset behaviour) + yield False, "Clearing Langflow data..." + from tui.main import _resolve_langflow_data_path + langflow_data_path = _resolve_langflow_data_path(Path.home() / ".openrag").resolve() + home = Path.home().resolve() + if not str(langflow_data_path).startswith(str(home) + "/"): + yield False, f"Refusing to delete path outside home directory: {langflow_data_path}" + return + if langflow_data_path.exists(): + try: + shutil.rmtree(langflow_data_path) + yield True, "Langflow data directory removed" + except Exception as e: + yield False, f"Error removing langflow-data directory: {e}" + return + yield True, "Factory reset completed successfully" async def _prune_images(self) -> None: From 69460cc5e51796ad4be0e6cea8fd09490aff8838 Mon Sep 17 00:00:00 2001 From: Mike Pawlowski Date: Tue, 24 Mar 2026 13:35:06 -0700 Subject: [PATCH 2/4] fix: resolved Langflow data directory permission error on macOS with Podman Issue - #1233 Summary - Fixed Langflow data directory permission error on macOS with Podman Container Entrypoint - Added scripts/langflow-entrypoint.py, a Python entrypoint script that runs as root at container startup - Entrypoint corrects /app/langflow-data bind-mount permissions (chmod 0o777) to work around macOS + Podman virtiofs not propagating host-side chmod into the container - After fixing permissions, the entrypoint drops privileges from root to uid/gid 1000 (langflow user) before exec-ing the main process Dockerfile Changes - Copied scripts/langflow-entrypoint.py into the image at /usr/local/bin/langflow-entrypoint with executable permissions (--chmod=755) - Switched to USER root after the COPY so the entrypoint has the privileges needed to correct bind-mount ownership at runtime - Set ENTRYPOINT ["langflow-entrypoint"] so the permission-fix logic runs before the default CMD --- Dockerfile.langflow | 7 +++++++ scripts/langflow-entrypoint.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 scripts/langflow-entrypoint.py diff --git a/Dockerfile.langflow b/Dockerfile.langflow index 122c8e5ea..d727620d4 100644 --- a/Dockerfile.langflow +++ b/Dockerfile.langflow @@ -6,6 +6,13 @@ FROM langflowai/langflow:1.8.0 RUN pip install uv \ && mkdir -p /app/langflow-data +# Copy the entrypoint before switching to root so the COPY runs as uid=1000 +# (which owns /app), then switch to root so the entrypoint can correct +# bind-mount permissions at runtime before dropping back to uid=1000. +COPY --chmod=755 scripts/langflow-entrypoint.py /usr/local/bin/langflow-entrypoint +USER root + EXPOSE 7860 +ENTRYPOINT ["langflow-entrypoint"] CMD ["langflow", "run", "--host", "0.0.0.0", "--port", "7860"] diff --git a/scripts/langflow-entrypoint.py b/scripts/langflow-entrypoint.py new file mode 100644 index 000000000..ed596f758 --- /dev/null +++ b/scripts/langflow-entrypoint.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Entrypoint for the OpenRAG Langflow container. + +Runs as root to correct /app/langflow-data bind-mount permissions, then drops +to uid/gid 1000 (langflow user) before exec-ing the main process. + +On macOS with Podman the virtiofs layer does not faithfully propagate +host-side chmod into the container, so permissions must be fixed from +inside the container after the mount is established. +""" +import os +import pathlib +import sys + +data_dir = pathlib.Path("/app/langflow-data") + +try: + data_dir.chmod(0o777) +except OSError: + pass + +# Drop from root to langflow (uid=1000, gid=1000). +os.setgid(1000) +os.setuid(1000) + +os.execvp(sys.argv[1], sys.argv[1:]) From 6e7a6bce9ca21a5065335db4e7e28532e624b592 Mon Sep 17 00:00:00 2001 From: Mike Pawlowski Date: Tue, 24 Mar 2026 13:56:52 -0700 Subject: [PATCH 3/4] fix: resolved Langflow data directory permission error on macOS with Podman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue - #1233 Summary - Fixed Langflow container startup failure on macOS with Podman caused by incorrect HOME environment variable after privilege drop. Bug Fix - Added pwd module import to look up the passwd entry for uid 1000 before dropping privileges. - After dropping from root to uid/gid 1000, restored HOME and USER environment variables to reflect the unprivileged user. - Added fallback values (/app and langflow) if uid 1000 has no passwd entry. Root Cause - When the container ran as root (USER root in the Dockerfile), HOME was set to /root. - After os.setuid(1000), the HOME variable remained /root, causing uv to attempt writing to /root/.cache/uv — a directory the now-unprivileged process could not access. - This manifested specifically on macOS with Podman due to how that runtime handles volume mount ownership. --- scripts/langflow-entrypoint.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/langflow-entrypoint.py b/scripts/langflow-entrypoint.py index ed596f758..12370ca96 100644 --- a/scripts/langflow-entrypoint.py +++ b/scripts/langflow-entrypoint.py @@ -10,6 +10,7 @@ """ import os import pathlib +import pwd import sys data_dir = pathlib.Path("/app/langflow-data") @@ -19,8 +20,24 @@ except OSError: pass +# Look up uid 1000's passwd entry so we can restore HOME and USER correctly +# after dropping privileges. Running as root (USER root in Dockerfile) sets +# HOME=/root; leaving it unchanged causes uv to try /root/.cache/uv, which +# uid 1000 cannot write to. +try: + pw = pwd.getpwuid(1000) + home = pw.pw_dir + user = pw.pw_name +except KeyError: + home = "/app" + user = "langflow" + # Drop from root to langflow (uid=1000, gid=1000). os.setgid(1000) os.setuid(1000) +# Restore environment variables to reflect the unprivileged user. +os.environ["HOME"] = home +os.environ["USER"] = user + os.execvp(sys.argv[1], sys.argv[1:]) From 119e9f262f8126faea0f69233e0b63aacf7bcb8f Mon Sep 17 00:00:00 2001 From: Mike Pawlowski Date: Tue, 24 Mar 2026 14:57:13 -0700 Subject: [PATCH 4/4] fix: resolved Langflow data directory permission error on macOS with Podman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue - #1233 Summary - Fixed save_env → save_env_file method renames and auto-corrected relative LANGFLOW_DATA_PATH on detection Bug Fixes - Renamed two calls to the deprecated save_env() method to the correct save_env_file() in migrate_legacy_data_directories() to fix runtime AttributeError during legacy data directory migration Behavior Changes - Updated log message wording from "Using default" to "Resetting to default" when a relative LANGFLOW_DATA_PATH is detected to better reflect the action taken - Added automatic correction of a relative LANGFLOW_DATA_PATH in the .env file when detected — previously the TUI would fall back to the default silently without persisting the fix, leaving the bad value in place for subsequent runs --- src/tui/main.py | 68 +++++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/src/tui/main.py b/src/tui/main.py index 56b32d985..884ce864f 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -64,17 +64,17 @@ class OpenRAGTUI(App): min-width: 20; border: solid #3f3f46; } - + #config-header { text-align: center; margin-bottom: 2; } - + #config-scroll { height: 1fr; overflow-y: auto; } - + #config-form { width: 80%; max-width: 100; @@ -82,7 +82,7 @@ class OpenRAGTUI(App): padding: 1; height: auto; } - + #config-form Input { margin-bottom: 1; width: 100%; @@ -99,7 +99,7 @@ class OpenRAGTUI(App): width: auto; min-width: 12; } - + #config-form Label { margin-bottom: 0; padding-left: 1; @@ -145,18 +145,18 @@ class OpenRAGTUI(App): } /* Docs path actions row */ - + #services-content { height: 100%; } - + #runtime-status { background: $panel; border: solid $primary; padding: 1; margin-bottom: 1; } - + #services-table { height: auto; max-height: 12; @@ -169,52 +169,52 @@ class OpenRAGTUI(App): margin-bottom: 1; } - - + + #logs-scroll { height: 1fr; border: solid $primary; background: $surface; } - + .controls-row { align: left middle; height: auto; margin: 1 0; } - + .controls-row > * { margin-right: 1; } - + .label { width: auto; margin-right: 1; text-style: bold; } - + #system-info { background: $panel; border: solid $primary; padding: 2; height: 1fr; } - + TabbedContent { height: 1fr; } - + TabPane { padding: 1; height: 1fr; } - + .tab-header { text-style: bold; color: $accent; margin-bottom: 1; } - + TabPane ScrollableContainer { height: 100%; padding: 1; @@ -367,7 +367,7 @@ def __init__(self): self.container_manager = ContainerManager() self.env_manager = EnvManager() self.docling_manager = DoclingManager() # Initialize singleton instance - + def notify( self, message: str, @@ -457,17 +457,17 @@ def _copy_assets(resource_tree, destination: Path, allowed_suffixes: Optional[It def copy_sample_documents(*, force: bool = False) -> None: """Copy sample documents from package to host directory. - + Uses the first path from OPENRAG_DOCUMENTS_PATHS env var. Defaults to ~/.openrag/documents if not configured. """ from .managers.env_manager import EnvManager from pathlib import Path - + # Get the configured documents path from env env_manager = EnvManager() env_manager.load_existing_env() - + # Parse the first path from the documents paths config documents_path_str = env_manager.config.openrag_documents_paths if documents_path_str: @@ -478,7 +478,7 @@ def copy_sample_documents(*, force: bool = False) -> None: else: # Default fallback documents_dir = Path.home() / ".openrag" / "documents" - + documents_dir.mkdir(parents=True, exist_ok=True) try: @@ -504,11 +504,11 @@ def copy_sample_documents(*, force: bool = False) -> None: def copy_sample_flows(*, force: bool = False) -> None: """Copy sample flows from package to host directory. - + Flows are placed in ~/.openrag/flows/ which will be volume-mounted to containers. """ from pathlib import Path - + # Flows always go to ~/.openrag/flows/ - this will be volume-mounted flows_dir = Path.home() / ".openrag" / "flows" flows_dir.mkdir(parents=True, exist_ok=True) @@ -524,7 +524,7 @@ def copy_sample_flows(*, force: bool = False) -> None: def copy_compose_files(*, force: bool = False) -> None: """Copy docker-compose templates into the TUI workspace if they are missing.""" from utils.paths import get_tui_compose_file - + try: assets_root = files("tui._assets") except Exception as e: @@ -534,7 +534,7 @@ def copy_compose_files(*, force: bool = False) -> None: for filename in ("docker-compose.yml", "docker-compose.gpu.yml"): is_gpu = "gpu" in filename destination = get_tui_compose_file(gpu=is_gpu) - + if destination.exists() and not force: continue @@ -610,7 +610,7 @@ def migrate_legacy_data_directories(): env_manager.config.openrag_data_path = f"{home}/.openrag/data" env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data" env_manager.config.langflow_data_path = f"{home}/.openrag/data/langflow-data" - env_manager.save_env() + env_manager.save_env_file() logger.info("Updated .env file with centralized paths") except Exception as e: logger.warning(f"Failed to update .env paths: {e}") @@ -690,7 +690,7 @@ def migrate_legacy_data_directories(): env_manager.config.openrag_data_path = f"{home}/.openrag/data" env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data" env_manager.config.langflow_data_path = f"{home}/.openrag/data/langflow-data" - env_manager.save_env() + env_manager.save_env_file() print(" Updated .env with centralized paths") logger.info("Updated .env file with centralized paths") except Exception as e: @@ -810,12 +810,18 @@ def _resolve_langflow_data_path(base_dir: Path) -> Path: if not resolved.is_absolute(): logger.warning( f"LANGFLOW_DATA_PATH='{raw}' is a relative path, which is not supported " - f"in the TUI. Using default: {default}" + f"in the TUI. Resetting to default: {default}" ) + try: + env_manager.config.langflow_data_path = str(default) + env_manager.save_env_file() + logger.info(f"Updated LANGFLOW_DATA_PATH to {default} in {env_manager.env_file}") + except Exception as write_err: + logger.error(f"Could not update LANGFLOW_DATA_PATH in env file: {write_err}") return default return resolved except Exception as e: - logger.debug(f"Could not read LANGFLOW_DATA_PATH from env, using default: {e}") + logger.error(f"Could not read LANGFLOW_DATA_PATH from env, using default: {e}") return default