mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
feat(core): add stable resource path aliases
This commit is contained in:
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.resource_links import ResourceView
|
||||
from nanobot.runtime_context import RuntimeContextBlock
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -346,6 +347,65 @@ class TestBuildSystemPrompt:
|
||||
assert "## AGENTS.md" not in result
|
||||
assert "[Archived Context Summary]" not in result
|
||||
|
||||
def test_resource_aliases_are_absent_without_explicit_mode(self, tmp_path):
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
|
||||
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt()
|
||||
|
||||
assert "## Resource Aliases" not in result
|
||||
|
||||
def test_full_resource_aliases_show_roots_and_policy(self, tmp_path):
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
|
||||
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt(
|
||||
resource_view_mode="full",
|
||||
)
|
||||
|
||||
assert "## Resource Aliases" in result
|
||||
assert f"Agent workspace: `{resource_view.agent}`" in result
|
||||
assert f"Media: `{resource_view.media}`" in result
|
||||
assert f"Nanobot package: `{resource_view.package}`" in result
|
||||
assert f"Long-term memory: {resource_view.agent}/memory/MEMORY.md" in result
|
||||
assert f"History log: {resource_view.agent}/memory/history.jsonl" in result
|
||||
assert f"Custom skills: {resource_view.agent}/skills/" in result
|
||||
assert "do not grant additional file or shell permissions" in result
|
||||
assert "sandboxed shell may not expose an alias" in result
|
||||
assert "paths relative to the current project workspace" in result
|
||||
|
||||
def test_restricted_resource_aliases_only_show_allowed_subtrees(self, tmp_path):
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
|
||||
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt(
|
||||
resource_view_mode="restricted",
|
||||
)
|
||||
|
||||
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in result
|
||||
assert f"Media: `{resource_view.media}`" in result
|
||||
assert f"Built-in skills: `{resource_view.package / 'skills'}`" in result
|
||||
assert f"Agent workspace: `{resource_view.agent}`" not in result
|
||||
assert f"Nanobot package: `{resource_view.package}`" not in result
|
||||
canonical_workspace = tmp_path.resolve()
|
||||
assert f"History log: {canonical_workspace}/memory/history.jsonl" in result
|
||||
assert f"History log: {resource_view.agent}/memory/history.jsonl" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages
|
||||
@@ -369,6 +429,25 @@ class TestBuildMessages:
|
||||
assert messages[1]["role"] == "user"
|
||||
assert "hello" in str(messages[1]["content"])
|
||||
|
||||
def test_resource_view_mode_is_forwarded_to_system_prompt(self, tmp_path):
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
builder = _builder(tmp_path, resource_view=resource_view)
|
||||
|
||||
messages = builder.build_messages(
|
||||
[],
|
||||
"hello",
|
||||
resource_view_mode="restricted",
|
||||
)
|
||||
|
||||
assert "## Resource Aliases" in messages[0]["content"]
|
||||
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in messages[0]["content"]
|
||||
|
||||
def test_public_builder_preserves_assistant_role_compatibility(self, tmp_path):
|
||||
from nanobot.agent import ContextBuilder as PublicContextBuilder
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.resource_links import ResourceView
|
||||
from nanobot.security.workspace_access import (
|
||||
bind_workspace_scope,
|
||||
default_workspace_scope,
|
||||
@@ -62,6 +63,27 @@ class TestBuildDreamPrompt:
|
||||
prompt, _ = result
|
||||
assert "skill-creator" in prompt
|
||||
|
||||
def test_prompt_uses_package_alias_for_skill_creator(self, tmp_path):
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
store = MemoryStore(tmp_path / "workspace", resource_view=resource_view)
|
||||
store.append_history("test")
|
||||
|
||||
result = store.build_dream_prompt()
|
||||
|
||||
assert result is not None
|
||||
prompt, _ = result
|
||||
expected = resource_view.package / "skills" / "skill-creator" / "SKILL.md"
|
||||
assert str(expected) in prompt
|
||||
|
||||
def test_default_dream_prompt_class_call_remains_compatible(self):
|
||||
assert "skill-creator" in MemoryStore.default_dream_prompt()
|
||||
|
||||
def test_prompt_embeds_current_memory_file_contents(self, store):
|
||||
"""Dream must see the real current file contents (Tier 4) so it edits the
|
||||
files, not a stale mental model."""
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""AgentLoop integration tests for the runtime resource view."""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop, TurnKind
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.resource_links import ResourceView
|
||||
from nanobot.security.workspace_access import build_workspace_scope
|
||||
|
||||
|
||||
def _provider() -> MagicMock:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = SimpleNamespace(
|
||||
max_tokens=4096,
|
||||
temperature=0.1,
|
||||
reasoning_effort=None,
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _loop(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
resource_view: ResourceView | None,
|
||||
tools_config: ToolsConfig | None = None,
|
||||
) -> tuple[AgentLoop, MagicMock, MagicMock]:
|
||||
with (
|
||||
patch("nanobot.agent.loop.ContextBuilder") as context_builder,
|
||||
patch("nanobot.agent.loop.SessionManager"),
|
||||
patch("nanobot.agent.loop.SubagentManager") as subagent_manager,
|
||||
patch.object(AgentLoop, "_register_default_tools"),
|
||||
):
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=_provider(),
|
||||
workspace=tmp_path,
|
||||
tools_config=tools_config,
|
||||
resource_view=resource_view,
|
||||
)
|
||||
return loop, context_builder, subagent_manager
|
||||
|
||||
|
||||
def test_loop_injects_resource_view_without_creating_one(tmp_path: Path) -> None:
|
||||
view = ResourceView(root=tmp_path / "resources" / "view")
|
||||
|
||||
loop, context_builder, subagent_manager = _loop(
|
||||
tmp_path,
|
||||
resource_view=view,
|
||||
)
|
||||
|
||||
assert loop.resource_view is view
|
||||
assert context_builder.call_args.kwargs["resource_view"] is view
|
||||
assert subagent_manager.call_args.kwargs["resource_view"] is view
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("access_mode", "sandbox", "expected"),
|
||||
[
|
||||
("full", "", "full"),
|
||||
("restricted", "", "restricted"),
|
||||
("full", "bwrap", "restricted"),
|
||||
],
|
||||
)
|
||||
def test_initial_prompt_uses_effective_resource_view_mode(
|
||||
tmp_path: Path,
|
||||
access_mode: str,
|
||||
sandbox: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
tools_config = ToolsConfig()
|
||||
tools_config.exec.sandbox = sandbox
|
||||
view = ResourceView(root=tmp_path / "resources" / "view")
|
||||
loop, _, _ = _loop(
|
||||
tmp_path,
|
||||
resource_view=view,
|
||||
tools_config=tools_config,
|
||||
)
|
||||
scope = build_workspace_scope(tmp_path, access_mode)
|
||||
loop.workspace_scopes = SimpleNamespace(for_message=MagicMock(return_value=scope))
|
||||
loop.context.build_messages.return_value = []
|
||||
turn = SimpleNamespace(
|
||||
session=SimpleNamespace(key="cli:test", metadata={}),
|
||||
msg=SimpleNamespace(content="hello", media=None),
|
||||
history=[],
|
||||
kind=TurnKind.USER,
|
||||
delivery=SimpleNamespace(route=SimpleNamespace(channel="cli")),
|
||||
pending_summary=None,
|
||||
runtime_context_blocks=[],
|
||||
ephemeral=False,
|
||||
)
|
||||
|
||||
loop._build_initial_messages(turn)
|
||||
|
||||
assert loop.context.build_messages.call_args.kwargs["resource_view_mode"] == expected
|
||||
|
||||
|
||||
def test_initial_prompt_keeps_legacy_mode_without_resource_view(tmp_path: Path) -> None:
|
||||
loop, _, _ = _loop(tmp_path, resource_view=None)
|
||||
scope = build_workspace_scope(tmp_path, "full")
|
||||
|
||||
assert loop._resource_view_mode_for_scope(scope) is None
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.resource_links import ResourceView
|
||||
|
||||
|
||||
def _write_skill(
|
||||
@@ -315,6 +316,41 @@ def test_build_skills_summary_groups_paths_by_root(tmp_path: Path) -> None:
|
||||
assert "`beta/SKILL.md`" in summary
|
||||
|
||||
|
||||
def test_build_skills_summary_uses_alias_roots_but_keeps_canonical_entries(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
workspace_skills = workspace / "skills"
|
||||
workspace_skills.mkdir(parents=True)
|
||||
workspace_path = _write_skill(workspace_skills, "alpha", body="# Alpha")
|
||||
builtin = tmp_path / "builtin"
|
||||
builtin_path = _write_skill(builtin, "beta", body="# Beta")
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
|
||||
loader = SkillsLoader(
|
||||
workspace,
|
||||
builtin_skills_dir=builtin,
|
||||
resource_view=resource_view,
|
||||
)
|
||||
entries = loader.list_skills(filter_unavailable=False)
|
||||
summary = loader.build_skills_summary()
|
||||
|
||||
assert {entry["path"] for entry in entries} == {
|
||||
str(workspace_path),
|
||||
str(builtin_path),
|
||||
}
|
||||
assert f"`{resource_view.agent / 'skills'}`" in summary
|
||||
assert f"`{resource_view.package / 'skills'}`" in summary
|
||||
assert str(workspace_path) not in summary
|
||||
assert str(builtin_path) not in summary
|
||||
|
||||
|
||||
def test_bundled_update_setup_description_is_valid_yaml(tmp_path: Path) -> None:
|
||||
metadata = SkillsLoader(tmp_path).get_skill_metadata("update-setup")
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||
from nanobot.resource_links import ResourceView
|
||||
from nanobot.security.workspace_access import build_workspace_scope
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
@@ -109,6 +110,51 @@ def test_subagent_prompt_explains_grouped_skill_paths(tmp_path):
|
||||
assert "project-custom" not in prompt
|
||||
|
||||
|
||||
def test_subagent_prompt_uses_restricted_resource_aliases(tmp_path):
|
||||
agent_workspace = tmp_path / "agent"
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
media=aliases / "media",
|
||||
package=aliases / "package",
|
||||
)
|
||||
manager = SubagentManager(
|
||||
workspace=agent_workspace,
|
||||
bus=MessageBus(),
|
||||
max_tool_result_chars=16_000,
|
||||
resource_view=resource_view,
|
||||
)
|
||||
|
||||
prompt = manager._build_subagent_prompt(resource_view_mode="restricted")
|
||||
|
||||
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in prompt
|
||||
assert f"Media: `{resource_view.media}`" in prompt
|
||||
assert f"Built-in skills: `{resource_view.package / 'skills'}`" in prompt
|
||||
assert f"Agent workspace: `{resource_view.agent}`" not in prompt
|
||||
assert f"Nanobot package: `{resource_view.package}`" not in prompt
|
||||
assert f"History log: {agent_workspace.resolve() / 'memory' / 'history.jsonl'}" in prompt
|
||||
|
||||
|
||||
def test_subagent_prompt_uses_agent_alias_for_full_history_path(tmp_path):
|
||||
agent_workspace = tmp_path / "agent"
|
||||
aliases = tmp_path / "resources" / "view"
|
||||
resource_view = ResourceView(
|
||||
root=aliases,
|
||||
agent=aliases / "agent",
|
||||
)
|
||||
manager = SubagentManager(
|
||||
workspace=agent_workspace,
|
||||
bus=MessageBus(),
|
||||
max_tool_result_chars=16_000,
|
||||
resource_view=resource_view,
|
||||
)
|
||||
|
||||
prompt = manager._build_subagent_prompt(resource_view_mode="full")
|
||||
|
||||
assert f"History log: {resource_view.agent / 'memory' / 'history.jsonl'}" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path):
|
||||
agent_workspace = tmp_path / "agent"
|
||||
|
||||
@@ -365,7 +365,7 @@ def test_status_help_shows_workspace_and_config_options():
|
||||
assert "-c" in stripped_output
|
||||
|
||||
|
||||
def test_status_uses_explicit_config_and_workspace(tmp_path: Path):
|
||||
def test_status_uses_explicit_config_and_workspace(tmp_path: Path, monkeypatch):
|
||||
config_path = tmp_path / "instance" / "config.json"
|
||||
config_workspace = tmp_path / "config-workspace"
|
||||
override_workspace = tmp_path / "override-workspace"
|
||||
@@ -373,6 +373,11 @@ def test_status_uses_explicit_config_and_workspace(tmp_path: Path):
|
||||
config.agents.defaults.workspace = str(config_workspace)
|
||||
config_path.parent.mkdir(parents=True)
|
||||
config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True)))
|
||||
monkeypatch.setattr(
|
||||
cli_commands,
|
||||
"_prepare_resource_view",
|
||||
lambda _config: pytest.fail("status must not prepare runtime resource links"),
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -387,6 +392,58 @@ def test_status_uses_explicit_config_and_workspace(tmp_path: Path):
|
||||
assert str(config_workspace) not in compact_output
|
||||
|
||||
|
||||
def test_prepare_resource_view_uses_active_config_and_workspace(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot import resource_links
|
||||
|
||||
config_path = (tmp_path / "instance" / "config.json").resolve()
|
||||
workspace = (tmp_path / "workspace").resolve()
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(workspace)
|
||||
expected = SimpleNamespace(warnings=())
|
||||
captured: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.get_config_path",
|
||||
lambda: config_path,
|
||||
)
|
||||
|
||||
def _fake_ensure_resource_view(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _fake_ensure_resource_view)
|
||||
|
||||
assert cli_commands._prepare_resource_view(config) is expected
|
||||
assert captured == {
|
||||
"data_dir": config_path.parent,
|
||||
"config_path": config_path,
|
||||
"agent_workspace": workspace,
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_resource_view_failure_does_not_block_runtime(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot import resource_links
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.get_config_path",
|
||||
lambda: config_path,
|
||||
)
|
||||
|
||||
def _fail(**_kwargs):
|
||||
raise OSError("read-only filesystem")
|
||||
|
||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _fail)
|
||||
|
||||
assert cli_commands._prepare_resource_view(Config()) is None
|
||||
|
||||
|
||||
def test_onboard_interactive_discard_does_not_save_or_create_workspace(mock_paths, monkeypatch):
|
||||
config_file, workspace_dir, _ = mock_paths
|
||||
|
||||
@@ -1442,10 +1499,15 @@ def mock_agent_runtime(tmp_path):
|
||||
"""Mock agent command dependencies for focused CLI tests."""
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
||||
resource_view = object()
|
||||
|
||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||
patch(
|
||||
"nanobot.cli.commands._prepare_resource_view",
|
||||
return_value=resource_view,
|
||||
) as mock_prepare_resource_view, \
|
||||
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||
patch("nanobot.bus.queue.MessageBus"), \
|
||||
@@ -1463,6 +1525,8 @@ def mock_agent_runtime(tmp_path):
|
||||
"config": config,
|
||||
"load_config": mock_load_config,
|
||||
"sync_templates": mock_sync_templates,
|
||||
"prepare_resource_view": mock_prepare_resource_view,
|
||||
"resource_view": resource_view,
|
||||
"from_config": mock_from_config,
|
||||
"agent_loop": agent_loop,
|
||||
"print_response": mock_print_response,
|
||||
@@ -1490,6 +1554,9 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
|
||||
)
|
||||
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
||||
assert passed_config.workspace_path == mock_agent_runtime["config"].workspace_path
|
||||
assert mock_agent_runtime["from_config"].call_args.kwargs["resource_view"] is (
|
||||
mock_agent_runtime["resource_view"]
|
||||
)
|
||||
mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
|
||||
mock_agent_runtime["print_response"].assert_called_once_with(
|
||||
"mock-response", render_markdown=True, metadata={},
|
||||
@@ -1520,6 +1587,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object())
|
||||
@@ -1558,6 +1626,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
|
||||
@@ -1607,6 +1676,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||
@@ -1663,6 +1733,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._prepare_resource_view", lambda _config: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir)
|
||||
@@ -1858,6 +1929,7 @@ def _patch_cli_command_runtime(
|
||||
session_manager=None,
|
||||
cron_service=None,
|
||||
get_cron_dir=None,
|
||||
prepare_resource_view=None,
|
||||
) -> None:
|
||||
provider_factory = make_provider or (lambda _config: _fake_provider())
|
||||
|
||||
@@ -1871,6 +1943,10 @@ def _patch_cli_command_runtime(
|
||||
"nanobot.cli.commands.sync_workspace_templates",
|
||||
sync_templates or (lambda _path: None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._prepare_resource_view",
|
||||
prepare_resource_view or (lambda _config: None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.make_provider",
|
||||
provider_factory,
|
||||
@@ -2429,6 +2505,8 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
|
||||
|
||||
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
||||
pytest.importorskip("aiohttp")
|
||||
resource_view = object()
|
||||
seen["expected_resource_view"] = resource_view
|
||||
|
||||
class _FakeApiApp:
|
||||
def __init__(self) -> None:
|
||||
@@ -2441,6 +2519,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
||||
return cls(workspace=config.workspace_path, **extra)
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["workspace"] = kwargs["workspace"]
|
||||
seen["resource_view"] = kwargs["resource_view"]
|
||||
|
||||
async def _connect_mcp(self) -> None:
|
||||
return None
|
||||
@@ -2470,6 +2549,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
||||
config,
|
||||
message_bus=lambda: object(),
|
||||
session_manager=lambda _workspace: object(),
|
||||
prepare_resource_view=lambda _config: resource_view,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||
@@ -2887,6 +2967,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
config.gateway.heartbeat.enabled = False
|
||||
bus = MagicMock()
|
||||
seen: dict[str, object] = {}
|
||||
resource_view = object()
|
||||
|
||||
_patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
@@ -2894,6 +2975,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
message_bus=lambda: bus,
|
||||
session_manager=lambda _workspace: _FakeSessionManager(),
|
||||
cron_service=lambda _store_path: _FakeCronService(),
|
||||
prepare_resource_view=lambda _config: resource_view,
|
||||
)
|
||||
|
||||
class _FakeMemory:
|
||||
@@ -2997,6 +3079,7 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
||||
agent_kwargs = seen["agent_from_config_kwargs"]
|
||||
kwargs = seen["local_trigger_queue_kwargs"]
|
||||
assert isinstance(agent_kwargs["provider"], UnconfiguredProvider) is bool(setup_error)
|
||||
assert agent_kwargs["resource_view"] is resource_view
|
||||
assert "local_trigger_store" in agent_kwargs
|
||||
assert kwargs["store"] is agent_kwargs["local_trigger_store"]
|
||||
assert "bus" not in kwargs
|
||||
@@ -3608,6 +3691,7 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert seen["workspace"] == override_workspace
|
||||
assert seen["resource_view"] is seen["expected_resource_view"]
|
||||
assert seen["host"] == "127.0.0.2"
|
||||
assert seen["port"] == 18900
|
||||
assert seen["request_timeout"] == 45.0
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.resource_links import ensure_resource_view
|
||||
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resource_targets(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
||||
data_dir = tmp_path / "data"
|
||||
workspace = tmp_path / "agent"
|
||||
package = tmp_path / "package" / "nanobot"
|
||||
project = tmp_path / "project"
|
||||
(workspace / "skills" / "custom").mkdir(parents=True)
|
||||
(workspace / "memory").mkdir()
|
||||
(package / "skills" / "builtin").mkdir(parents=True)
|
||||
(package / "templates").mkdir()
|
||||
project.mkdir()
|
||||
(workspace / "skills" / "custom" / "SKILL.md").write_text("custom", encoding="utf-8")
|
||||
(workspace / "memory" / "history.jsonl").write_text("{}\n", encoding="utf-8")
|
||||
(package / "skills" / "builtin" / "SKILL.md").write_text("builtin", encoding="utf-8")
|
||||
(package / "templates" / "identity.md").write_text("identity", encoding="utf-8")
|
||||
return data_dir, workspace, package, project
|
||||
|
||||
|
||||
def _view_for(targets: tuple[Path, Path, Path, Path]):
|
||||
data_dir, workspace, package, _ = targets
|
||||
view = ensure_resource_view(
|
||||
data_dir=data_dir,
|
||||
config_path=data_dir / "config.json",
|
||||
agent_workspace=workspace,
|
||||
package_root=package,
|
||||
)
|
||||
if view.agent is None or view.media is None or view.package is None:
|
||||
pytest.skip(f"directory links unavailable: {view.warnings}")
|
||||
return view
|
||||
|
||||
|
||||
def test_restricted_access_follows_resource_alias_targets(
|
||||
resource_targets: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, workspace, package, project = resource_targets
|
||||
view = _view_for(resource_targets)
|
||||
|
||||
custom_skill = resolve_allowed_path(
|
||||
view.agent / "skills" / "custom" / "SKILL.md",
|
||||
workspace=project,
|
||||
allowed_root=project,
|
||||
extra_allowed_roots=[workspace / "skills", package / "skills"],
|
||||
strict=True,
|
||||
)
|
||||
builtin_skill = resolve_allowed_path(
|
||||
view.package / "skills" / "builtin" / "SKILL.md",
|
||||
workspace=project,
|
||||
allowed_root=project,
|
||||
extra_allowed_roots=[workspace / "skills", package / "skills"],
|
||||
strict=True,
|
||||
)
|
||||
media_root = resolve_allowed_path(
|
||||
view.media,
|
||||
workspace=project,
|
||||
allowed_root=project,
|
||||
extra_allowed_roots=[resource_targets[0] / "media"],
|
||||
strict=True,
|
||||
)
|
||||
|
||||
assert custom_skill == (workspace / "skills" / "custom" / "SKILL.md").resolve()
|
||||
assert builtin_skill == (package / "skills" / "builtin" / "SKILL.md").resolve()
|
||||
assert media_root == (resource_targets[0] / "media").resolve()
|
||||
|
||||
|
||||
def test_alias_does_not_expand_restricted_package_or_agent_access(
|
||||
resource_targets: tuple[Path, Path, Path, Path],
|
||||
) -> None:
|
||||
_, workspace, _, project = resource_targets
|
||||
view = _view_for(resource_targets)
|
||||
|
||||
with pytest.raises(WorkspaceBoundaryError):
|
||||
resolve_allowed_path(
|
||||
view.package / "templates" / "identity.md",
|
||||
workspace=project,
|
||||
allowed_root=project,
|
||||
extra_allowed_roots=[workspace / "skills"],
|
||||
strict=True,
|
||||
)
|
||||
|
||||
history = workspace / "memory" / "history.jsonl"
|
||||
with pytest.raises(WorkspaceBoundaryError):
|
||||
resolve_allowed_path(
|
||||
view.agent / "memory" / "history.jsonl",
|
||||
workspace=project,
|
||||
allowed_root=project,
|
||||
extra_allowed_files=[history],
|
||||
strict=True,
|
||||
)
|
||||
|
||||
assert resolve_allowed_path(
|
||||
history,
|
||||
workspace=project,
|
||||
allowed_root=project,
|
||||
extra_allowed_files=[history],
|
||||
strict=True,
|
||||
) == history.resolve()
|
||||
@@ -10,6 +10,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.nanobot import (
|
||||
STREAM_EVENT_REASONING_COMPLETED,
|
||||
STREAM_EVENT_REASONING_DELTA,
|
||||
@@ -30,6 +31,7 @@ from nanobot.nanobot import (
|
||||
StreamEvent,
|
||||
StreamEventType,
|
||||
)
|
||||
from nanobot.nanobot import _prepare_resource_view as prepare_resource_view
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
RuntimeContextBlock,
|
||||
@@ -39,6 +41,15 @@ from nanobot.session.manager import FILE_MAX_MESSAGES
|
||||
from nanobot.utils.llm_runtime import runtime_from_provider_snapshot
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_sdk_resource_view_creation(monkeypatch) -> None:
|
||||
"""Keep facade tests from creating runtime links unless a test opts in."""
|
||||
monkeypatch.setattr(
|
||||
"nanobot.nanobot._prepare_resource_view",
|
||||
lambda _config, _config_path: None,
|
||||
)
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
|
||||
data = {
|
||||
"providers": {"openrouter": {"apiKey": "sk-test-key"}},
|
||||
@@ -138,6 +149,72 @@ def test_from_config_default_path():
|
||||
mock_load.assert_called_once_with(None)
|
||||
|
||||
|
||||
def test_from_config_scopes_resource_view_to_custom_config_without_global_mutation(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
|
||||
instance_dir = tmp_path / "instance"
|
||||
instance_dir.mkdir()
|
||||
config_path = _write_config(instance_dir)
|
||||
workspace = tmp_path / "workspace"
|
||||
unrelated_config = tmp_path / "other" / "config.json"
|
||||
monkeypatch.setattr(loader, "_current_config_path", unrelated_config)
|
||||
resource_view = object()
|
||||
|
||||
with patch(
|
||||
"nanobot.nanobot._prepare_resource_view",
|
||||
return_value=resource_view,
|
||||
) as mock_prepare, patch("nanobot.nanobot.AgentLoop.from_config") as mock_loop:
|
||||
bot = Nanobot.from_config(config_path, workspace=workspace)
|
||||
|
||||
prepared_config, prepared_path = mock_prepare.call_args.args
|
||||
assert prepared_path == config_path.resolve()
|
||||
assert prepared_config.workspace_path == workspace.resolve()
|
||||
assert mock_loop.call_args.kwargs["resource_view"] is resource_view
|
||||
assert loader.get_config_path() == unrelated_config
|
||||
assert bot._loop is mock_loop.return_value
|
||||
|
||||
|
||||
def test_sdk_resource_view_failure_is_non_fatal(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot import resource_links
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
||||
|
||||
def _fail(**_kwargs):
|
||||
raise PermissionError("read-only")
|
||||
|
||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _fail)
|
||||
|
||||
assert prepare_resource_view(config, tmp_path / "config.json") is None
|
||||
|
||||
|
||||
def test_sdk_resource_view_prepares_fresh_workspace_before_linking(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from nanobot import resource_links
|
||||
|
||||
config = Config()
|
||||
workspace = tmp_path / "fresh-workspace"
|
||||
config.agents.defaults.workspace = str(workspace)
|
||||
expected = SimpleNamespace(warnings=())
|
||||
|
||||
def _capture(**kwargs):
|
||||
assert workspace.is_dir()
|
||||
assert kwargs["agent_workspace"] == workspace
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(resource_links, "ensure_resource_view", _capture)
|
||||
|
||||
assert prepare_resource_view(config, tmp_path / "config.json") is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_returns_result(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from filelock import Timeout
|
||||
|
||||
from nanobot import resource_links
|
||||
from nanobot.resource_links import ResourceView, ensure_resource_view
|
||||
|
||||
|
||||
def _targets(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
||||
data_dir = tmp_path / "state"
|
||||
config_path = data_dir / "config.json"
|
||||
agent_workspace = tmp_path / "agent"
|
||||
package_root = tmp_path / "package"
|
||||
agent_workspace.mkdir()
|
||||
package_root.mkdir()
|
||||
return data_dir, config_path, agent_workspace, package_root
|
||||
|
||||
|
||||
def _ensure(
|
||||
data_dir: Path,
|
||||
config_path: Path,
|
||||
agent_workspace: Path,
|
||||
package_root: Path,
|
||||
) -> ResourceView:
|
||||
return ensure_resource_view(
|
||||
data_dir=data_dir,
|
||||
config_path=config_path,
|
||||
agent_workspace=agent_workspace,
|
||||
package_root=package_root,
|
||||
)
|
||||
|
||||
|
||||
def _remove_directory_link(path: Path) -> None:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
os.rmdir(path)
|
||||
|
||||
|
||||
def test_ensure_resource_view_is_stable_and_idempotent(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
|
||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert first == second
|
||||
assert first.warnings == ()
|
||||
assert first.root is not None
|
||||
assert len(first.root.name) == 16
|
||||
assert first.agent is not None
|
||||
assert first.agent.resolve(strict=True) == agent_workspace.resolve(strict=True)
|
||||
assert first.media is not None
|
||||
assert first.media.resolve(strict=True) == (data_dir / "media").resolve(strict=True)
|
||||
assert first.package is not None
|
||||
assert first.package.resolve(strict=True) == package_root.resolve(strict=True)
|
||||
|
||||
|
||||
def test_resource_view_id_isolated_by_config_workspace_and_package(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
other_workspace = tmp_path / "other-agent"
|
||||
other_package = tmp_path / "other-package"
|
||||
other_workspace.mkdir()
|
||||
other_package.mkdir()
|
||||
|
||||
baseline = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
config_variant = _ensure(
|
||||
data_dir,
|
||||
data_dir / "other-config.json",
|
||||
agent_workspace,
|
||||
package_root,
|
||||
)
|
||||
workspace_variant = _ensure(data_dir, config_path, other_workspace, package_root)
|
||||
package_variant = _ensure(data_dir, config_path, agent_workspace, other_package)
|
||||
|
||||
roots = {
|
||||
baseline.root,
|
||||
config_variant.root,
|
||||
workspace_variant.root,
|
||||
package_variant.root,
|
||||
}
|
||||
assert None not in roots
|
||||
assert len(roots) == 4
|
||||
|
||||
|
||||
def test_partial_link_failure_only_degrades_that_alias(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
real_create = resource_links._create_directory_link
|
||||
|
||||
def fail_media(alias: Path, target: Path) -> None:
|
||||
if alias.name == "media":
|
||||
raise PermissionError("media denied")
|
||||
real_create(alias, target)
|
||||
|
||||
monkeypatch.setattr(resource_links, "_create_directory_link", fail_media)
|
||||
|
||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert view.root is not None
|
||||
assert view.agent is not None
|
||||
assert view.media is None
|
||||
assert view.package is not None
|
||||
assert any("media denied" in warning for warning in view.warnings)
|
||||
|
||||
|
||||
def test_existing_alias_collision_is_never_replaced(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
assert first.agent is not None
|
||||
_remove_directory_link(first.agent)
|
||||
first.agent.write_text("user-owned", encoding="utf-8")
|
||||
|
||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert second.root == first.root
|
||||
assert second.agent is None
|
||||
assert second.media is not None
|
||||
assert second.package is not None
|
||||
assert first.agent.read_text(encoding="utf-8") == "user-owned"
|
||||
assert any("alias collision for agent" in warning for warning in second.warnings)
|
||||
|
||||
|
||||
def test_wrong_link_is_never_repointed(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
wrong_target = tmp_path / "wrong-agent"
|
||||
wrong_target.mkdir()
|
||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
assert first.agent is not None
|
||||
_remove_directory_link(first.agent)
|
||||
resource_links._create_directory_link(first.agent, wrong_target)
|
||||
|
||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert second.agent is None
|
||||
assert first.agent.resolve(strict=True) == wrong_target.resolve(strict=True)
|
||||
assert any("alias collision for agent" in warning for warning in second.warnings)
|
||||
|
||||
|
||||
def test_unmanaged_namespace_collision_is_not_modified(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
namespace = data_dir / "resources"
|
||||
namespace.mkdir(parents=True)
|
||||
user_file = namespace / "notes.txt"
|
||||
user_file.write_text("keep me", encoding="utf-8")
|
||||
|
||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert view.root is None
|
||||
assert view.agent is None
|
||||
assert user_file.read_text(encoding="utf-8") == "keep me"
|
||||
assert list(namespace.iterdir()) == [user_file]
|
||||
assert any("ownership marker missing" in warning for warning in view.warnings)
|
||||
|
||||
|
||||
def test_mismatched_view_marker_is_not_repaired(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
assert first.root is not None
|
||||
marker = first.root / ".nanobot-resource-view.json"
|
||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
||||
payload["targets"]["agent"] = str(tmp_path / "someone-else")
|
||||
marker.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert second.root is None
|
||||
assert second.agent is None
|
||||
assert any("marker does not match" in warning for warning in second.warnings)
|
||||
|
||||
|
||||
def test_invalid_marker_encoding_degrades_without_raising(tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
first = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
assert first.root is not None
|
||||
marker = first.root / ".nanobot-resource-view.json"
|
||||
marker.write_bytes(b"\xff")
|
||||
|
||||
second = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert second.root is None
|
||||
assert any("Could not read resource view marker" in warning for warning in second.warnings)
|
||||
|
||||
|
||||
def test_failed_marker_write_removes_only_new_empty_view_directory(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
real_write_marker = resource_links._write_marker
|
||||
|
||||
def fail_view_marker(marker_path: Path, payload: dict) -> None:
|
||||
if marker_path.name == resource_links._VIEW_MARKER:
|
||||
raise PermissionError("view marker denied")
|
||||
real_write_marker(marker_path, payload)
|
||||
|
||||
monkeypatch.setattr(resource_links, "_write_marker", fail_view_marker)
|
||||
|
||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
namespace = data_dir / "resources"
|
||||
assert view.root is None
|
||||
assert namespace.is_dir()
|
||||
assert [entry.name for entry in namespace.iterdir()] == [
|
||||
resource_links._NAMESPACE_MARKER
|
||||
]
|
||||
assert any("view marker denied" in warning for warning in view.warnings)
|
||||
|
||||
|
||||
def test_view_inside_agent_target_is_fully_disabled_to_avoid_recursive_walk(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent_workspace = tmp_path / "agent"
|
||||
data_dir = agent_workspace / ".nanobot"
|
||||
config_path = data_dir / "config.json"
|
||||
package_root = tmp_path / "package"
|
||||
agent_workspace.mkdir()
|
||||
package_root.mkdir()
|
||||
|
||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert view.root is None
|
||||
assert view.agent is None
|
||||
assert view.media is None
|
||||
assert view.package is None
|
||||
assert not (data_dir / "resources").exists()
|
||||
assert any("recursive traversal unsafe" in warning for warning in view.warnings)
|
||||
|
||||
|
||||
def test_unverified_new_link_is_removed_without_touching_target(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
real_points_to = resource_links._link_points_to
|
||||
|
||||
def fail_agent_verification(alias: Path, target: Path) -> bool:
|
||||
if alias.name == "agent":
|
||||
return False
|
||||
return real_points_to(alias, target)
|
||||
|
||||
monkeypatch.setattr(resource_links, "_link_points_to", fail_agent_verification)
|
||||
|
||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert view.root is not None
|
||||
assert view.agent is None
|
||||
assert not os.path.lexists(view.root / "agent")
|
||||
assert agent_workspace.is_dir()
|
||||
assert view.media is not None
|
||||
assert view.package is not None
|
||||
assert any("could not be verified" in warning for warning in view.warnings)
|
||||
|
||||
|
||||
def test_lock_timeout_is_nonfatal_and_finite(monkeypatch, tmp_path: Path) -> None:
|
||||
data_dir, config_path, agent_workspace, package_root = _targets(tmp_path)
|
||||
observed_timeouts: list[float] = []
|
||||
|
||||
def fail_lock(lock_path: str, *, timeout: float):
|
||||
observed_timeouts.append(timeout)
|
||||
raise Timeout(lock_path)
|
||||
|
||||
monkeypatch.setattr(resource_links, "FileLock", fail_lock)
|
||||
|
||||
view = _ensure(data_dir, config_path, agent_workspace, package_root)
|
||||
|
||||
assert observed_timeouts == [resource_links._LOCK_TIMEOUT_SECONDS]
|
||||
assert view == ResourceView(
|
||||
warnings=(
|
||||
f"Timed out waiting for resource view lock: "
|
||||
f"{data_dir.resolve() / '.nanobot-resource-links.lock'}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_windows_symlink_failure_falls_back_to_junction(monkeypatch, tmp_path: Path) -> None:
|
||||
alias = tmp_path / "alias"
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
junction_calls: list[tuple[Path, Path]] = []
|
||||
|
||||
def fail_symlink(self: Path, target: Path, *, target_is_directory: bool = False) -> None:
|
||||
assert target_is_directory is True
|
||||
raise PermissionError("symlinks unavailable")
|
||||
|
||||
def record_junction(link: Path, junction_target: Path) -> None:
|
||||
junction_calls.append((link, junction_target))
|
||||
|
||||
monkeypatch.setattr(Path, "symlink_to", fail_symlink)
|
||||
monkeypatch.setattr(resource_links, "_is_windows", lambda: True)
|
||||
monkeypatch.setattr(resource_links, "_create_windows_junction", record_junction)
|
||||
|
||||
resource_links._create_directory_link(alias, target)
|
||||
|
||||
assert junction_calls == [(alias, target)]
|
||||
|
||||
|
||||
def test_windows_junction_command_timeout_is_bounded(monkeypatch, tmp_path: Path) -> None:
|
||||
observed_timeouts: list[float] = []
|
||||
|
||||
def time_out(command: str, **kwargs):
|
||||
observed_timeouts.append(kwargs["timeout"])
|
||||
raise subprocess.TimeoutExpired(command, kwargs["timeout"])
|
||||
|
||||
monkeypatch.setattr(resource_links.subprocess, "run", time_out)
|
||||
|
||||
with pytest.raises(OSError, match="Timed out creating Windows junction"):
|
||||
resource_links._create_windows_junction(tmp_path / "alias", tmp_path / "target")
|
||||
|
||||
assert observed_timeouts == [resource_links._JUNCTION_TIMEOUT_SECONDS]
|
||||
|
||||
|
||||
def test_default_package_root_points_to_installed_nanobot_package(tmp_path: Path) -> None:
|
||||
data_dir = tmp_path / "state"
|
||||
agent_workspace = tmp_path / "agent"
|
||||
agent_workspace.mkdir()
|
||||
|
||||
view = ensure_resource_view(
|
||||
data_dir=data_dir,
|
||||
config_path=data_dir / "config.json",
|
||||
agent_workspace=agent_workspace,
|
||||
)
|
||||
|
||||
assert view.package is not None
|
||||
assert view.package.resolve(strict=True) == Path(resource_links.__file__).parent.resolve(strict=True)
|
||||
Reference in New Issue
Block a user