diff --git a/.dockerignore b/.dockerignore index a8bebec2cc..544e2663f9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,4 +23,5 @@ data/ tests/ .ruff_cache/ .astrbot +.astrbot-tmp astrbot.lock diff --git a/.gitignore b/.gitignore index 8304f1e311..746cc1d3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ astrbot/dashboard/dist/ # AstrBot specific .astrbot +.astrbot-tmp astrbot.lock # Other diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index e6afde7aef..1186a02a00 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -15,6 +15,7 @@ import os import tempfile +from pathlib import Path from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime @@ -88,8 +89,22 @@ def get_astrbot_workspaces_path() -> str: def get_astrbot_system_tmp_path() -> str: - """Return the shared system temporary directory used by local tools.""" - return os.path.realpath(os.path.join(tempfile.gettempdir(), ".astrbot")) + """Return the shared system temporary directory used by local tools. + + The directory is the ``.astrbot`` entry of the system temporary + directory. When the system temporary directory is unusable, Python falls + back to the current working directory, which is where ``astrbot init`` + leaves the ``.astrbot`` marker file. In that case a sibling directory is + used so the marker file is never shadowed by a directory. + + Returns: + Absolute path of the shared system temporary directory. + """ + system_tmp = Path(tempfile.gettempdir()) + candidate = system_tmp / ".astrbot" + if candidate.is_file(): + candidate = system_tmp / ".astrbot-tmp" + return os.path.realpath(str(candidate)) def get_astrbot_site_packages_path() -> str: diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 2e6d55d888..4ff5cb5385 100644 --- a/changelogs/unreleased.md +++ b/changelogs/unreleased.md @@ -1,5 +1,7 @@ # Unreleased +- Fix plugin installation failing with `FileExistsError` in NAS and similar environments where cross-device (`EXDEV`) errors make system temporary directories unusable and Python falls back to the working directory. Use `.astrbot-tmp` when the temporary path conflicts with the `.astrbot` initialization marker file. ([#10784](https://github.com/AstrBotDevs/AstrBot/pull/10784)) + - Simplify Context Management Strategy settings: Handling for History Limits or Context Window Pressure → Compression Strategy; Model Provider ID for Context Compression → Compression Model. Move maximum turns, discarded turns, compression instruction, recent-context ratio and fallback context window size under the collapsed More Settings section. Preserve existing configuration keys, defaults and runtime behavior. - Add compact data-cleanup checkboxes to the existing bot deletion dialog. Custom rules, preferences, configuration routes and scheduled tasks are selected by default; AI conversations, message history, local traces and usage statistics are retained unless selected. Preserve shared resources and match adapter IDs exactly. diff --git a/tests/test_astrbot_path.py b/tests/test_astrbot_path.py new file mode 100644 index 0000000000..58c5f1498b --- /dev/null +++ b/tests/test_astrbot_path.py @@ -0,0 +1,63 @@ +import os +import tempfile +from pathlib import Path + +from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path + + +def test_system_tmp_path_lives_in_the_system_temp_dir(monkeypatch, tmp_path): + system_temp = tmp_path / "system_temp" + system_temp.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) + + assert get_astrbot_system_tmp_path() == os.path.realpath( + str(system_temp / ".astrbot") + ) + + +def test_system_tmp_path_keeps_an_existing_directory(monkeypatch, tmp_path): + system_temp = tmp_path / "system_temp" + (system_temp / ".astrbot").mkdir(parents=True) + monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) + + assert get_astrbot_system_tmp_path() == os.path.realpath( + str(system_temp / ".astrbot") + ) + + +def test_system_tmp_path_never_shadows_the_init_marker_file(monkeypatch, tmp_path): + """The system temp dir can fall back to the AstrBot root. + + There ".astrbot" is the marker file created by "astrbot init", so the + shared temporary directory must not be created on top of it. + """ + system_temp = tmp_path / "system_temp" + system_temp.mkdir() + marker = system_temp / ".astrbot" + marker.write_text("", encoding="utf-8") + monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) + + resolved = get_astrbot_system_tmp_path() + + assert resolved != os.path.realpath(str(marker)) + Path(resolved).mkdir(parents=True, exist_ok=True) + assert Path(resolved).is_dir() + assert marker.is_file() + + +def test_fallback_temp_dir_is_ignored_by_git_and_docker(): + """The fallback temp dir must not leak into git history or images. + + The temp dir can be created inside the repository root, so both ignore + files have to exclude it. + """ + repo_root = Path(__file__).resolve().parents[1] + + for ignore_file in (".gitignore", ".dockerignore"): + entries = { + line.strip() + for line in (repo_root / ignore_file) + .read_text(encoding="utf-8") + .splitlines() + } + assert ".astrbot-tmp" in entries, f"{ignore_file} must ignore .astrbot-tmp" diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index e7b5787ac1..68bdf9ae34 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -4,6 +4,7 @@ import json import os import sys +import tempfile import zipfile from pathlib import Path from types import ModuleType @@ -16,6 +17,7 @@ from astrbot.core.star import star_manager as star_manager_module from astrbot.core.star.star_handler import EventType, StarHandlerMetadata from astrbot.core.star.star_manager import PluginDependencyInstallError, PluginManager +from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path from astrbot.core.utils.pip_installer import PipInstallError from astrbot.core.utils.requirements_utils import MissingRequirementsPlan @@ -427,13 +429,16 @@ def local_updater(plugin_manager_pm): @pytest.mark.parametrize("dependency_install_fails", [False, True]) @pytest.mark.parametrize("cross_filesystem", [False, True]) async def test_install_plugin_dependency_install_flow( - plugin_manager_pm: PluginManager, monkeypatch, dependency_install_fails: bool, + plugin_manager_pm: PluginManager, + monkeypatch, + dependency_install_fails: bool, cross_filesystem: bool, ): plugin_path = Path(plugin_manager_pm.plugin_store_path) / TEST_PLUGIN_DIR events = [] _mock_missing_requirements(monkeypatch, {"networkx"}) if cross_filesystem: + def cross_device_rename(*args, **kwargs): raise OSError(errno.EXDEV, "Cross-device move") @@ -480,6 +485,49 @@ def mock_load_and_register(*args, **kwargs): assert events[1] == ("load", TEST_PLUGIN_DIR) +@pytest.mark.asyncio +async def test_install_plugin_staging_root_does_not_shadow_the_marker_file( + plugin_manager_pm: PluginManager, monkeypatch, tmp_path +): + """Installing a plugin must not fail when the temp dir holds the marker file. + + Python falls back to the current working directory when the system + temporary directory is unusable, and that directory holds the ".astrbot" + marker file created by "astrbot init". + """ + system_temp = tmp_path / "system_temp" + system_temp.mkdir() + marker = system_temp / ".astrbot" + marker.write_text("", encoding="utf-8") + monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) + monkeypatch.setattr( + star_manager_module, + "get_astrbot_system_tmp_path", + get_astrbot_system_tmp_path, + ) + + events = [] + _mock_missing_requirements(monkeypatch, {"networkx"}) + + async def mock_install(repo_url: str, proxy="", *, download_url="", target_dir): + staged_path = Path(target_dir) + _write_local_test_plugin(staged_path, repo_url) + _write_requirements(staged_path) + return str(staged_path) + + monkeypatch.setattr(plugin_manager_pm._updater, "install", mock_install) + monkeypatch.setattr( + "astrbot.core.star.star_manager.pip_installer.install", + _build_dependency_install_mock(events, False), + ) + monkeypatch.setattr(plugin_manager_pm, "load", _build_load_mock(events)) + + await plugin_manager_pm.install_plugin(TEST_PLUGIN_REPO) + + assert marker.is_file() + assert events[-1] == ("load", TEST_PLUGIN_DIR) + + @pytest.mark.asyncio @pytest.mark.parametrize("dependency_install_fails", [False, True]) async def test_install_plugin_from_file_dependency_install_flow( @@ -585,7 +633,9 @@ async def test_install_updates_existing_plugin_and_restores_on_failure( original_rename = os.rename def cross_device_rename(source, destination, *args, **kwargs): - if Path(source).is_relative_to(system_temp) != Path(destination).is_relative_to(system_temp): + if Path(source).is_relative_to(system_temp) != Path(destination).is_relative_to( + system_temp + ): raise OSError(errno.EXDEV, "Cross-device move") return original_rename(source, destination, *args, **kwargs) @@ -816,7 +866,9 @@ async def load(specified_dir_name=None, ignore_version_check=False): assert {path.name: path.read_bytes() for path in backup.iterdir()} == old_files assert versions_loaded == ["2.0.0"] else: - assert {path.name: path.read_bytes() for path in local_updater.iterdir()} == old_files + assert { + path.name: path.read_bytes() for path in local_updater.iterdir() + } == old_files assert versions_loaded == ["1.0.0"] assert list(system_temp.iterdir()) == []