From a4a9548bcf494cea09bb9a3434a03247a6581fbe Mon Sep 17 00:00:00 2001 From: rwzdk Date: Mon, 26 Oct 2026 21:19:52 +0800 Subject: [PATCH 1/5] fix: avoid shadowing the .astrbot marker file in the system temp dir --- astrbot/core/utils/astrbot_path.py | 19 ++++++++-- tests/test_astrbot_path.py | 45 +++++++++++++++++++++++ tests/test_plugin_manager.py | 58 ++++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 tests/test_astrbot_path.py diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index e6afde7aef6..1186a02a00c 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/tests/test_astrbot_path.py b/tests/test_astrbot_path.py new file mode 100644 index 00000000000..5cb7994329d --- /dev/null +++ b/tests/test_astrbot_path.py @@ -0,0 +1,45 @@ +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() diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index e7b5787ac16..68bdf9ae34a 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()) == [] From 6151ef68c7ba05ffb294c8c4622488f3721398b1 Mon Sep 17 00:00:00 2001 From: rwzdk Date: Mon, 26 Oct 2026 21:36:09 +0800 Subject: [PATCH 2/5] fix: ignore the fallback temp dir in git and docker --- .dockerignore | 1 + .gitignore | 1 + tests/test_astrbot_path.py | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/.dockerignore b/.dockerignore index a8bebec2ccf..544e2663f90 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 8304f1e3119..746cc1d3a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ astrbot/dashboard/dist/ # AstrBot specific .astrbot +.astrbot-tmp astrbot.lock # Other diff --git a/tests/test_astrbot_path.py b/tests/test_astrbot_path.py index 5cb7994329d..58c5f1498bc 100644 --- a/tests/test_astrbot_path.py +++ b/tests/test_astrbot_path.py @@ -43,3 +43,21 @@ def test_system_tmp_path_never_shadows_the_init_marker_file(monkeypatch, tmp_pat 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" From 62454a30cecc13f31c989955d68453d775e8befb Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 26 Oct 2026 22:51:56 +0800 Subject: [PATCH 3/5] fix: always use a dedicated system temporary directory --- astrbot/core/utils/astrbot_path.py | 16 +++++----------- docs/en/use/astrbot-agent-sandbox.md | 2 +- docs/zh/use/astrbot-agent-sandbox.md | 2 +- tests/test_astrbot_path.py | 17 +++++++++++------ 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index 1186a02a00c..4242775c18a 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -10,7 +10,7 @@ Data subdirectories: - Most runtime data lives under ``/data``. - A few tool-runtime files intentionally live under the system temporary - directory as ``.astrbot``. + directory as ``.astrbot-tmp``. """ import os @@ -91,20 +91,14 @@ def get_astrbot_workspaces_path() -> str: def get_astrbot_system_tmp_path() -> str: """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. + Use a distinct name from the ``.astrbot`` initialization marker so the + directory cannot collide with it when Python falls back to the current + working directory for temporary files. 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)) + return str((Path(tempfile.gettempdir()) / ".astrbot-tmp").resolve()) def get_astrbot_site_packages_path() -> str: diff --git a/docs/en/use/astrbot-agent-sandbox.md b/docs/en/use/astrbot-agent-sandbox.md index 6b6196a899e..16fd8b442bf 100644 --- a/docs/en/use/astrbot-agent-sandbox.md +++ b/docs/en/use/astrbot-agent-sandbox.md @@ -41,7 +41,7 @@ Enabling **Allow network during execution** permits network access during Shell/ - **Workspace**: Filesystem tools are limited to the current user session's workspace (identified by UMO) and the following directories: - Workspace directory, under `data/workspaces//`: **read/write**. ChatUI projects can also use a shared project workspace or a custom workspace. - AstrBot temporary directory, `data/temp/`: **read/write**. - - Tool temporary directory, `/.astrbot/`: **read/write**. + - Tool temporary directory, `/.astrbot-tmp/`: **read/write**. - Installed Agent skills, `data/skills/`: **read-only for members; read/write for administrators**. - Plugin-provided skills, `data/plugins//skills/`: **read-only**. - Built-in plugin skills, `/astrbot/builtin_stars//skills/`: **read-only**. diff --git a/docs/zh/use/astrbot-agent-sandbox.md b/docs/zh/use/astrbot-agent-sandbox.md index 8d45b1d1d16..714b2914a09 100644 --- a/docs/zh/use/astrbot-agent-sandbox.md +++ b/docs/zh/use/astrbot-agent-sandbox.md @@ -41,7 +41,7 @@ AstrBot 内置 AI 支持让 Agent 通过执行代码、读写文件等方式访 - **工作区**:Agent 只能访问对应用户会话(按 UMO 来划分)的以下目录: - 工作区目录(在 `data/workspaces//` 下)【**读写**】 - AstrBot 的临时目录(`data/temp/`)【**读写**】 - - 系统临时目录(`<系统临时目录>/.astrbot/`)【**读写**】 + - 系统临时目录(`<系统临时目录>/.astrbot-tmp/`)【**读写**】 - AstrBot 提供给 Agent 的的技能(Skills)目录 (`data/skills/`) 【普通成员**只读**,管理员**读写**】 - AstrBot 插件提供的技能(Skills)目录 (`data/plugins/<插件名>/skills/`) 【**只读**】 - AstrBot 内置插件提供的技能(Skills)目录 (`/builtin_stars/<插件名>/skills/`) 【**只读**】 diff --git a/tests/test_astrbot_path.py b/tests/test_astrbot_path.py index 58c5f1498bc..0187fa51424 100644 --- a/tests/test_astrbot_path.py +++ b/tests/test_astrbot_path.py @@ -2,6 +2,8 @@ import tempfile from pathlib import Path +import pytest + from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path @@ -11,17 +13,20 @@ def test_system_tmp_path_lives_in_the_system_temp_dir(monkeypatch, tmp_path): monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) assert get_astrbot_system_tmp_path() == os.path.realpath( - str(system_temp / ".astrbot") + str(system_temp / ".astrbot-tmp") ) -def test_system_tmp_path_keeps_an_existing_directory(monkeypatch, tmp_path): +@pytest.mark.parametrize("directory_name", [".astrbot", ".astrbot-tmp"]) +def test_system_tmp_path_uses_dedicated_directory( + monkeypatch, tmp_path, directory_name +): system_temp = tmp_path / "system_temp" - (system_temp / ".astrbot").mkdir(parents=True) + (system_temp / directory_name).mkdir(parents=True) monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) assert get_astrbot_system_tmp_path() == os.path.realpath( - str(system_temp / ".astrbot") + str(system_temp / ".astrbot-tmp") ) @@ -45,8 +50,8 @@ def test_system_tmp_path_never_shadows_the_init_marker_file(monkeypatch, tmp_pat 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. +def test_system_temp_dir_is_ignored_by_git_and_docker(): + """The system 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. From 713f85ed46848b1a56076ff10e5358b0f83594b5 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 26 Oct 2026 23:13:38 +0800 Subject: [PATCH 4/5] revert: restore conditional system temporary directory fallback Reverts commit 62454a30cecc13f31c989955d68453d775e8befb. --- astrbot/core/utils/astrbot_path.py | 16 +++++++++++----- docs/en/use/astrbot-agent-sandbox.md | 2 +- docs/zh/use/astrbot-agent-sandbox.md | 2 +- tests/test_astrbot_path.py | 17 ++++++----------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index 4242775c18a..1186a02a00c 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -10,7 +10,7 @@ Data subdirectories: - Most runtime data lives under ``/data``. - A few tool-runtime files intentionally live under the system temporary - directory as ``.astrbot-tmp``. + directory as ``.astrbot``. """ import os @@ -91,14 +91,20 @@ def get_astrbot_workspaces_path() -> str: def get_astrbot_system_tmp_path() -> str: """Return the shared system temporary directory used by local tools. - Use a distinct name from the ``.astrbot`` initialization marker so the - directory cannot collide with it when Python falls back to the current - working directory for temporary files. + 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. """ - return str((Path(tempfile.gettempdir()) / ".astrbot-tmp").resolve()) + 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/docs/en/use/astrbot-agent-sandbox.md b/docs/en/use/astrbot-agent-sandbox.md index 16fd8b442bf..6b6196a899e 100644 --- a/docs/en/use/astrbot-agent-sandbox.md +++ b/docs/en/use/astrbot-agent-sandbox.md @@ -41,7 +41,7 @@ Enabling **Allow network during execution** permits network access during Shell/ - **Workspace**: Filesystem tools are limited to the current user session's workspace (identified by UMO) and the following directories: - Workspace directory, under `data/workspaces//`: **read/write**. ChatUI projects can also use a shared project workspace or a custom workspace. - AstrBot temporary directory, `data/temp/`: **read/write**. - - Tool temporary directory, `/.astrbot-tmp/`: **read/write**. + - Tool temporary directory, `/.astrbot/`: **read/write**. - Installed Agent skills, `data/skills/`: **read-only for members; read/write for administrators**. - Plugin-provided skills, `data/plugins//skills/`: **read-only**. - Built-in plugin skills, `/astrbot/builtin_stars//skills/`: **read-only**. diff --git a/docs/zh/use/astrbot-agent-sandbox.md b/docs/zh/use/astrbot-agent-sandbox.md index 714b2914a09..8d45b1d1d16 100644 --- a/docs/zh/use/astrbot-agent-sandbox.md +++ b/docs/zh/use/astrbot-agent-sandbox.md @@ -41,7 +41,7 @@ AstrBot 内置 AI 支持让 Agent 通过执行代码、读写文件等方式访 - **工作区**:Agent 只能访问对应用户会话(按 UMO 来划分)的以下目录: - 工作区目录(在 `data/workspaces//` 下)【**读写**】 - AstrBot 的临时目录(`data/temp/`)【**读写**】 - - 系统临时目录(`<系统临时目录>/.astrbot-tmp/`)【**读写**】 + - 系统临时目录(`<系统临时目录>/.astrbot/`)【**读写**】 - AstrBot 提供给 Agent 的的技能(Skills)目录 (`data/skills/`) 【普通成员**只读**,管理员**读写**】 - AstrBot 插件提供的技能(Skills)目录 (`data/plugins/<插件名>/skills/`) 【**只读**】 - AstrBot 内置插件提供的技能(Skills)目录 (`/builtin_stars/<插件名>/skills/`) 【**只读**】 diff --git a/tests/test_astrbot_path.py b/tests/test_astrbot_path.py index 0187fa51424..58c5f1498bc 100644 --- a/tests/test_astrbot_path.py +++ b/tests/test_astrbot_path.py @@ -2,8 +2,6 @@ import tempfile from pathlib import Path -import pytest - from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path @@ -13,20 +11,17 @@ def test_system_tmp_path_lives_in_the_system_temp_dir(monkeypatch, tmp_path): monkeypatch.setattr(tempfile, "tempdir", str(system_temp)) assert get_astrbot_system_tmp_path() == os.path.realpath( - str(system_temp / ".astrbot-tmp") + str(system_temp / ".astrbot") ) -@pytest.mark.parametrize("directory_name", [".astrbot", ".astrbot-tmp"]) -def test_system_tmp_path_uses_dedicated_directory( - monkeypatch, tmp_path, directory_name -): +def test_system_tmp_path_keeps_an_existing_directory(monkeypatch, tmp_path): system_temp = tmp_path / "system_temp" - (system_temp / directory_name).mkdir(parents=True) + (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-tmp") + str(system_temp / ".astrbot") ) @@ -50,8 +45,8 @@ def test_system_tmp_path_never_shadows_the_init_marker_file(monkeypatch, tmp_pat assert marker.is_file() -def test_system_temp_dir_is_ignored_by_git_and_docker(): - """The system temp dir must not leak into git history or images. +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. From 054c1342352e992ec6da6aa5fef4b164f1799679 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 26 Oct 2026 23:16:04 +0800 Subject: [PATCH 5/5] docs: note plugin temp directory collision in NAS environments --- changelogs/unreleased.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelogs/unreleased.md b/changelogs/unreleased.md index 2e6d55d8881..4ff5cb5385d 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.