feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
|
||||
|
||||
def _make_loop(tmp_path):
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings(max_tokens=0)
|
||||
provider.estimate_prompt_tokens.return_value = (0, "test-counter")
|
||||
response = LLMResponse(content="done", tool_calls=[])
|
||||
provider.chat_with_retry = AsyncMock(return_value=response)
|
||||
provider.chat_stream_with_retry = AsyncMock(return_value=response)
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
return loop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
response = await loop.process_direct(
|
||||
"deliver reminder",
|
||||
session_key="cron:reminder-1",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
|
||||
events = []
|
||||
while loop.bus.outbound_size:
|
||||
events.append(await loop.bus.consume_outbound())
|
||||
|
||||
statuses = [
|
||||
event.metadata
|
||||
for event in events
|
||||
if event.metadata.get("_goal_status") is True
|
||||
]
|
||||
assert [status["goal_status"] for status in statuses] == ["running", "idle"]
|
||||
assert isinstance(statuses[0].get("started_at"), float)
|
||||
assert "started_at" not in statuses[1]
|
||||
@@ -0,0 +1,344 @@
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.security.workspace_access import (
|
||||
WORKSPACE_SCOPE_METADATA_KEY,
|
||||
WorkspaceScopeError,
|
||||
bind_workspace_scope,
|
||||
default_workspace_scope,
|
||||
reset_workspace_scope,
|
||||
validate_workspace_scope_payload,
|
||||
workspace_scope_from_metadata,
|
||||
)
|
||||
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig
|
||||
|
||||
PNG_BYTES = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
|
||||
b"\x00\x00\x00\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02"
|
||||
b"\x00\x00\x00\x0bIDATx\xdacd\xfc\xff\x1f\x00\x03\x03"
|
||||
b"\x02\x00\xef\xbf\xa7\xdb\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_scope_defaults_match_legacy_config(tmp_path: Path) -> None:
|
||||
unrestricted = default_workspace_scope(tmp_path, restrict_to_workspace=False)
|
||||
restricted = default_workspace_scope(tmp_path, restrict_to_workspace=True)
|
||||
|
||||
assert unrestricted.project_path == tmp_path.resolve()
|
||||
assert unrestricted.access_mode == "full"
|
||||
assert unrestricted.restrict_to_workspace is False
|
||||
assert restricted.access_mode == "restricted"
|
||||
assert restricted.restrict_to_workspace is True
|
||||
|
||||
|
||||
def test_workspace_scope_rejects_invalid_project_path(tmp_path: Path) -> None:
|
||||
with pytest.raises(WorkspaceScopeError, match="absolute"):
|
||||
validate_workspace_scope_payload(
|
||||
{"project_path": "relative/project", "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
|
||||
with pytest.raises(WorkspaceScopeError, match="existing directory"):
|
||||
validate_workspace_scope_payload(
|
||||
{"project_path": str(tmp_path / "missing"), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_scope_accepts_home_relative_project_path(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
home = tmp_path / "home"
|
||||
project = home / "Desktop" / "Photos"
|
||||
project.mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": "~/Desktop/Photos", "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
|
||||
assert scope.project_path == project.resolve()
|
||||
assert scope.metadata()["project_path"] == str(project.resolve())
|
||||
|
||||
|
||||
def test_workspace_scope_metadata_falls_back_for_stale_session(tmp_path: Path) -> None:
|
||||
scope = workspace_scope_from_metadata(
|
||||
{
|
||||
WORKSPACE_SCOPE_METADATA_KEY: {
|
||||
"project_path": str(tmp_path / "missing"),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
|
||||
assert scope.project_path == tmp_path.resolve()
|
||||
assert scope.access_mode == "full"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filesystem_tool_uses_current_restricted_workspace_scope(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("nope")
|
||||
inside = project / "inside.txt"
|
||||
inside.write_text("ok")
|
||||
tool = ReadFileTool(workspace=tmp_path, restrict_to_workspace=False)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
assert "ok" in await tool.execute(path="inside.txt")
|
||||
assert "outside allowed directory" in await tool.execute(path=str(outside))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_tool_uses_scope_project_as_default_cwd(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
tool = ExecTool(working_dir=str(tmp_path), restrict_to_workspace=False, timeout=5)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
result = await tool.execute(command="printf ok > scoped-marker.txt")
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "Exit code: 0" in result
|
||||
assert (project / "scoped-marker.txt").read_text() == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_full_scope_allows_explicit_cwd_outside_project(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
tool = ExecTool(working_dir=str(tmp_path), restrict_to_workspace=True, timeout=5)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "full"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
result = await tool.execute(command="printf ok > outside-marker.txt", working_dir=str(outside))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "Exit code: 0" in result
|
||||
assert (outside / "outside-marker.txt").read_text() == "ok"
|
||||
|
||||
|
||||
def test_image_reference_scope_restricted_blocks_outside_and_full_allows(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
ref = outside / "ref.png"
|
||||
ref.write_bytes(PNG_BYTES)
|
||||
tool = ImageGenerationTool(
|
||||
workspace=tmp_path,
|
||||
config=ImageGenerationToolConfig(enabled=True),
|
||||
provider_config=ProviderConfig(api_key="sk-test"),
|
||||
)
|
||||
|
||||
restricted = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
token = bind_workspace_scope(restricted)
|
||||
try:
|
||||
with pytest.raises(ImageGenerationError, match="inside the workspace"):
|
||||
tool._resolve_reference_image(str(ref))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
full = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "full"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
token = bind_workspace_scope(full)
|
||||
try:
|
||||
assert tool._resolve_reference_image(str(ref)) == str(ref.resolve())
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
|
||||
def test_message_media_scope_restricted_blocks_outside_and_full_allows(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
media = outside / "shot.png"
|
||||
media.write_bytes(PNG_BYTES)
|
||||
tool = MessageTool(workspace=tmp_path, restrict_to_workspace=True)
|
||||
|
||||
restricted = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
token = bind_workspace_scope(restricted)
|
||||
try:
|
||||
with pytest.raises(PermissionError):
|
||||
tool._resolve_media([str(media)])
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
full = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "full"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
token = bind_workspace_scope(full)
|
||||
try:
|
||||
assert tool._resolve_media([str(media)]) == [str(media)]
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_app_scope_controls_working_dir(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
data_dir = tmp_path / "data"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
registry = {
|
||||
"meta": {},
|
||||
"clis": [
|
||||
{
|
||||
"name": "demo",
|
||||
"display_name": "Demo",
|
||||
"version": "1.0",
|
||||
"description": "demo",
|
||||
"category": "test",
|
||||
"install_cmd": "pip install demo",
|
||||
"entry_point": "demo-cli",
|
||||
}
|
||||
],
|
||||
}
|
||||
data_dir.mkdir()
|
||||
(data_dir / "harness_registry_cache.json").write_text(
|
||||
json.dumps({"_cached_at": time.time(), "data": registry}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(data_dir / "public_registry_cache.json").write_text(
|
||||
json.dumps({"_cached_at": time.time(), "data": {"meta": {}, "clis": []}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
CliAppManager(workspace=project, data_dir=data_dir)._save_installed(
|
||||
{"demo": {"entry_point": "demo-cli"}}
|
||||
)
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.apps.cli.service.shutil.which",
|
||||
lambda entry: "/usr/bin/demo-cli" if entry == "demo-cli" else None,
|
||||
)
|
||||
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
seen["cwd"] = kwargs["cwd"]
|
||||
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr("nanobot.apps.cli.service.subprocess.run", fake_run)
|
||||
tool = CliAppsTool(
|
||||
workspace=tmp_path,
|
||||
restrict_to_workspace=True,
|
||||
runtime=CliAppsRuntimeConfig(run_timeout=5),
|
||||
)
|
||||
|
||||
restricted = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
token = bind_workspace_scope(restricted)
|
||||
try:
|
||||
blocked = await tool.execute(name="demo", working_dir=str(outside))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert "outside the configured workspace" in blocked
|
||||
|
||||
full = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "full"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
token = bind_workspace_scope(full)
|
||||
try:
|
||||
result = await tool.execute(name="demo", working_dir=str(outside))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert "CLI app 'demo' exited 0" in result
|
||||
assert seen["cwd"] == str(outside.resolve())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_tool_forwards_current_workspace_scope(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=tmp_path,
|
||||
default_restrict_to_workspace=False,
|
||||
)
|
||||
|
||||
class Manager:
|
||||
max_concurrent_subagents = 4
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.seen = None
|
||||
|
||||
def get_running_count(self) -> int:
|
||||
return 0
|
||||
|
||||
async def spawn(self, **kwargs):
|
||||
self.seen = kwargs
|
||||
return "spawned"
|
||||
|
||||
manager = Manager()
|
||||
tool = SpawnTool(manager) # type: ignore[arg-type]
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
result = await tool.execute(task="inspect")
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert result == "spawned"
|
||||
assert manager.seen["workspace_scope"] == scope
|
||||
@@ -30,6 +30,8 @@ from nanobot.channels.websocket import (
|
||||
)
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||
|
||||
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
|
||||
@@ -57,6 +59,14 @@ def bus() -> MagicMock:
|
||||
return b
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.workspaces.get_webui_dir",
|
||||
lambda: tmp_path / "webui",
|
||||
)
|
||||
|
||||
|
||||
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
|
||||
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
|
||||
return await asyncio.to_thread(
|
||||
@@ -64,6 +74,15 @@ async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Re
|
||||
)
|
||||
|
||||
|
||||
async def _recv_ws_event(client: Any, event: str) -> dict[str, Any]:
|
||||
"""Receive until a specific websocket event appears."""
|
||||
for _ in range(10):
|
||||
payload = json.loads(await client.recv())
|
||||
if payload.get("event") == event:
|
||||
return payload
|
||||
raise AssertionError(f"websocket event {event!r} was not received")
|
||||
|
||||
|
||||
def test_normalize_http_path_strips_trailing_slash_except_root() -> None:
|
||||
assert _normalize_http_path("/chat/") == "/chat"
|
||||
assert _normalize_http_path("/chat?x=1") == "/chat"
|
||||
@@ -81,6 +100,19 @@ def test_normalize_config_path_matches_request() -> None:
|
||||
assert _normalize_config_path("/") == "/"
|
||||
|
||||
|
||||
def test_websocket_config_accepts_absolute_unix_socket(tmp_path) -> None:
|
||||
socket_path = tmp_path / "engine.sock"
|
||||
|
||||
cfg = WebSocketConfig(unix_socket_path=str(socket_path))
|
||||
|
||||
assert cfg.unix_socket_path == str(socket_path)
|
||||
|
||||
|
||||
def test_websocket_config_rejects_relative_unix_socket() -> None:
|
||||
with pytest.raises(ValueError, match="absolute path"):
|
||||
WebSocketConfig(unix_socket_path="engine.sock")
|
||||
|
||||
|
||||
def test_parse_query_extracts_token_and_client_id() -> None:
|
||||
query = _parse_query("/?token=secret&client_id=u1")
|
||||
assert query.get("token") == ["secret"]
|
||||
@@ -204,6 +236,291 @@ async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> No
|
||||
assert "webui" not in msg.metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_scope_inherits_persisted_session_scope(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
default_workspace.mkdir()
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=default_workspace,
|
||||
restrict_to_workspace=True,
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-scope",
|
||||
"workspace_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "message", "chat_id": "chat-scope", "content": "hello", "webui": True},
|
||||
)
|
||||
|
||||
msg = bus.publish_inbound.await_args.args[0]
|
||||
assert msg.metadata["workspace_scope"] == {
|
||||
"project_path": str(project.resolve()),
|
||||
"access_mode": "full",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_scope_expands_home_project_path(
|
||||
bus: MagicMock,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
home = tmp_path / "home"
|
||||
project = home / "Desktop" / "Photos"
|
||||
default_workspace.mkdir()
|
||||
project.mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
session_manager=SessionManager(tmp_path / "sessions"),
|
||||
workspace_path=default_workspace,
|
||||
restrict_to_workspace=True,
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-scope",
|
||||
"workspace_scope": {
|
||||
"project_path": "~/Desktop/Photos",
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
},
|
||||
)
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{"type": "message", "chat_id": "chat-scope", "content": "hello", "webui": True},
|
||||
)
|
||||
|
||||
msg = bus.publish_inbound.await_args.args[0]
|
||||
assert msg.metadata["workspace_scope"] == {
|
||||
"project_path": str(project.resolve()),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_scope_rejects_missing_project_path(bus: MagicMock, tmp_path) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
default_workspace.mkdir()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
session_manager=SessionManager(tmp_path / "sessions"),
|
||||
workspace_path=default_workspace,
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-scope",
|
||||
"workspace_scope": {
|
||||
"project_path": str(tmp_path / "missing"),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
conn.send.assert_awaited()
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
assert payload["detail"] == "workspace_scope_rejected"
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
other = tmp_path / "other"
|
||||
default_workspace.mkdir()
|
||||
project.mkdir()
|
||||
other.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=default_workspace,
|
||||
restrict_to_workspace=True,
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-running",
|
||||
"workspace_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
},
|
||||
)
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-running"] = 123.0
|
||||
try:
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "chat-running",
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
"workspace_scope": {
|
||||
"project_path": str(other),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
assert payload["detail"] == "workspace_scope_rejected"
|
||||
assert payload["reason"] == "chat_running"
|
||||
assert payload["chat_id"] == "chat-running"
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_set_workspace_scope_rejects_running_chat(bus: MagicMock, tmp_path) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
other = tmp_path / "other"
|
||||
default_workspace.mkdir()
|
||||
project.mkdir()
|
||||
other.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=default_workspace,
|
||||
restrict_to_workspace=True,
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-running",
|
||||
"workspace_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "restricted",
|
||||
},
|
||||
},
|
||||
)
|
||||
conn.send.reset_mock()
|
||||
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-running"] = 123.0
|
||||
try:
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-running",
|
||||
"workspace_scope": {
|
||||
"project_path": str(other),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
assert payload["detail"] == "workspace_scope_rejected"
|
||||
assert payload["reason"] == "chat_running"
|
||||
assert payload["chat_id"] == "chat-running"
|
||||
|
||||
saved = sessions.read_session_file("websocket:chat-running")
|
||||
assert saved["metadata"]["workspace_scope"] == {
|
||||
"project_path": str(project.resolve()),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp_path) -> None:
|
||||
default_workspace = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
default_workspace.mkdir()
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=default_workspace,
|
||||
restrict_to_workspace=True,
|
||||
)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("203.0.113.8", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": "chat-remote",
|
||||
"workspace_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
payload = json.loads(conn.send.await_args.args[0])
|
||||
assert payload["event"] == "error"
|
||||
assert payload["detail"] == "workspace_scope_rejected"
|
||||
assert payload["reason"] == "workspace controls are localhost-only"
|
||||
assert payload["chat_id"] == "chat-remote"
|
||||
assert sessions.read_session_file("websocket:chat-remote") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delivers_json_message_with_media_and_reply() -> None:
|
||||
bus = MagicMock()
|
||||
@@ -1067,6 +1384,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
config.tools.web.search.api_key = "brave-secret"
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api._oauth_provider_status",
|
||||
lambda _spec: {
|
||||
"configured": False,
|
||||
"account": None,
|
||||
"expires_at": None,
|
||||
"login_supported": True,
|
||||
},
|
||||
)
|
||||
|
||||
channel = _ch(bus, port=port)
|
||||
channel._api_tokens["tok"] = time.monotonic() + 300
|
||||
@@ -1103,6 +1429,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert providers["atomic_chat"]["configured"] is False
|
||||
assert providers["atomic_chat"]["api_key_required"] is False
|
||||
assert providers["atomic_chat"]["default_api_base"] == "http://localhost:1337/v1"
|
||||
assert providers["openai_codex"]["auth_type"] == "oauth"
|
||||
assert providers["openai_codex"]["configured"] is False
|
||||
assert body["agent"]["has_api_key"] is True
|
||||
assert body["web_search"]["provider"] == "brave"
|
||||
assert body["web_search"]["api_key_hint"] == "brav••••cret"
|
||||
@@ -1121,18 +1449,29 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
}
|
||||
assert image_providers["openrouter"]["label"] == "OpenRouter"
|
||||
assert image_providers["openrouter"]["configured"] is False
|
||||
assert image_providers["openai_codex"]["configured"] is True
|
||||
assert image_providers["openai_codex"]["auth_type"] == "oauth"
|
||||
assert image_providers["openai_codex"]["configured"] is False
|
||||
assert image_providers["gemini"]["label"] == "Gemini"
|
||||
assert body["runtime"]["config_path"] == str(config_path)
|
||||
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
|
||||
assert workspace_path.endswith("/.nanobot/workspace")
|
||||
assert body["runtime"]["gateway_port"] == 18790
|
||||
assert body["advanced"]["exec_enabled"] is True
|
||||
assert body["advanced"]["webui_allow_local_service_access"] is True
|
||||
assert body["advanced"]["webui_default_access_mode"] == "default"
|
||||
assert body["advanced"]["private_service_protection_enabled"] is True
|
||||
assert body["advanced"]["mcp_server_count"] == 0
|
||||
assert body["restart_required_sections"] == []
|
||||
assert "secret-key" not in settings.text
|
||||
assert "brave-secret" not in settings.text
|
||||
|
||||
unknown_api = await _http_get(
|
||||
f"http://127.0.0.1:{port}/api/settings/model-configurations/missing",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert unknown_api.status_code == 404
|
||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||
|
||||
provider_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||
@@ -1204,6 +1543,21 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||
|
||||
updated_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/update"
|
||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert updated_preset.status_code == 200
|
||||
updated_preset_body = updated_preset.json()
|
||||
assert updated_preset_body["agent"]["model_preset"] == "fast-writing"
|
||||
assert updated_preset_body["agent"]["model"] == "openai/gpt-5.5"
|
||||
updated_presets = {
|
||||
preset["name"]: preset for preset in updated_preset_body["model_presets"]
|
||||
}
|
||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||
|
||||
duplicate_preset = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/model-configurations/create"
|
||||
@@ -1222,13 +1576,26 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert search_updated.status_code == 200
|
||||
search_body = search_updated.json()
|
||||
assert search_body["requires_restart"] is True
|
||||
assert search_body["restart_required_sections"] == ["runtime", "web"]
|
||||
assert search_body["restart_required_sections"] == ["browser", "runtime"]
|
||||
assert search_body["web_search"]["provider"] == "searxng"
|
||||
assert search_body["web_search"]["api_key_hint"] is None
|
||||
assert search_body["web_search"]["base_url"] == "https://search.example.com"
|
||||
assert search_body["web_search"]["max_results"] == 8
|
||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||
|
||||
network_safety_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert network_safety_updated.status_code == 200
|
||||
network_safety_body = network_safety_updated.json()
|
||||
assert network_safety_body["requires_restart"] is True
|
||||
assert network_safety_body["restart_required_sections"] == ["browser", "runtime"]
|
||||
assert network_safety_body["advanced"]["webui_allow_local_service_access"] is False
|
||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||
|
||||
image_updated = await _http_get(
|
||||
"http://127.0.0.1:"
|
||||
f"{port}/api/settings/image-generation/update?enabled=true"
|
||||
@@ -1240,7 +1607,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert image_updated.status_code == 200
|
||||
image_body = image_updated.json()
|
||||
assert image_body["requires_restart"] is True
|
||||
assert image_body["restart_required_sections"] == ["image", "runtime", "web"]
|
||||
assert image_body["restart_required_sections"] == ["browser", "image", "runtime"]
|
||||
assert image_body["image_generation"]["enabled"] is True
|
||||
assert image_body["image_generation"]["model"] == "openai/gpt-image-1"
|
||||
assert image_body["image_generation"]["default_aspect_ratio"] == "16:9"
|
||||
@@ -1256,9 +1623,9 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert image_provider_updated.status_code == 200
|
||||
assert image_provider_updated.json()["requires_restart"] is True
|
||||
assert image_provider_updated.json()["restart_required_sections"] == [
|
||||
"browser",
|
||||
"image",
|
||||
"runtime",
|
||||
"web",
|
||||
]
|
||||
assert "sk-or-next" not in image_provider_updated.text
|
||||
|
||||
@@ -1280,8 +1647,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||
assert saved.agents.defaults.provider == "atomic_chat"
|
||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||
assert saved.model_presets["fast-writing"].label == "Codex"
|
||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5"
|
||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||
assert saved.agents.defaults.timezone == "Asia/Shanghai"
|
||||
assert saved.agents.defaults.bot_name == "Nano"
|
||||
@@ -1296,6 +1663,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
||||
assert saved.tools.web.search.max_results == 8
|
||||
assert saved.tools.web.search.timeout == 45
|
||||
assert saved.tools.web.fetch.use_jina_reader is False
|
||||
assert saved.tools.webui_allow_local_service_access is False
|
||||
assert saved.tools.image_generation.enabled is True
|
||||
assert saved.tools.image_generation.provider == "openrouter"
|
||||
assert saved.tools.image_generation.model == "openai/gpt-image-1"
|
||||
@@ -1335,6 +1703,43 @@ async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> No
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None:
|
||||
port = 29893
|
||||
channel = WebSocketChannel(
|
||||
{
|
||||
"enabled": True,
|
||||
"allowFrom": ["*"],
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"path": "/ws",
|
||||
"tokenIssueSecret": "native-secret",
|
||||
"websocketRequiresToken": True,
|
||||
},
|
||||
bus,
|
||||
runtime_surface="native",
|
||||
runtime_capabilities_overrides={"can_pick_folder": True},
|
||||
)
|
||||
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
try:
|
||||
response = await _http_get(
|
||||
f"http://127.0.0.1:{port}/webui/bootstrap",
|
||||
headers={"X-Nanobot-Auth": "native-secret"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["runtime_surface"] == "native"
|
||||
assert body["runtime_capabilities"]["can_pick_folder"] is True
|
||||
assert body["runtime_capabilities"]["can_restart_engine"] is True
|
||||
assert body["token"].startswith("nbwt_")
|
||||
finally:
|
||||
await channel.stop()
|
||||
await server_task
|
||||
|
||||
|
||||
def test_settings_payload_normalizes_camel_case_provider(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
@@ -1365,6 +1770,44 @@ def test_settings_payload_exposes_api_type_only_for_openai(monkeypatch, tmp_path
|
||||
assert "api_type" not in providers["custom"]
|
||||
|
||||
|
||||
def test_settings_payload_reports_workspace_sandbox(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.tools.restrict_to_workspace = True
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setenv("NANOBOT_SANDBOX_ENFORCED", "macos_app_sandbox")
|
||||
|
||||
body = settings_payload()
|
||||
sandbox = body["advanced"]["workspace_sandbox"]
|
||||
|
||||
assert sandbox["restrict_to_workspace"] is True
|
||||
assert sandbox["level"] == "system"
|
||||
assert sandbox["enforced"] is True
|
||||
assert sandbox["provider"] == "macos_app_sandbox"
|
||||
assert sandbox["provider_label"] == "macOS App Sandbox"
|
||||
|
||||
|
||||
def test_settings_payload_includes_native_runtime_surface(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
body = settings_payload(
|
||||
surface="native",
|
||||
runtime_capability_overrides={"can_open_logs": True},
|
||||
restart_required_sections=["runtime"],
|
||||
)
|
||||
|
||||
assert body["surface"] == "native"
|
||||
assert body["runtime_surface"] == "native"
|
||||
assert body["runtime_capabilities"]["can_open_logs"] is True
|
||||
assert body["runtime_capabilities"]["can_restart_engine"] is True
|
||||
assert body["restart_behavior_by_section"]["runtime"] == "engineRestart"
|
||||
assert body["requires_restart"] is True
|
||||
assert body["apply_state"] == {"status": "pending", "sections": ["runtime"]}
|
||||
|
||||
|
||||
def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
@@ -1671,6 +2114,8 @@ async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None:
|
||||
OutboundMessage(channel="websocket", chat_id=new_chat, content="ok")
|
||||
)
|
||||
reply = json.loads(await client.recv())
|
||||
if reply["event"] == "session_updated":
|
||||
reply = json.loads(await client.recv())
|
||||
assert reply["event"] == "message"
|
||||
assert reply["chat_id"] == new_chat
|
||||
assert reply["text"] == "ok"
|
||||
@@ -1691,16 +2136,16 @@ async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
|
||||
await client.recv() # ready
|
||||
|
||||
await client.send(json.dumps({"type": "new_chat"}))
|
||||
chat_a = json.loads(await client.recv())["chat_id"]
|
||||
chat_a = (await _recv_ws_event(client, "attached"))["chat_id"]
|
||||
await client.send(json.dumps({"type": "new_chat"}))
|
||||
chat_b = json.loads(await client.recv())["chat_id"]
|
||||
chat_b = (await _recv_ws_event(client, "attached"))["chat_id"]
|
||||
assert chat_a != chat_b
|
||||
|
||||
# Push A → client sees A only (FIFO over the single WS).
|
||||
await channel.send(
|
||||
OutboundMessage(channel="websocket", chat_id=chat_a, content="for-A")
|
||||
)
|
||||
msg_a = json.loads(await client.recv())
|
||||
msg_a = await _recv_ws_event(client, "message")
|
||||
assert msg_a["chat_id"] == chat_a
|
||||
assert msg_a["text"] == "for-A"
|
||||
|
||||
@@ -1708,7 +2153,7 @@ async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None:
|
||||
await channel.send(
|
||||
OutboundMessage(channel="websocket", chat_id=chat_b, content="for-B")
|
||||
)
|
||||
msg_b = json.loads(await client.recv())
|
||||
msg_b = await _recv_ws_event(client, "message")
|
||||
assert msg_b["chat_id"] == chat_b
|
||||
assert msg_b["text"] == "for-B"
|
||||
finally:
|
||||
@@ -1830,6 +2275,9 @@ def test_sessions_list_includes_active_run_started_at() -> None:
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
workspace_scope = body["sessions"][0].pop("workspace_scope")
|
||||
assert workspace_scope["project_path"] == str(channel._workspace_path)
|
||||
assert workspace_scope["access_mode"] in {"restricted", "full"}
|
||||
assert body["sessions"] == [
|
||||
{
|
||||
"key": "websocket:chat-1",
|
||||
|
||||
@@ -95,6 +95,7 @@ async def test_bootstrap_returns_token_for_localhost(
|
||||
body = resp.json()
|
||||
assert body["token"].startswith("nbwt_")
|
||||
assert body["ws_path"] == "/"
|
||||
assert body["ws_url"] == "ws://127.0.0.1:29901/"
|
||||
assert body["expires_in"] > 0
|
||||
assert isinstance(body.get("model_name"), str)
|
||||
finally:
|
||||
@@ -734,6 +735,17 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
|
||||
assert body["token"].startswith("nbwt_")
|
||||
|
||||
|
||||
def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, host="127.0.0.1", port=29931)
|
||||
resp = channel._handle_bootstrap(
|
||||
_LOCAL,
|
||||
_FakeReq({"Host": "nanobot.example", "X-Forwarded-Proto": "https"}),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body)
|
||||
assert body["ws_url"] == "wss://nanobot.example/"
|
||||
|
||||
|
||||
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, host="127.0.0.1")
|
||||
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
|
||||
|
||||
@@ -1521,6 +1521,35 @@ def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path)
|
||||
assert "port 18792" in result.stdout
|
||||
|
||||
|
||||
def test_configure_desktop_gateway_forces_local_websocket_only() -> None:
|
||||
from nanobot.cli.commands import _configure_desktop_gateway
|
||||
|
||||
config = Config()
|
||||
config.channels.__pydantic_extra__ = {
|
||||
"telegram": {"enabled": True, "token": "x"},
|
||||
"websocket": {"enabled": False, "port": 8765},
|
||||
}
|
||||
|
||||
_configure_desktop_gateway(
|
||||
config,
|
||||
webui_port=29888,
|
||||
webui_socket="/tmp/nanobot-test.sock",
|
||||
token_issue_secret="secret",
|
||||
)
|
||||
|
||||
extras = config.channels.__pydantic_extra__ or {}
|
||||
assert config.gateway.host == "127.0.0.1"
|
||||
assert config.gateway.port == 29888
|
||||
assert config.gateway.heartbeat.enabled is False
|
||||
assert extras["telegram"]["enabled"] is False
|
||||
assert extras["websocket"]["enabled"] is True
|
||||
assert extras["websocket"]["host"] == "127.0.0.1"
|
||||
assert extras["websocket"]["port"] == 29888
|
||||
assert extras["websocket"]["unix_socket_path"] == "/tmp/nanobot-test.sock"
|
||||
assert extras["websocket"]["token_issue_secret"] == "secret"
|
||||
assert extras["websocket"]["websocket_requires_token"] is True
|
||||
|
||||
|
||||
def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -143,6 +143,8 @@ def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -
|
||||
assert apps["gimp"]["install_supported"] is True
|
||||
assert apps["gimp"]["source"] == "harness+public"
|
||||
assert apps["gimp"]["description"] == "Public duplicate entry"
|
||||
assert apps["feishu"]["description"] == "Lark CLI"
|
||||
assert apps["feishu"]["manifest"]["description"] == "Lark CLI"
|
||||
assert apps["clibrowser"]["install_supported"] is False
|
||||
assert apps["jimeng"]["install_supported"] is False
|
||||
assert apps["suno"]["install_supported"] is True
|
||||
|
||||
@@ -223,3 +223,24 @@ def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||
ok, _ = validate_url_target("http://ts.local/api")
|
||||
assert not ok
|
||||
|
||||
|
||||
def test_load_config_defaults_local_service_access_to_enabled(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
|
||||
|
||||
config = load_config(config_path)
|
||||
|
||||
assert config.tools.webui_allow_local_service_access is True
|
||||
|
||||
|
||||
def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"tools": {"allowLocalPreviewAccess": False}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_config(config_path)
|
||||
|
||||
assert config.tools.webui_allow_local_service_access is False
|
||||
|
||||
@@ -12,6 +12,7 @@ import nanobot.providers.base as provider_base
|
||||
from nanobot.providers.openai_codex_provider import (
|
||||
OpenAICodexProvider,
|
||||
_codex_error_response,
|
||||
_build_reasoning_options,
|
||||
_CodexHTTPError,
|
||||
_friendly_error,
|
||||
_request_codex,
|
||||
@@ -128,11 +129,12 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
|
||||
body,
|
||||
verify,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = on_tool_call_delta
|
||||
_ = on_thinking_delta, on_tool_call_delta
|
||||
bodies.append(body)
|
||||
return "ok", [], "stop"
|
||||
return "ok", [], "stop", None
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||
|
||||
@@ -257,7 +259,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise httpx.ReadTimeout("")
|
||||
return "ok", [], "stop"
|
||||
return "ok", [], "stop", None
|
||||
|
||||
async def fake_sleep(delay: float) -> None:
|
||||
delays.append(delay)
|
||||
@@ -397,3 +399,56 @@ def test_codex_429_classification_uses_raw_error_semantics(
|
||||
error_type, error_code = provider_base.LLMProvider._extract_error_type_code(raw)
|
||||
|
||||
assert _should_retry_status(429, error_type, error_code, raw) is expected_retry
|
||||
|
||||
|
||||
def test_codex_reasoning_options_request_summary_without_forcing_effort() -> None:
|
||||
assert _build_reasoning_options(None) == {"summary": "auto"}
|
||||
assert _build_reasoning_options("high") == {"summary": "auto", "effort": "high"}
|
||||
assert _build_reasoning_options("none") == {"effort": "none"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.openai_codex_provider.get_codex_token",
|
||||
lambda: SimpleNamespace(account_id="acct", access="token"),
|
||||
)
|
||||
|
||||
async def fake_request(
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
verify,
|
||||
on_content_delta=None,
|
||||
on_thinking_delta=None,
|
||||
on_tool_call_delta=None,
|
||||
):
|
||||
_ = url, headers, verify, on_tool_call_delta
|
||||
assert body["reasoning"] == {"summary": "auto", "effort": "medium"}
|
||||
if on_content_delta:
|
||||
await on_content_delta("answer")
|
||||
if on_thinking_delta:
|
||||
await on_thinking_delta("summary")
|
||||
return "answer", [], "stop", "summary"
|
||||
|
||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||
|
||||
provider = OpenAICodexProvider()
|
||||
content_deltas: list[str] = []
|
||||
thinking_deltas: list[str] = []
|
||||
|
||||
response = await provider.chat_stream(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
reasoning_effort="medium",
|
||||
on_content_delta=lambda delta: _append(content_deltas, delta),
|
||||
on_thinking_delta=lambda delta: _append(thinking_deltas, delta),
|
||||
)
|
||||
|
||||
assert content_deltas == ["answer"]
|
||||
assert thinking_deltas == ["summary"]
|
||||
assert response.content == "answer"
|
||||
assert response.reasoning_content == "summary"
|
||||
|
||||
|
||||
async def _append(target: list[str], value: str) -> None:
|
||||
target.append(value)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Tests for the shared openai_responses converters and parsers."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.openai_responses.converters import (
|
||||
convert_messages,
|
||||
convert_tools,
|
||||
@@ -13,6 +13,8 @@ from nanobot.providers.openai_responses.converters import (
|
||||
)
|
||||
from nanobot.providers.openai_responses.parsing import (
|
||||
consume_sdk_stream,
|
||||
consume_sse,
|
||||
consume_sse_with_reasoning,
|
||||
map_finish_reason,
|
||||
parse_response_output,
|
||||
)
|
||||
@@ -434,6 +436,166 @@ class TestParseResponseOutput:
|
||||
assert result.usage["total_tokens"] == 150
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - consume_sse
|
||||
# ======================================================================
|
||||
|
||||
|
||||
class _SseResponse:
|
||||
def __init__(self, events: list[dict]):
|
||||
self._events = events
|
||||
|
||||
async def aiter_lines(self):
|
||||
for event in self._events:
|
||||
yield f"data: {json.dumps(event)}"
|
||||
yield ""
|
||||
|
||||
|
||||
class TestConsumeSse:
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_consume_sse_returns_three_tuple(self):
|
||||
response = _SseResponse([
|
||||
{"type": "response.output_text.delta", "delta": "hi"},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
|
||||
content, tool_calls, finish_reason = await consume_sse(response)
|
||||
|
||||
assert content == "hi"
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_delta_extracted(self):
|
||||
response = _SseResponse([
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
|
||||
{"type": "response.output_text.delta", "delta": "answer"},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
deltas: list[str] = []
|
||||
|
||||
async def on_reasoning(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
content, tool_calls, finish_reason, reasoning = await consume_sse_with_reasoning(
|
||||
response,
|
||||
on_reasoning_delta=on_reasoning,
|
||||
)
|
||||
|
||||
assert content == "answer"
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
assert reasoning == "thinking briefly"
|
||||
assert deltas == ["thinking ", "briefly"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_from_completed_response(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{"type": "reasoning", "summary": [
|
||||
{"type": "summary_text", "text": "cached "},
|
||||
{"type": "summary_text", "text": "summary"},
|
||||
]},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
_, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||
|
||||
assert reasoning == "cached summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_from_done_item(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "done summary"}],
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {"status": "completed", "output": []}},
|
||||
])
|
||||
deltas: list[str] = []
|
||||
|
||||
async def on_reasoning(delta: str) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
_, _, _, reasoning = await consume_sse_with_reasoning(
|
||||
response,
|
||||
on_reasoning_delta=on_reasoning,
|
||||
)
|
||||
|
||||
assert reasoning == "done summary"
|
||||
assert deltas == ["done summary"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_part_done_extracted(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.reasoning_summary_part.done",
|
||||
"part": {"type": "summary_text", "text": "part summary"},
|
||||
},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
|
||||
_, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||
|
||||
assert reasoning == "part summary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_done_arguments_callback(self):
|
||||
response = _SseResponse([
|
||||
{
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"id": "fc1",
|
||||
"name": "write_file",
|
||||
"arguments": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response.function_call_arguments.done",
|
||||
"call_id": "c1",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"id": "fc1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
deltas: list[dict] = []
|
||||
|
||||
async def cb(delta: dict) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
await consume_sse_with_reasoning(response, on_tool_call_delta=cb)
|
||||
|
||||
assert deltas == [
|
||||
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parsing - consume_sdk_stream
|
||||
# ======================================================================
|
||||
@@ -544,6 +706,46 @@ class TestConsumeSdkStream:
|
||||
"arguments_delta": '{"path":"a.txt","content":"',
|
||||
},
|
||||
{"call_id": "c1", "name": "write_file", "arguments_delta": "hello\\n"},
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"a.txt","content":"hello\\n"}',
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_call_done_item_arguments_callback_without_delta(self):
|
||||
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
|
||||
item_added.name = "write_file"
|
||||
ev1 = MagicMock(type="response.output_item.added", item=item_added)
|
||||
item_done = MagicMock(
|
||||
type="function_call",
|
||||
call_id="c1",
|
||||
id="fc1",
|
||||
arguments='{"path":"late.txt","content":"done\\n"}',
|
||||
)
|
||||
item_done.name = "write_file"
|
||||
ev2 = MagicMock(type="response.output_item.done", item=item_done)
|
||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
||||
ev3 = MagicMock(type="response.completed", response=resp_obj)
|
||||
deltas: list[dict] = []
|
||||
|
||||
async def cb(delta: dict) -> None:
|
||||
deltas.append(delta)
|
||||
|
||||
async def stream():
|
||||
for e in [ev1, ev2, ev3]:
|
||||
yield e
|
||||
|
||||
await consume_sdk_stream(stream(), on_tool_call_delta=cb)
|
||||
|
||||
assert deltas == [
|
||||
{"call_id": "c1", "name": "write_file", "arguments_delta": ""},
|
||||
{
|
||||
"call_id": "c1",
|
||||
"name": "write_file",
|
||||
"arguments": '{"path":"late.txt","content":"done\\n"}',
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_rejects_missing_domain():
|
||||
])
|
||||
def test_blocks_private_ipv4(ip: str, label: str):
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", [ip])):
|
||||
ok, err = validate_url_target(f"http://evil.com/path")
|
||||
ok, err = validate_url_target("http://evil.com/path")
|
||||
assert not ok, f"Should block {label} ({ip})"
|
||||
assert "private" in err.lower() or "blocked" in err.lower()
|
||||
|
||||
@@ -92,6 +92,21 @@ def test_detects_wget_localhost():
|
||||
assert contains_internal_url("wget http://localhost:8080/secret")
|
||||
|
||||
|
||||
def test_loopback_exception_allows_literal_localhost_only():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("localhost", ["127.0.0.1"])):
|
||||
assert not contains_internal_url("curl http://localhost:8765/", allow_loopback=True)
|
||||
|
||||
|
||||
def test_loopback_exception_rejects_public_name_resolving_to_loopback():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["127.0.0.1"])):
|
||||
assert contains_internal_url("curl http://example.com:8765/", allow_loopback=True)
|
||||
|
||||
|
||||
def test_loopback_exception_rejects_metadata():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("169.254.169.254", ["169.254.169.254"])):
|
||||
assert contains_internal_url("curl http://169.254.169.254/latest/meta-data/", allow_loopback=True)
|
||||
|
||||
|
||||
def test_allows_normal_curl():
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])):
|
||||
assert not contains_internal_url("curl https://example.com/api/data")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.security.workspace_policy import (
|
||||
WorkspaceBoundaryError,
|
||||
is_path_within,
|
||||
resolve_allowed_path,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_allowed_path_accepts_workspace_relative_path(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
target = workspace / "src" / "main.py"
|
||||
target.parent.mkdir()
|
||||
target.write_text("print('ok')", encoding="utf-8")
|
||||
|
||||
resolved = resolve_allowed_path("src/main.py", workspace=workspace, allowed_root=workspace)
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_allowed_path_blocks_parent_traversal(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
|
||||
with pytest.raises(WorkspaceBoundaryError, match="outside allowed directory"):
|
||||
resolve_allowed_path("../secret.txt", workspace=workspace, allowed_root=workspace)
|
||||
|
||||
|
||||
def test_resolve_allowed_path_blocks_symlink_escape(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
secret = outside / "secret.txt"
|
||||
secret.write_text("secret", encoding="utf-8")
|
||||
link = workspace / "linked-secret.txt"
|
||||
try:
|
||||
link.symlink_to(secret)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink creation is unavailable: {exc}")
|
||||
|
||||
assert not is_path_within(link, workspace)
|
||||
with pytest.raises(WorkspaceBoundaryError):
|
||||
resolve_allowed_path("linked-secret.txt", workspace=workspace, allowed_root=workspace)
|
||||
|
||||
|
||||
def test_resolve_allowed_path_allows_extra_root(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
media = tmp_path / "media"
|
||||
media.mkdir()
|
||||
image = media / "image.png"
|
||||
image.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
resolved = resolve_allowed_path(
|
||||
image,
|
||||
workspace=workspace,
|
||||
allowed_root=workspace,
|
||||
extra_allowed_roots=[media],
|
||||
)
|
||||
|
||||
assert resolved == image.resolve()
|
||||
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||
|
||||
|
||||
def test_workspace_sandbox_disabled(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=False,
|
||||
workspace=tmp_path,
|
||||
environ={},
|
||||
)
|
||||
|
||||
assert status.level == "off"
|
||||
assert status.enforced is False
|
||||
assert status.provider == "none"
|
||||
assert status.as_dict()["workspace_root"] == str(tmp_path.resolve())
|
||||
|
||||
|
||||
def test_workspace_sandbox_application_guard(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={},
|
||||
)
|
||||
|
||||
assert status.level == "application"
|
||||
assert status.enforced is False
|
||||
assert status.provider == "none"
|
||||
assert "application-level" in status.summary
|
||||
|
||||
|
||||
def test_workspace_sandbox_system_provider_from_compact_env(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={"NANOBOT_SANDBOX_ENFORCED": "macos_app_sandbox"},
|
||||
)
|
||||
|
||||
assert status.level == "system"
|
||||
assert status.enforced is True
|
||||
assert status.provider == "macos_app_sandbox"
|
||||
assert status.provider_label == "macOS App Sandbox"
|
||||
|
||||
|
||||
def test_workspace_sandbox_system_provider_from_boolean_env(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={
|
||||
"NANOBOT_WORKSPACE_SANDBOX_ENFORCED": "true",
|
||||
"NANOBOT_WORKSPACE_SANDBOX_PROVIDER": "macOS App Sandbox",
|
||||
},
|
||||
)
|
||||
|
||||
assert status.level == "system"
|
||||
assert status.enforced is True
|
||||
assert status.provider == "macos_app_sandbox"
|
||||
|
||||
|
||||
def test_workspace_sandbox_false_env_does_not_enforce(tmp_path: Path) -> None:
|
||||
status = workspace_sandbox_status(
|
||||
restrict_to_workspace=True,
|
||||
workspace=tmp_path,
|
||||
environ={"NANOBOT_WORKSPACE_SANDBOX_ENFORCED": "false"},
|
||||
)
|
||||
|
||||
assert status.level == "application"
|
||||
assert status.enforced is False
|
||||
@@ -65,6 +65,7 @@ async def test_spawn_tool_keeps_task_local_context() -> None:
|
||||
session_key: str,
|
||||
origin_message_id: str | None = None,
|
||||
temperature: float | None = None,
|
||||
workspace_scope=None,
|
||||
) -> str:
|
||||
seen.append((origin_channel, origin_chat_id, session_key))
|
||||
return f"{origin_channel}:{origin_chat_id}:{task}"
|
||||
@@ -178,6 +179,7 @@ async def test_spawn_tool_basic_set_context_and_execute() -> None:
|
||||
session_key,
|
||||
origin_message_id=None,
|
||||
temperature=None,
|
||||
workspace_scope=None,
|
||||
):
|
||||
seen.append((origin_channel, origin_chat_id, session_key))
|
||||
return f"ok: {task}"
|
||||
@@ -211,6 +213,7 @@ async def test_spawn_tool_default_values_without_set_context() -> None:
|
||||
session_key,
|
||||
origin_message_id=None,
|
||||
temperature=None,
|
||||
workspace_scope=None,
|
||||
):
|
||||
seen.append((origin_channel, origin_chat_id, session_key))
|
||||
return "ok"
|
||||
|
||||
@@ -89,7 +89,7 @@ def test_apply_patch_edits_add_to_existing_file(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
def test_apply_patch_edits_delete(tmp_path):
|
||||
def test_apply_patch_rejects_delete_action(tmp_path):
|
||||
target = tmp_path / "utils.py"
|
||||
target.write_text("def unused():\n pass\ndef used():\n return 1\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
@@ -106,51 +106,8 @@ def test_apply_patch_edits_delete(tmp_path):
|
||||
)
|
||||
)
|
||||
|
||||
assert "update utils.py" in result
|
||||
assert target.read_text() == "def used():\n return 1\n"
|
||||
|
||||
|
||||
def test_apply_patch_edits_delete_entire_file(tmp_path):
|
||||
target = tmp_path / "obsolete.txt"
|
||||
target.write_text("remove me\n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
edits=[
|
||||
{
|
||||
"path": "obsolete.txt",
|
||||
"action": "delete",
|
||||
"old_text": "remove me\n",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert "delete obsolete.txt" in result
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_apply_patch_edits_delete_substring_with_surrounding_whitespace(tmp_path):
|
||||
target = tmp_path / "keep_whitespace.txt"
|
||||
target.write_text(" token \n")
|
||||
tool = ApplyPatchTool(workspace=tmp_path)
|
||||
|
||||
result = asyncio.run(
|
||||
tool.execute(
|
||||
edits=[
|
||||
{
|
||||
"path": "keep_whitespace.txt",
|
||||
"action": "delete",
|
||||
"old_text": "token",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert "update keep_whitespace.txt" in result
|
||||
assert target.exists()
|
||||
assert target.read_text() == " \n"
|
||||
assert "unknown action: delete" in result
|
||||
assert target.read_text() == "def unused():\n pass\ndef used():\n return 1\n"
|
||||
|
||||
|
||||
def test_apply_patch_edits_batch_multiple_files(tmp_path):
|
||||
@@ -319,8 +276,9 @@ def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
|
||||
},
|
||||
{
|
||||
"path": "missing.txt",
|
||||
"action": "delete",
|
||||
"action": "replace",
|
||||
"old_text": "remove me",
|
||||
"new_text": "removed",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
|
||||
|
||||
|
||||
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
||||
@@ -42,6 +43,70 @@ async def test_exec_blocks_wget_localhost():
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
def test_exec_full_workspace_scope_allows_loopback(tmp_path):
|
||||
tool = ExecTool(working_dir=str(tmp_path))
|
||||
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
|
||||
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert error is None
|
||||
|
||||
|
||||
def test_exec_core_full_workspace_scope_blocks_loopback(tmp_path):
|
||||
tool = ExecTool(working_dir=str(tmp_path))
|
||||
scope = build_workspace_scope(tmp_path, "full")
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
|
||||
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert error is not None
|
||||
assert "internal/private" in error
|
||||
|
||||
|
||||
def test_exec_full_workspace_scope_blocks_loopback_when_local_service_disabled(tmp_path):
|
||||
tool = ExecTool(working_dir=str(tmp_path), webui_allow_local_service_access=False)
|
||||
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
|
||||
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert error is not None
|
||||
assert "internal/private" in error
|
||||
|
||||
|
||||
def test_exec_restricted_workspace_scope_blocks_loopback(tmp_path):
|
||||
tool = ExecTool(working_dir=str(tmp_path))
|
||||
scope = build_workspace_scope(tmp_path, "restricted", source_channel="websocket")
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
|
||||
error = tool._guard_command("curl http://localhost:8765/", str(tmp_path))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert error is not None
|
||||
assert "internal/private" in error
|
||||
|
||||
|
||||
def test_exec_full_workspace_scope_still_blocks_metadata(tmp_path):
|
||||
tool = ExecTool(working_dir=str(tmp_path))
|
||||
scope = build_workspace_scope(tmp_path, "full", source_channel="websocket")
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
|
||||
error = tool._guard_command("curl http://169.254.169.254/latest/meta-data/", str(tmp_path))
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
assert error is not None
|
||||
assert "internal/private" in error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_allows_normal_commands():
|
||||
tool = ExecTool(timeout=5)
|
||||
|
||||
@@ -5,8 +5,6 @@ from dataclasses import fields
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
|
||||
@@ -115,6 +113,31 @@ def test_discover_skips_private_classes():
|
||||
assert not cls.__name__.startswith("_")
|
||||
|
||||
|
||||
def test_loader_registers_exec_with_real_tools_config(tmp_path):
|
||||
"""Real config objects catch bad ctx.config attribute paths that mocks hide."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
ctx = ToolContext(
|
||||
config=ToolsConfig(),
|
||||
workspace=str(tmp_path),
|
||||
bus=None,
|
||||
subagent_manager=SimpleNamespace(
|
||||
get_running_count=lambda: 0,
|
||||
max_concurrent_subagents=4,
|
||||
),
|
||||
cron_service=None,
|
||||
timezone="UTC",
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
registered = ToolLoader().load(ctx, registry)
|
||||
|
||||
assert "exec" in registered
|
||||
assert registry.has("exec")
|
||||
|
||||
|
||||
# --- Task 4: _FsTool.create() ---
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from nanobot.agent.tools import web as web_module
|
||||
from nanobot.agent.tools.web import WebFetchTool
|
||||
from nanobot.config.schema import WebFetchConfig
|
||||
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
|
||||
|
||||
_REAL_GETADDRINFO = socket.getaddrinfo
|
||||
|
||||
@@ -45,6 +46,24 @@ async def test_web_fetch_blocks_localhost():
|
||||
assert "error" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_blocks_localhost_even_in_full_workspace_scope(tmp_path):
|
||||
tool = WebFetchTool()
|
||||
scope = build_workspace_scope(tmp_path, "full")
|
||||
|
||||
def _resolve_localhost(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]
|
||||
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _resolve_localhost):
|
||||
result = await tool.execute(url="http://localhost/admin")
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_result_contains_untrusted_flag():
|
||||
"""When fetch succeeds, result JSON must include untrusted=True and the banner."""
|
||||
|
||||
@@ -86,13 +86,10 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
|
||||
(tmp_path / "src").mkdir()
|
||||
existing = tmp_path / "src" / "existing.py"
|
||||
existing.write_text("old\nkeep\n", encoding="utf-8")
|
||||
delete_me = tmp_path / "src" / "delete_me.py"
|
||||
delete_me.write_text("gone\n", encoding="utf-8")
|
||||
|
||||
edits = [
|
||||
{"path": "src/new.py", "action": "add", "new_text": "fresh"},
|
||||
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
|
||||
{"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
|
||||
]
|
||||
|
||||
trackers = prepare_file_edit_trackers(
|
||||
@@ -106,18 +103,15 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
|
||||
assert [tracker.display_path for tracker in trackers] == [
|
||||
"src/new.py",
|
||||
"src/existing.py",
|
||||
"src/delete_me.py",
|
||||
]
|
||||
|
||||
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
|
||||
existing.write_text("new\nkeep\n", encoding="utf-8")
|
||||
delete_me.unlink()
|
||||
|
||||
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
|
||||
by_path = {event["path"]: event for event in events}
|
||||
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
|
||||
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
|
||||
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
|
||||
|
||||
|
||||
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
|
||||
|
||||
@@ -27,6 +27,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
"pinned_keys": ["websocket:a", "websocket:a", "", 123],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"title_overrides": {"websocket:a": " Release notes ", "bad": ""},
|
||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||
"collapsed_groups": {"Earlier": 1},
|
||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||
@@ -41,6 +42,7 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
||||
assert state["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release notes"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||
assert state["collapsed_groups"] == {"Earlier": True}
|
||||
assert state["view"] == {
|
||||
@@ -60,6 +62,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
"pinned_keys": ["websocket:a"],
|
||||
"archived_keys": ["websocket:b"],
|
||||
"title_overrides": {"websocket:a": "Release"},
|
||||
"project_name_overrides": {"/repo": "Core"},
|
||||
"view": {"density": "compact", "show_previews": True},
|
||||
}
|
||||
)
|
||||
@@ -67,6 +70,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
||||
assert state["pinned_keys"] == ["websocket:a"]
|
||||
assert state["archived_keys"] == ["websocket:b"]
|
||||
assert state["title_overrides"] == {"websocket:a": "Release"}
|
||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||
assert state["view"]["density"] == "compact"
|
||||
assert state["view"]["show_previews"] is True
|
||||
assert webui_sidebar_state_path().is_file()
|
||||
|
||||
@@ -122,6 +122,103 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) ->
|
||||
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
|
||||
|
||||
|
||||
def test_replay_file_edit_absorbs_matching_write_tool_event() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-file",
|
||||
"text": 'write_file({"path":"foo.txt"})',
|
||||
"kind": "tool_hint",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "start",
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments": {"path": "foo.txt", "content": "hello\n"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "t-file",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "start",
|
||||
"added": 1,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-file",
|
||||
"text": "",
|
||||
"kind": "progress",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "end",
|
||||
"call_id": "call-write",
|
||||
"name": "write_file",
|
||||
"arguments": {"path": "foo.txt", "content": "hello\n"},
|
||||
"result": "ok",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["kind"] == "trace"
|
||||
assert msgs[0]["traces"] == []
|
||||
assert "toolEvents" not in msgs[0]
|
||||
assert msgs[0]["fileEdits"] == [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-write",
|
||||
"tool": "write_file",
|
||||
"path": "foo.txt",
|
||||
"phase": "start",
|
||||
"added": 1,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
||||
{"event": "stream_end", "chat_id": "t-stream"},
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-stream",
|
||||
"text": 'exec({"cmd":"ls"})',
|
||||
"kind": "tool_hint",
|
||||
},
|
||||
{
|
||||
"event": "stream_end",
|
||||
"chat_id": "t-stream",
|
||||
"text": "Done. Open index.html to play.",
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 3
|
||||
assert msgs[0]["role"] == "assistant"
|
||||
assert msgs[0]["content"] == ""
|
||||
assert msgs[0]["reasoning"] == "I will inspect first."
|
||||
assert "isStreaming" not in msgs[0]
|
||||
assert msgs[1]["kind"] == "trace"
|
||||
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
||||
assert msgs[2]["role"] == "assistant"
|
||||
assert msgs[2]["content"] == "Done. Open index.html to play."
|
||||
|
||||
|
||||
def test_replay_tool_events_dedupes_finish_after_start() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import json
|
||||
|
||||
from nanobot.security.workspace_access import default_workspace_scope
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.workspaces import (
|
||||
WebUIWorkspaceController,
|
||||
read_webui_default_access_mode,
|
||||
read_webui_workspace_state,
|
||||
webui_workspace_state_path,
|
||||
write_webui_default_access_mode,
|
||||
workspaces_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_state_defaults_when_file_missing(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
state = read_webui_workspace_state()
|
||||
|
||||
assert state["default_access_mode"] == "default"
|
||||
assert webui_workspace_state_path() == tmp_path / "webui" / "workspace-state.json"
|
||||
|
||||
|
||||
def test_workspace_state_ignores_legacy_project_history(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
path = webui_workspace_state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"recent_projects": [
|
||||
{"project_path": str(project)},
|
||||
{"project_path": str(tmp_path / "missing")},
|
||||
],
|
||||
"last_scope": {
|
||||
"project_path": str(project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
state = read_webui_workspace_state()
|
||||
|
||||
assert "recent_projects" not in state
|
||||
assert "last_scope" not in state
|
||||
assert state["default_access_mode"] == "default"
|
||||
|
||||
|
||||
def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
default.mkdir()
|
||||
|
||||
payload = workspaces_payload(
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=False,
|
||||
controls_available=True,
|
||||
)
|
||||
|
||||
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
||||
assert payload["default_scope"]["access_mode"] == "full"
|
||||
assert payload["default_access_mode"] == "default"
|
||||
assert payload["controls"]["can_change_project"] is True
|
||||
|
||||
|
||||
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
default.mkdir()
|
||||
|
||||
payload = workspaces_payload(
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=False,
|
||||
controls_available=False,
|
||||
)
|
||||
|
||||
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
||||
assert payload["controls"]["can_change_project"] is False
|
||||
assert payload["controls"]["can_use_full_access"] is False
|
||||
|
||||
|
||||
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
default.mkdir()
|
||||
|
||||
assert write_webui_default_access_mode("full") is True
|
||||
assert write_webui_default_access_mode("full") is False
|
||||
|
||||
payload = workspaces_payload(
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=True,
|
||||
controls_available=True,
|
||||
)
|
||||
|
||||
assert payload["default_access_mode"] == "full"
|
||||
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
||||
assert payload["default_scope"]["access_mode"] == "full"
|
||||
|
||||
|
||||
def test_legacy_restricted_webui_default_access_mode_maps_to_default(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
assert write_webui_default_access_mode("restricted") is False
|
||||
assert read_webui_default_access_mode() == "default"
|
||||
|
||||
|
||||
def test_webui_default_access_applies_to_unscoped_old_sessions(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
default.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
sessions.save(sessions.get_or_create("websocket:old-chat"))
|
||||
write_webui_default_access_mode("full")
|
||||
controller = WebUIWorkspaceController(
|
||||
session_manager=sessions,
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
|
||||
scope = controller.scope_for_session_key("websocket:old-chat")
|
||||
new_scope = controller.scope_for_new_chat({}, controls_available=True)
|
||||
|
||||
assert scope.project_path == default.resolve()
|
||||
assert scope.access_mode == "full"
|
||||
assert new_scope.access_mode == "full"
|
||||
|
||||
|
||||
def test_webui_default_access_does_not_override_explicit_session_scope(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
default = tmp_path / "default"
|
||||
project = tmp_path / "project"
|
||||
default.mkdir()
|
||||
project.mkdir()
|
||||
sessions = SessionManager(tmp_path / "sessions")
|
||||
controller = WebUIWorkspaceController(
|
||||
session_manager=sessions,
|
||||
default_workspace=default,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
explicit = default_workspace_scope(project, restrict_to_workspace=False)
|
||||
controller.persist_scope("explicit-chat", explicit)
|
||||
|
||||
scope = controller.scope_for_session_key("websocket:explicit-chat")
|
||||
|
||||
assert scope.project_path == project.resolve()
|
||||
assert scope.access_mode == "full"
|
||||
@@ -1,10 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.settings_api import WebUISettingsError, create_model_configuration
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
settings_payload,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
)
|
||||
from nanobot.providers.registry import find_by_name
|
||||
|
||||
|
||||
def test_create_model_configuration_writes_label_and_selects(
|
||||
@@ -65,3 +75,237 @@ def test_create_model_configuration_rejects_unconfigured_provider(
|
||||
"model": ["openai/gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_update_model_configuration_edits_named_preset_and_selects(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.providers.openai.api_key = "sk-test"
|
||||
config.model_presets["codex"] = ModelPresetConfig(
|
||||
label="Old Codex",
|
||||
provider="openai",
|
||||
model="openai/gpt-4.1",
|
||||
)
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api._oauth_provider_status",
|
||||
lambda spec: {
|
||||
"configured": spec.name == "openai_codex",
|
||||
"account": "acct-test",
|
||||
"expires_at": 123,
|
||||
"login_supported": True,
|
||||
},
|
||||
)
|
||||
|
||||
payload = update_model_configuration(
|
||||
{
|
||||
"name": ["codex"],
|
||||
"label": ["Codex"],
|
||||
"provider": ["openai_codex"],
|
||||
"model": ["openai-codex/gpt-5.5"],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "codex"
|
||||
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
|
||||
saved = load_config(config_path)
|
||||
assert saved.agents.defaults.model_preset == "codex"
|
||||
assert saved.model_presets["codex"].label == "Codex"
|
||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
||||
|
||||
|
||||
def test_update_model_configuration_rejects_default_preset(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="model configuration is required"):
|
||||
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
|
||||
|
||||
|
||||
def test_settings_payload_includes_oauth_provider_status(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
def fake_oauth_status(spec):
|
||||
if spec.name == "openai_codex":
|
||||
return {
|
||||
"configured": True,
|
||||
"account": "acct-test",
|
||||
"expires_at": 123,
|
||||
"login_supported": True,
|
||||
}
|
||||
return {
|
||||
"configured": False,
|
||||
"account": None,
|
||||
"expires_at": None,
|
||||
"login_supported": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_api._oauth_provider_status", fake_oauth_status)
|
||||
|
||||
payload = settings_payload()
|
||||
providers = {row["name"]: row for row in payload["providers"]}
|
||||
|
||||
assert providers["openai_codex"]["auth_type"] == "oauth"
|
||||
assert providers["openai_codex"]["configured"] is True
|
||||
assert providers["openai_codex"]["oauth_account"] == "acct-test"
|
||||
|
||||
|
||||
def test_settings_payload_includes_network_safety_fields(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.tools.webui_allow_local_service_access = False
|
||||
config.tools.ssrf_whitelist = ["100.64.0.0/10"]
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["advanced"]["webui_allow_local_service_access"] is False
|
||||
assert payload["advanced"]["allow_local_preview_access"] is False
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "default"
|
||||
assert payload["advanced"]["private_service_protection_enabled"] is True
|
||||
assert payload["advanced"]["ssrf_whitelist_count"] == 1
|
||||
|
||||
|
||||
def test_update_network_safety_settings_writes_local_service_flag(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = update_network_safety_settings(
|
||||
{
|
||||
"webui_allow_local_service_access": ["false"],
|
||||
"webui_default_access_mode": ["full"],
|
||||
}
|
||||
)
|
||||
|
||||
saved = load_config(config_path)
|
||||
saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved.tools.webui_allow_local_service_access is False
|
||||
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
|
||||
assert "allowLocalPreviewAccess" not in saved_raw["tools"]
|
||||
assert payload["advanced"]["webui_allow_local_service_access"] is False
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert payload["requires_restart"] is True
|
||||
|
||||
|
||||
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = update_network_safety_settings({"webui_default_access_mode": ["restricted"]})
|
||||
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "default"
|
||||
|
||||
|
||||
def test_update_network_safety_settings_default_access_is_webui_only(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
before = config_path.read_text(encoding="utf-8")
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||
|
||||
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
|
||||
|
||||
saved = load_config(config_path)
|
||||
assert config_path.read_text(encoding="utf-8") == before
|
||||
assert saved.tools.restrict_to_workspace is False
|
||||
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
||||
assert payload["requires_restart"] is False
|
||||
|
||||
|
||||
def test_openai_codex_oauth_status_uses_available_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get_token():
|
||||
return type(
|
||||
"Token",
|
||||
(),
|
||||
{
|
||||
"access": "access-token",
|
||||
"refresh": "refresh-token",
|
||||
"expires": 2_000_000_000_000,
|
||||
"account_id": "acct-codex",
|
||||
},
|
||||
)()
|
||||
|
||||
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
|
||||
|
||||
status = _oauth_provider_status(find_by_name("openai_codex"))
|
||||
|
||||
assert status["configured"] is True
|
||||
assert status["account"] == "acct-codex"
|
||||
|
||||
|
||||
def test_openai_codex_oauth_status_rejects_unavailable_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get_token():
|
||||
raise RuntimeError("refresh failed")
|
||||
|
||||
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
|
||||
|
||||
status = _oauth_provider_status(find_by_name("openai_codex"))
|
||||
|
||||
assert status["configured"] is False
|
||||
assert status["account"] is None
|
||||
|
||||
|
||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_api._oauth_provider_status",
|
||||
lambda spec: {
|
||||
"configured": spec.name == "openai_codex",
|
||||
"account": "acct-test",
|
||||
"expires_at": 123,
|
||||
"login_supported": True,
|
||||
},
|
||||
)
|
||||
|
||||
payload = create_model_configuration(
|
||||
{
|
||||
"label": ["Codex"],
|
||||
"provider": ["openai_codex"],
|
||||
"model": ["openai-codex/gpt-5.1-codex"],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["agent"]["model_preset"] == "codex"
|
||||
saved = load_config(config_path)
|
||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||
|
||||
Reference in New Issue
Block a user