Skip to content
Open
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
57 changes: 56 additions & 1 deletion astrbot/core/computer/computer_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
import json
import os
import shutil
Expand Down Expand Up @@ -31,6 +32,41 @@ def _list_local_skill_dirs(skills_root: Path) -> list[Path]:
return skills


def _compute_local_skills_revision(skills_root: Path) -> str:
"""Return a stable fingerprint for the current local skills tree.

Includes all managed skill files so sandbox reuse can detect local skill
updates even when the same sandbox session stays alive.
"""
if not skills_root.is_dir():
return "missing"

digest = hashlib.sha256()
local_skill_dirs = _list_local_skill_dirs(skills_root)
if not local_skill_dirs:
digest.update(b"empty")
return digest.hexdigest()

for skill_dir in local_skill_dirs:
for path in sorted(skill_dir.rglob("*")):
relative = path.relative_to(skills_root).as_posix()
digest.update(relative.encode("utf-8"))
stat = path.stat()
digest.update(str(stat.st_mtime_ns).encode("utf-8"))
if path.is_file():
digest.update(str(stat.st_size).encode("utf-8"))
Comment on lines +52 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current method of updating the hash digest by concatenating file metadata without separators can lead to hash collisions. For example, a file with relative path foo and mtime 12345 would produce the same hash input as a file with path foo1 and mtime 2345, as both result in the byte sequence foo12345 being hashed. This could cause the system to fail to detect changes in skills. Using a separator like a null byte between each piece of metadata will prevent such ambiguities.

Suggested change
relative = path.relative_to(skills_root).as_posix()
digest.update(relative.encode("utf-8"))
stat = path.stat()
digest.update(str(stat.st_mtime_ns).encode("utf-8"))
if path.is_file():
digest.update(str(stat.st_size).encode("utf-8"))
relative = path.relative_to(skills_root).as_posix()
stat = path.stat()
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(str(stat.st_mtime_ns).encode("utf-8"))
digest.update(b"\0")
if path.is_file():
digest.update(str(stat.st_size).encode("utf-8"))
digest.update(b"\0")

return digest.hexdigest()


def _get_booter_skills_revision(booter: ComputerBooter) -> str | None:
value = getattr(booter, "_astrbot_skills_revision", None)
return value if isinstance(value, str) and value else None


def _set_booter_skills_revision(booter: ComputerBooter, revision: str) -> None:
setattr(booter, "_astrbot_skills_revision", revision)


