feat(core): add stable resource path aliases

This commit is contained in:
chengyongru
2026-07-28 11:52:25 +08:00
parent fa5d27696a
commit 1f51c12343
22 changed files with 1662 additions and 29 deletions
+79
View File
@@ -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
+22
View File
@@ -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."""
+107
View File
@@ -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
+36
View File
@@ -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")
+46
View File
@@ -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"
+85 -1
View File
@@ -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
+104
View File
@@ -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()
+77
View File
@@ -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)
+332
View File
@@ -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)