Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ data/
tests/
.ruff_cache/
.astrbot
.astrbot-tmp
astrbot.lock
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ astrbot/dashboard/dist/

# AstrBot specific
.astrbot
.astrbot-tmp
astrbot.lock

# Other
Expand Down
19 changes: 17 additions & 2 deletions astrbot/core/utils/astrbot_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import os
import tempfile
from pathlib import Path

from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions changelogs/unreleased.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
63 changes: 63 additions & 0 deletions tests/test_astrbot_path.py
Original file line number Diff line number Diff line change
@@ -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"
58 changes: 55 additions & 3 deletions tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import sys
import tempfile
import zipfile
from pathlib import Path
from types import ModuleType
Expand All @@ -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

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()) == []

Expand Down
Loading