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:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
@@ -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]
+344
View File
@@ -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
+458 -10
View File
@@ -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)
+29
View File
@@ -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:
+2
View File
@@ -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
+21
View File
@@ -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
+58 -3
View File
@@ -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)
+203 -1
View File
@@ -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
+16 -1
View File
@@ -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")
+69
View File
@@ -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()
+68
View File
@@ -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
+3
View File
@@ -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"
+5 -47
View File
@@ -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",
},
]
)
+65
View File
@@ -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)
+25 -2
View File
@@ -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
+19
View File
@@ -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."""
-6
View File
@@ -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:
+4
View File
@@ -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()
+97
View 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([
{
+154
View File
@@ -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"
+246 -2
View File
@@ -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"