def _discover_bay_credentials(endpoint: str) -> str:
"""Try to auto-discover Bay API key from credentials.json.

Expand Down Expand Up @@ -374,6 +410,7 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
skills_root = Path(get_astrbot_skills_path())
if not skills_root.is_dir():
return
local_skills_revision = _compute_local_skills_revision(skills_root)
local_skill_dirs = _list_local_skill_dirs(skills_root)

temp_dir = Path(get_astrbot_temp_path())
Expand Down Expand Up @@ -403,10 +440,12 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
await _apply_skills_to_sandbox(booter)
payload = await _scan_sandbox_skills(booter)
_update_sandbox_skills_cache(payload)
_set_booter_skills_revision(booter, local_skills_revision)
managed = payload.get("managed_skills", []) if isinstance(payload, dict) else []
logger.info(
"[Computer] Sandbox skill sync complete: managed=%d",
"[Computer] Sandbox skill sync complete: managed=%d, revision=%s",
len(managed),
local_skills_revision[:12],
)
finally:
if zip_path.exists():
Expand All @@ -428,6 +467,9 @@ async def get_booter(
elif runtime == "none":
raise RuntimeError("Sandbox runtime is disabled by configuration.")

skills_root = Path(get_astrbot_skills_path())
current_skills_revision = _compute_local_skills_revision(skills_root)

sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")

Expand All @@ -436,6 +478,19 @@ async def get_booter(
if not await booter.available():
# rebuild
session_booter.pop(session_id, None)
elif _get_booter_skills_revision(booter) != current_skills_revision:
logger.info(
"[Computer] Local skills changed for session=%s, refreshing sandbox before reuse",
session_id,
)
try:
await _sync_skills_to_sandbox(booter)
except Exception as e:
logger.warning(
"Failed to refresh sandbox skills before reusing session %s: %s",
session_id,
e,
)
Comment on lines +488 to +493
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching a broad Exception is reasonable here to prevent crashes, but logging just str(e) can hide important debugging information like the traceback. Using logger.warning with exc_info=True will automatically include the exception information and traceback in the log, which is much more helpful for diagnosing failures during skill synchronization.

Suggested change
except Exception as e:
logger.warning(
"Failed to refresh sandbox skills before reusing session %s: %s",
session_id,
e,
)
except Exception:
logger.warning(
"Failed to refresh sandbox skills before reusing session %s",
session_id,
exc_info=True,
)

if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
logger.info(
Expand Down
29 changes: 29 additions & 0 deletions tests/test_computer_skill_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,35 @@ def _fake_set_cache(self, skills):
]


def test_sync_skills_sets_revision_after_success(monkeypatch, tmp_path: Path):
skills_root = tmp_path / "skills"
temp_root = tmp_path / "temp"
skill_dir = skills_root / "custom-agent-skill"
skill_dir.mkdir(parents=True, exist_ok=True)
skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
temp_root.mkdir(parents=True, exist_ok=True)

monkeypatch.setattr(
"astrbot.core.computer.computer_client.get_astrbot_skills_path",
Comment on lines +77 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): 将修订版本的断言加强为检查精确的预期值,而不仅仅是检查其是否存在

当前这个测试只验证 _astrbot_skills_revision 是否为 truthy。由于 _compute_local_skills_revision 对于给定的目录树是确定性的,更稳健的做法是断言精确的修订版本值,例如:

  • 通过 computer_client._compute_local_skills_revision(Path(skills_root)) 计算期望的修订版本,并与 booter._astrbot_skills_revision 比较;或者
  • _compute_local_skills_revision 打补丁为返回一个已知的哨兵值(例如 "rev-123"),然后断言该属性与之相等。

这样测试可以验证 _sync_skills_to_sandbox 传播的是正确的修订版本,而不仅仅是一个非空值。

建议的实现:

def test_sync_skills_sets_revision_after_success(monkeypatch, tmp_path: Path):
    skills_root = tmp_path / "skills"
    temp_root = tmp_path / "temp"
    skill_dir = skills_root / "custom-agent-skill"
    skill_dir.mkdir(parents=True, exist_ok=True)
    skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
    temp_root.mkdir(parents=True, exist_ok=True)

    monkeypatch.setattr(
        "astrbot.core.computer.computer_client.get_astrbot_skills_path",
        lambda: str(skills_root),
    expected_revision = computer_client._compute_local_skills_revision(skills_root)
    assert booter._astrbot_skills_revision == expected_revision

我假设当前的断言只是一个简单的 truthy 检查,例如 assert booter._astrbot_skills_revision,并且在调用 _sync_skills_to_sandbox 之后,测试中可以在作用域中访问到 computer_clientbooter 变量。如果变量名不同(例如使用 client 而不是 computer_client,或使用 skill_booter 而不是 booter),请相应调整 expected_revision = ... 这一行来使用正确的实例。另请确保调用 _compute_local_skills_revision 时使用与其他地方一致的类型(例如使用 Path 类型的 skills_root;如果该方法期望字符串类型,则用 str(skills_root) 包装)。

Original comment in English

suggestion (testing): Strengthen the revision assertion to check the exact expected value rather than just existence

This test currently only checks that _astrbot_skills_revision is truthy. Since _compute_local_skills_revision is deterministic for a given tree, it would be more robust to assert the exact revision value, e.g. by:

  • Computing the expected revision via computer_client._compute_local_skills_revision(Path(skills_root)) and comparing it to booter._astrbot_skills_revision, or
  • Patching _compute_local_skills_revision to return a known sentinel (e.g. "rev-123") and asserting the attribute matches it.

That way the test verifies that _sync_skills_to_sandbox propagates the correct revision, not just a non-empty value.

Suggested implementation:

def test_sync_skills_sets_revision_after_success(monkeypatch, tmp_path: Path):
    skills_root = tmp_path / "skills"
    temp_root = tmp_path / "temp"
    skill_dir = skills_root / "custom-agent-skill"
    skill_dir.mkdir(parents=True, exist_ok=True)
    skill_dir.joinpath("SKILL.md").write_text("# demo", encoding="utf-8")
    temp_root.mkdir(parents=True, exist_ok=True)

    monkeypatch.setattr(
        "astrbot.core.computer.computer_client.get_astrbot_skills_path",
        lambda: str(skills_root),
    expected_revision = computer_client._compute_local_skills_revision(skills_root)
    assert booter._astrbot_skills_revision == expected_revision

I’ve assumed the existing assertion is a simple truthiness check like assert booter._astrbot_skills_revision and that the test has computer_client and booter variables available in scope after invoking _sync_skills_to_sandbox. If the variable names differ (e.g. client instead of computer_client, or skill_booter instead of booter), adjust the expected_revision = ... line accordingly to use the correct instance. Also ensure the call to _compute_local_skills_revision uses the same type used elsewhere (e.g. skills_root as a Path; if the method expects a string, wrap with str(skills_root)).

lambda: str(skills_root),
)
monkeypatch.setattr(
"astrbot.core.computer.computer_client.get_astrbot_temp_path",
lambda: str(temp_root),
)
monkeypatch.setattr(
"astrbot.core.computer.computer_client.SkillManager.set_sandbox_skills_cache",
lambda self, skills: None,
)

booter = _FakeBooter(
'{"skills":[{"name":"custom-agent-skill","description":"","path":"skills/custom-agent-skill/SKILL.md"}]}'
)
asyncio.run(computer_client._sync_skills_to_sandbox(booter))

assert getattr(booter, "_astrbot_skills_revision", None)


def test_sync_skills_uses_managed_strategy_instead_of_wiping_all(
monkeypatch,
tmp_path: Path,
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/test_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,94 @@ async def test_get_booter_reuses_existing(self):
# Cleanup
computer_client.session_booter.clear()

@pytest.mark.asyncio
async def test_get_booter_refreshes_existing_session_when_skills_change(self):
"""Test get_booter re-syncs local skills before reusing a stale sandbox."""
from astrbot.core.computer import computer_client

computer_client.session_booter.clear()

mock_context = MagicMock()
mock_config = MagicMock()
mock_config.get = lambda key, default=None: {
"provider_settings": {
"computer_use_runtime": "sandbox",
"sandbox": {
"booter": "shipyard",
"shipyard_endpoint": "http://localhost:8080",
"shipyard_access_token": "test_token",
}
}
}.get(key, default)
mock_context.get_config = MagicMock(return_value=mock_config)

mock_booter = MagicMock()
mock_booter.available = AsyncMock(return_value=True)
mock_booter._astrbot_skills_revision = "rev-old"

sync_mock = AsyncMock()
with (
patch(
"astrbot.core.computer.computer_client._compute_local_skills_revision",
return_value="rev-new",
),
patch(
"astrbot.core.computer.computer_client._sync_skills_to_sandbox",
sync_mock,
),
):
computer_client.session_booter["test-session"] = mock_booter

booter = await computer_client.get_booter(mock_context, "test-session")
assert booter is mock_booter
sync_mock.assert_awaited_once_with(mock_booter)

computer_client.session_booter.clear()

@pytest.mark.asyncio
async def test_get_booter_skips_resync_when_skills_revision_matches(self):
"""Test get_booter reuses existing booter without redundant syncs."""
from astrbot.core.computer import computer_client

computer_client.session_booter.clear()

mock_context = MagicMock()
mock_config = MagicMock()
mock_config.get = lambda key, default=None: {
"provider_settings": {
"computer_use_runtime": "sandbox",
"sandbox": {
"booter": "shipyard",
"shipyard_endpoint": "http://localhost:8080",
"shipyard_access_token": "test_token",
}
}
}.get(key, default)
mock_context.get_config = MagicMock(return_value=mock_config)

mock_booter = MagicMock()
mock_booter.available = AsyncMock(return_value=True)
mock_booter._astrbot_skills_revision = "rev-same"

sync_mock = AsyncMock()
with (
patch(
"astrbot.core.computer.computer_client._compute_local_skills_revision",
return_value="rev-same",
),
patch(
"astrbot.core.computer.computer_client._sync_skills_to_sandbox",
sync_mock,
),
):
computer_client.session_booter["test-session"] = mock_booter

booter = await computer_client.get_booter(mock_context, "test-session")
assert booter is mock_booter
sync_mock.assert_not_awaited()

computer_client.session_booter.clear()
Comment on lines +736 to +821
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The tests test_get_booter_refreshes_existing_session_when_skills_change and test_get_booter_skips_resync_when_skills_revision_matches are nearly identical, with only the revision values and the expected outcome being different. This code duplication can be eliminated by using pytest.mark.parametrize to create a single, data-driven test. This will make the test suite more concise and easier to maintain or extend in the future.

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "old_rev, new_rev, should_sync",
        [
            ("rev-old", "rev-new", True),
            ("rev-same", "rev-same", False),
        ],
        ids=[
            "refreshes_when_skills_change",
            "skips_resync_when_skills_revision_matches",
        ],
    )
    async def test_get_booter_skill_refresh_logic(self, old_rev, new_rev, should_sync):
        """Test get_booter re-syncs local skills based on revision changes."""
        from astrbot.core.computer import computer_client

        computer_client.session_booter.clear()

        mock_context = MagicMock()
        mock_config = MagicMock()
        mock_config.get = lambda key, default=None: {
            "provider_settings": {
                "computer_use_runtime": "sandbox",
                "sandbox": {
                    "booter": "shipyard",
                    "shipyard_endpoint": "http://localhost:8080",
                    "shipyard_access_token": "test_token",
                },
            }
        }.get(key, default)
        mock_context.get_config = MagicMock(return_value=mock_config)

        mock_booter = MagicMock()
        mock_booter.available = AsyncMock(return_value=True)
        mock_booter._astrbot_skills_revision = old_rev

        sync_mock = AsyncMock()
        with (
            patch(
                "astrbot.core.computer.computer_client._compute_local_skills_revision",
                return_value=new_rev,
            ),
            patch(
                "astrbot.core.computer.computer_client._sync_skills_to_sandbox",
                sync_mock,
            ),
        ):
            computer_client.session_booter["test-session"] = mock_booter

            booter = await computer_client.get_booter(mock_context, "test-session")
            assert booter is mock_booter
            if should_sync:
                sync_mock.assert_awaited_once_with(mock_booter)
            else:
                sync_mock.assert_not_awaited()

        computer_client.session_booter.clear()


@pytest.mark.asyncio
async def test_get_booter_rebuild_unavailable(self):
"""Test get_booter rebuilds when existing booter is unavailable."""
Expand Down