mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 08:09:16 +03:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
057c5e849b | ||
|
|
e226242dfc | ||
|
|
e3d1819a2b | ||
|
|
9703656b25 | ||
|
|
26c9687b80 | ||
|
|
410e5e5121 | ||
|
|
335808e525 | ||
|
|
afad96af5f | ||
|
|
fd7eb8e046 |
+2
-1
@@ -133,7 +133,8 @@ or a result you must retain.
|
||||
|
||||
Use the workspace picker before starting project-specific work. This gives the
|
||||
agent the right project context for file paths, shell commands, and session
|
||||
metadata.
|
||||
metadata. A locally hosted WebUI opens the operating system's folder chooser
|
||||
when one is available; remote deployments keep the manual absolute path entry.
|
||||
|
||||
Selecting a project does not replace the configured agent workspace. The two
|
||||
paths have different responsibilities:
|
||||
|
||||
+8
-11
@@ -769,27 +769,24 @@ class MemoryStore:
|
||||
return f"{prefix}\n\n{diff_body}"
|
||||
|
||||
@staticmethod
|
||||
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
|
||||
def prune_dream_sessions(sessions: SessionManager, *, keep: int = 10) -> None:
|
||||
"""Remove the oldest Dream session files, keeping only the N most recent.
|
||||
|
||||
Only current base64url-encoded Dream session keys are considered.
|
||||
Non-dream session files are never touched.
|
||||
"""
|
||||
dream_files: list[Path] = []
|
||||
with sessions.locked_session_files() as sessions_dir:
|
||||
dream_files: list[tuple[Path, str]] = []
|
||||
for path in sessions_dir.glob("*.jsonl"):
|
||||
decoded_key = SessionManager.decode_storage_key(path.stem)
|
||||
if decoded_key is not None and decoded_key.startswith("dream:"):
|
||||
dream_files.append(path)
|
||||
dream_files.sort(key=lambda p: p.stat().st_mtime)
|
||||
if len(dream_files) <= keep:
|
||||
return
|
||||
dream_files.append((path, decoded_key))
|
||||
dream_files.sort(key=lambda item: item[0].stat().st_mtime)
|
||||
|
||||
to_remove = dream_files[: len(dream_files) - keep]
|
||||
for path in to_remove:
|
||||
try:
|
||||
path.unlink()
|
||||
for path, key in dream_files[: max(0, len(dream_files) - keep)]:
|
||||
if sessions.delete_session(key):
|
||||
logger.debug("Pruned old dream session: {}", path.stem)
|
||||
except OSError:
|
||||
else:
|
||||
logger.warning("Failed to prune dream session {}", path)
|
||||
|
||||
|
||||
|
||||
@@ -3266,6 +3266,85 @@ async def _webui_mutate(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
selected = tmp_path / "project"
|
||||
selected.mkdir()
|
||||
pick_folder = AsyncMock(return_value=str(selected))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||
channel = _ch(bus)
|
||||
|
||||
response = await _webui_mutate(channel, "workspace.pick_folder")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"path": str(selected)}
|
||||
pick_folder.assert_awaited_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_folder_picker_rejects_direct_http(
|
||||
bus: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pick_folder = AsyncMock(return_value="/tmp")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||
channel = _ch(bus)
|
||||
|
||||
response = await channel.gateway.http.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{"Host": "127.0.0.1:8765"},
|
||||
path="/api/workspaces/pick-folder",
|
||||
),
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 405
|
||||
assert b"authenticated WebSocket" in response.body
|
||||
pick_folder.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("connection", "host"),
|
||||
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
|
||||
)
|
||||
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
|
||||
bus: MagicMock,
|
||||
monkeypatch,
|
||||
connection: _FakeConn,
|
||||
host: str,
|
||||
) -> None:
|
||||
pick_folder = AsyncMock(return_value="/tmp")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
|
||||
|
||||
response = await _webui_mutate(
|
||||
channel,
|
||||
"workspace.pick_folder",
|
||||
connection=connection,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
pick_folder.assert_not_awaited()
|
||||
|
||||
|
||||
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
||||
from nanobot.webui.http_utils import is_local_browser_request
|
||||
|
||||
|
||||
@@ -566,7 +566,7 @@ def _run_gateway(
|
||||
if sha:
|
||||
logger.info("Dream commit: {}", sha)
|
||||
store.compact_history()
|
||||
prune_dream_sessions(agent.sessions.sessions_dir)
|
||||
prune_dream_sessions(agent.sessions)
|
||||
return None
|
||||
|
||||
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
|
||||
|
||||
@@ -490,7 +490,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
|
||||
if sha:
|
||||
content += f" (commit {sha})"
|
||||
store.compact_history()
|
||||
prune_dream_sessions(loop.sessions.sessions_dir)
|
||||
prune_dream_sessions(loop.sessions)
|
||||
await loop.bus.publish_outbound(OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
))
|
||||
|
||||
+70
-11
@@ -9,12 +9,12 @@ import re
|
||||
import secrets
|
||||
import stat
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from contextlib import contextmanager, suppress
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
||||
from typing import Any, Callable, Collection, Generator, Protocol, TypedDict, cast
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from filelock import FileLock
|
||||
@@ -65,6 +65,7 @@ _WORKSPACE_STATE_DIR = ".nanobot"
|
||||
_WORKSPACE_ID_FILE = "workspace-id"
|
||||
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
|
||||
_SESSION_FILES_LOCK_FILENAME = ".session-files.lock"
|
||||
_COPY_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
@@ -472,13 +473,28 @@ class Session:
|
||||
if limit <= 0 or len(self.messages) <= limit:
|
||||
return
|
||||
|
||||
original_messages = self.messages
|
||||
original_last_consolidated = self.last_consolidated
|
||||
original_provider_state = self.provider_state
|
||||
original_updated_at = self.updated_at
|
||||
result = self.retain_recent_legal_suffix(limit)
|
||||
if not result.dropped:
|
||||
return
|
||||
|
||||
archive_chunk = result.dropped[result.already_consolidated_count:]
|
||||
if archive_chunk and on_archive:
|
||||
try:
|
||||
on_archive(archive_chunk)
|
||||
except BaseException:
|
||||
# Retention runs before the archive callback so the callback can
|
||||
# receive the exact dropped prefix. Restore the in-memory session
|
||||
# if archival fails; otherwise a later save would persist the
|
||||
# trimmed state and make that prefix impossible to retry.
|
||||
self.messages = original_messages
|
||||
self.last_consolidated = original_last_consolidated
|
||||
self.provider_state = original_provider_state
|
||||
self.updated_at = original_updated_at
|
||||
raise
|
||||
logger.info(
|
||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||
self.key,
|
||||
@@ -576,8 +592,18 @@ class JsonlSessionStore:
|
||||
)
|
||||
self.sessions_dir = ensure_dir(root / workspace_id)
|
||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||
self._session_files_lock = FileLock(
|
||||
str(self.sessions_dir / _SESSION_FILES_LOCK_FILENAME)
|
||||
)
|
||||
with self._session_files_lock:
|
||||
self._migrate_from_workspace(canonical_workspace)
|
||||
|
||||
@contextmanager
|
||||
def locked_session_files(self) -> Generator[Path, None, None]:
|
||||
"""Guard direct access to canonical session files in this directory."""
|
||||
with self._session_files_lock:
|
||||
yield self.sessions_dir
|
||||
|
||||
@staticmethod
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
with suppress(PermissionError, NotImplementedError):
|
||||
@@ -959,7 +985,7 @@ class JsonlSessionStore:
|
||||
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
|
||||
ensure_dir(old_dir)
|
||||
|
||||
with self._migration_lock:
|
||||
with self._migration_lock, self._session_files_lock:
|
||||
for src in self.sessions_dir.glob("*.jsonl"):
|
||||
if self.session_key_from_path(src) is None:
|
||||
continue
|
||||
@@ -1021,6 +1047,10 @@ class JsonlSessionStore:
|
||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
|
||||
def load(self, key: str) -> Session | None:
|
||||
with self._session_files_lock:
|
||||
return self._load_unlocked(key)
|
||||
|
||||
def _load_unlocked(self, key: str) -> Session | None:
|
||||
path = self.get_session_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -1086,7 +1116,7 @@ class JsonlSessionStore:
|
||||
)
|
||||
except _SESSION_DATA_ERRORS as e:
|
||||
logger.warning("Failed to load session {}: {}", key, e)
|
||||
repaired = self.repair(key)
|
||||
repaired = self._repair_unlocked(key)
|
||||
if repaired is not None:
|
||||
logger.info(
|
||||
"Recovered session {} from corrupt file ({} messages)",
|
||||
@@ -1096,6 +1126,10 @@ class JsonlSessionStore:
|
||||
return repaired
|
||||
|
||||
def repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||
with self._session_files_lock:
|
||||
return self._repair_unlocked(key, path=path)
|
||||
|
||||
def _repair_unlocked(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||
if path is None:
|
||||
path = self.get_session_path(key)
|
||||
if not path.exists():
|
||||
@@ -1188,11 +1222,15 @@ class JsonlSessionStore:
|
||||
}
|
||||
|
||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||
with self._session_files_lock:
|
||||
self._save_unlocked(session, fsync=fsync)
|
||||
|
||||
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
|
||||
path = self.get_session_path(session.key)
|
||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
with open(tmp_path, "x", encoding="utf-8") as f:
|
||||
metadata_line = {
|
||||
"_type": "metadata",
|
||||
"key": session.key,
|
||||
@@ -1226,11 +1264,14 @@ class JsonlSessionStore:
|
||||
raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
except BaseException:
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
with self._session_files_lock:
|
||||
return self._delete_unlocked(key)
|
||||
|
||||
def _delete_unlocked(self, key: str) -> bool:
|
||||
paths = [
|
||||
self.get_session_path(key),
|
||||
self.get_legacy_lossy_path(key),
|
||||
@@ -1248,6 +1289,10 @@ class JsonlSessionStore:
|
||||
return deleted
|
||||
|
||||
def read(self, key: str) -> SessionPayload | None:
|
||||
with self._session_files_lock:
|
||||
return self._read_unlocked(key)
|
||||
|
||||
def _read_unlocked(self, key: str) -> SessionPayload | None:
|
||||
path = self.get_session_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -1297,13 +1342,17 @@ class JsonlSessionStore:
|
||||
}
|
||||
except _SESSION_DATA_ERRORS as e:
|
||||
logger.warning("Failed to read session {}: {}", key, e)
|
||||
repaired = self.repair(key, path=path)
|
||||
repaired = self._repair_unlocked(key, path=path)
|
||||
if repaired is not None:
|
||||
logger.info("Recovered read-only session view {} from corrupt file", key)
|
||||
return self.session_payload(repaired)
|
||||
return None
|
||||
|
||||
def read_metadata(self, key: str) -> SessionMetadataPayload | None:
|
||||
with self._session_files_lock:
|
||||
return self._read_metadata_unlocked(key)
|
||||
|
||||
def _read_metadata_unlocked(self, key: str) -> SessionMetadataPayload | None:
|
||||
path = self.get_session_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -1338,7 +1387,7 @@ class JsonlSessionStore:
|
||||
return None
|
||||
except _SESSION_DATA_ERRORS as e:
|
||||
logger.warning("Failed to read session metadata {}: {}", key, e)
|
||||
repaired = self.repair(key, path=path)
|
||||
repaired = self._repair_unlocked(key, path=path)
|
||||
if repaired is not None:
|
||||
logger.info("Recovered read-only session metadata {} from corrupt file", key)
|
||||
return {
|
||||
@@ -1350,6 +1399,10 @@ class JsonlSessionStore:
|
||||
return None
|
||||
|
||||
def list_sessions(self) -> list[SessionInfo]:
|
||||
with self._session_files_lock:
|
||||
return self._list_sessions_unlocked()
|
||||
|
||||
def _list_sessions_unlocked(self) -> list[SessionInfo]:
|
||||
sessions: list[SessionInfo] = []
|
||||
|
||||
for path in self.sessions_dir.glob("*.jsonl"):
|
||||
@@ -1427,7 +1480,7 @@ class JsonlSessionStore:
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except _SESSION_DATA_ERRORS:
|
||||
repaired = self.repair(storage_key, path=path)
|
||||
repaired = self._repair_unlocked(storage_key, path=path)
|
||||
if repaired is not None:
|
||||
sessions.append(
|
||||
{
|
||||
@@ -1536,6 +1589,12 @@ class SessionManager:
|
||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||
return self._jsonl_store.get_legacy_session_path(key)
|
||||
|
||||
@contextmanager
|
||||
def locked_session_files(self) -> Generator[Path, None, None]:
|
||||
"""Guard exceptional direct access to canonical JSONL files."""
|
||||
with self._jsonl_store.locked_session_files() as sessions_dir:
|
||||
yield sessions_dir
|
||||
|
||||
def get_or_create(self, key: str) -> Session:
|
||||
"""
|
||||
Get an existing session or create a new one.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Native directory picker used by a locally hosted WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_PICKER_TIMEOUT_SECONDS = 300
|
||||
_COMMON_ENV_KEYS = (
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_MESSAGES",
|
||||
"LOGNAME",
|
||||
"PATH",
|
||||
"SHELL",
|
||||
"TMPDIR",
|
||||
"USER",
|
||||
)
|
||||
_LINUX_GUI_ENV_KEYS = (
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"DESKTOP_SESSION",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XAUTHORITY",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_RUNTIME_DIR",
|
||||
)
|
||||
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
|
||||
_WINDOWS_GUI_ENV_KEYS = (
|
||||
"APPDATA",
|
||||
"COMSPEC",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LOCALAPPDATA",
|
||||
"PATHEXT",
|
||||
"ProgramData",
|
||||
"ProgramFiles",
|
||||
"ProgramFiles(x86)",
|
||||
"ProgramW6432",
|
||||
"SESSIONNAME",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERDOMAIN",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
)
|
||||
|
||||
|
||||
class NativeFolderPickerError(RuntimeError):
|
||||
"""Raised when an available native folder picker cannot complete."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PickerCommand:
|
||||
argv: tuple[str, ...]
|
||||
cancel_codes: frozenset[int]
|
||||
cancel_markers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _picker_command() -> _PickerCommand | None:
|
||||
if sys.platform == "darwin":
|
||||
executable = shutil.which("osascript")
|
||||
if executable is None:
|
||||
return None
|
||||
return _PickerCommand(
|
||||
argv=(
|
||||
executable,
|
||||
"-e",
|
||||
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
|
||||
"-e",
|
||||
"POSIX path of selectedFolder",
|
||||
),
|
||||
cancel_codes=frozenset({1}),
|
||||
cancel_markers=("user canceled", "(-128)"),
|
||||
)
|
||||
|
||||
if sys.platform == "win32":
|
||||
executable = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
if executable is None:
|
||||
return None
|
||||
script = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
|
||||
"$dialog.Description='Select Workspace Directory';"
|
||||
"$dialog.ShowNewFolderButton=$true;"
|
||||
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
|
||||
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
|
||||
"[Console]::Out.Write($dialog.SelectedPath)}"
|
||||
)
|
||||
return _PickerCommand(
|
||||
argv=(
|
||||
executable,
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-STA",
|
||||
"-Command",
|
||||
script,
|
||||
),
|
||||
cancel_codes=frozenset(),
|
||||
)
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
||||
return None
|
||||
zenity = shutil.which("zenity")
|
||||
if zenity is not None:
|
||||
return _PickerCommand(
|
||||
argv=(
|
||||
zenity,
|
||||
"--file-selection",
|
||||
"--directory",
|
||||
"--title=Select Workspace Directory",
|
||||
),
|
||||
cancel_codes=frozenset({1}),
|
||||
)
|
||||
kdialog = shutil.which("kdialog")
|
||||
if kdialog is not None:
|
||||
return _PickerCommand(
|
||||
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
|
||||
cancel_codes=frozenset({1}),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def native_folder_picker_available() -> bool:
|
||||
"""Return whether this host can display a native directory picker."""
|
||||
return _picker_command() is not None
|
||||
|
||||
|
||||
def _picker_environment() -> dict[str, str]:
|
||||
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
|
||||
keys: list[str] = list(_COMMON_ENV_KEYS)
|
||||
if sys.platform == "darwin":
|
||||
keys.extend(_MACOS_GUI_ENV_KEYS)
|
||||
elif sys.platform == "win32":
|
||||
keys.extend(_WINDOWS_GUI_ENV_KEYS)
|
||||
elif sys.platform.startswith("linux"):
|
||||
keys.extend(_LINUX_GUI_ENV_KEYS)
|
||||
return {
|
||||
key: value
|
||||
for key in keys
|
||||
if (value := os.environ.get(key)) is not None
|
||||
}
|
||||
|
||||
|
||||
async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
||||
if process.returncode is not None:
|
||||
return
|
||||
with suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2)
|
||||
except TimeoutError:
|
||||
with suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
async def pick_native_folder() -> str | None:
|
||||
"""Open the platform directory picker and return an existing absolute path."""
|
||||
command = _picker_command()
|
||||
if command is None:
|
||||
raise NativeFolderPickerError("native folder picker is unavailable on this host")
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command.argv,
|
||||
env=_picker_environment(),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise NativeFolderPickerError("native folder picker failed to start") from exc
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=_PICKER_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await _stop_process(process)
|
||||
raise
|
||||
except TimeoutError as exc:
|
||||
await _stop_process(process)
|
||||
raise NativeFolderPickerError("native folder picker timed out") from exc
|
||||
|
||||
error_text = stderr.decode("utf-8", errors="replace").strip()
|
||||
normalized_error = error_text.lower()
|
||||
if process.returncode != 0:
|
||||
if process.returncode in command.cancel_codes and (
|
||||
not command.cancel_markers
|
||||
or any(marker in normalized_error for marker in command.cancel_markers)
|
||||
):
|
||||
return None
|
||||
raise NativeFolderPickerError("native folder picker failed")
|
||||
|
||||
selected = stdout.decode("utf-8", errors="replace").strip()
|
||||
if not selected:
|
||||
return None
|
||||
path = Path(selected).expanduser()
|
||||
if not path.is_absolute() or not path.is_dir():
|
||||
raise NativeFolderPickerError("native folder picker returned an invalid directory")
|
||||
return str(path)
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
@@ -56,6 +57,7 @@ _TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
|
||||
|
||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||
"""Return session rows for the WebUI sidebar, backed by a rebuildable cache."""
|
||||
with session_manager.locked_session_files():
|
||||
rows, changed = _reconcile_index(session_manager)
|
||||
if changed:
|
||||
try:
|
||||
@@ -169,14 +171,14 @@ def _read_index_rows(sessions_dir: Path) -> list[dict[str, Any]] | None:
|
||||
|
||||
def _write_index_rows(sessions_dir: Path, rows: list[dict[str, Any]]) -> None:
|
||||
path = _index_path(sessions_dir)
|
||||
tmp_path = path.with_suffix(".json.tmp")
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
|
||||
data = {"version": _INDEX_VERSION, "sessions": rows}
|
||||
try:
|
||||
tmp_path.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
with open(tmp_path, "x", encoding="utf-8") as file:
|
||||
file.write(json.dumps(data, ensure_ascii=False) + "\n")
|
||||
os.replace(tmp_path, path)
|
||||
except BaseException:
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _file_signature(path: Path) -> dict[str, int]:
|
||||
|
||||
@@ -149,6 +149,7 @@ def workspaces_payload(
|
||||
default_workspace: Path,
|
||||
default_restrict_to_workspace: bool,
|
||||
controls_available: bool,
|
||||
folder_picker_available: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
default_access_mode = read_webui_default_access_mode()
|
||||
default_scope = (
|
||||
@@ -167,6 +168,7 @@ def workspaces_payload(
|
||||
"controls": {
|
||||
"can_change_project": controls_available,
|
||||
"can_use_full_access": controls_available,
|
||||
"can_pick_folder": folder_picker_available,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -241,11 +243,17 @@ class WebUIWorkspaceController:
|
||||
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
||||
)
|
||||
|
||||
def payload(self, *, controls_available: bool) -> dict[str, Any]:
|
||||
def payload(
|
||||
self,
|
||||
*,
|
||||
controls_available: bool,
|
||||
folder_picker_available: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return workspaces_payload(
|
||||
default_workspace=self._default_workspace,
|
||||
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
||||
controls_available=controls_available,
|
||||
folder_picker_available=folder_picker_available,
|
||||
)
|
||||
|
||||
def scope_from_envelope(
|
||||
|
||||
@@ -62,6 +62,7 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.http_utils import (
|
||||
is_localhost as _is_localhost,
|
||||
)
|
||||
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
|
||||
from nanobot.webui.http_utils import (
|
||||
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||
)
|
||||
@@ -85,6 +86,11 @@ from nanobot.webui.http_utils import (
|
||||
)
|
||||
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.native_folder_picker import (
|
||||
NativeFolderPickerError,
|
||||
native_folder_picker_available,
|
||||
pick_native_folder,
|
||||
)
|
||||
from nanobot.webui.session_automations import (
|
||||
all_automations_payload,
|
||||
serialize_automation_jobs,
|
||||
@@ -133,6 +139,7 @@ _WEBUI_MUTATION_PATHS = {
|
||||
"skill.update": "/api/webui/skills/update",
|
||||
"skill.delete": "/api/webui/skills/delete",
|
||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
||||
"settings.agent.update": "/api/settings/update",
|
||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||
@@ -329,6 +336,7 @@ class GatewayHTTPHandler:
|
||||
)
|
||||
self.skill_state_action = skill_state_action
|
||||
self._skill_install_lock = asyncio.Lock()
|
||||
self._folder_picker_lock = asyncio.Lock()
|
||||
self.cron_service = cron_service
|
||||
self.local_trigger_store = local_trigger_store
|
||||
self.cron_pending_job_ids = cron_pending_job_ids
|
||||
@@ -360,6 +368,17 @@ class GatewayHTTPHandler:
|
||||
def workspace_controls_available(self, connection: Any) -> bool:
|
||||
return self._runtime_surface == "native" or _is_localhost(connection)
|
||||
|
||||
def workspace_folder_picker_available(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
) -> bool:
|
||||
return (
|
||||
_is_loopback_host(self.config.host)
|
||||
and _is_local_browser_request(connection, request.headers)
|
||||
and native_folder_picker_available()
|
||||
)
|
||||
|
||||
# -- Token management ---------------------------------------------------
|
||||
|
||||
def check_api_token(self, request: WsRequest) -> bool:
|
||||
@@ -435,6 +454,7 @@ class GatewayHTTPHandler:
|
||||
"/api/webui/skills/update",
|
||||
"/api/webui/skills/delete",
|
||||
"/api/webui/sidebar-state/update",
|
||||
"/api/workspaces/pick-folder",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -1054,6 +1074,8 @@ class GatewayHTTPHandler:
|
||||
return await self._handle_sessions_list(request)
|
||||
if got == "/api/commands":
|
||||
return self._handle_commands(request)
|
||||
if got == "/api/workspaces/pick-folder":
|
||||
return await self._handle_workspace_folder_picker(connection, request)
|
||||
if got == "/api/workspaces":
|
||||
return self._handle_workspaces(connection, request)
|
||||
if got == "/api/webui/skills/search":
|
||||
@@ -1089,10 +1111,32 @@ class GatewayHTTPHandler:
|
||||
return _http_error(401, "Unauthorized")
|
||||
return _http_json_response(
|
||||
self.workspaces.payload(
|
||||
controls_available=self.workspace_controls_available(connection)
|
||||
controls_available=self.workspace_controls_available(connection),
|
||||
folder_picker_available=self.workspace_folder_picker_available(
|
||||
connection,
|
||||
request,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def _handle_workspace_folder_picker(
|
||||
self,
|
||||
connection: Any,
|
||||
request: WsRequest,
|
||||
) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
if not self.workspace_folder_picker_available(connection, request):
|
||||
return _http_error(403, "native folder picker is unavailable for this connection")
|
||||
if self._folder_picker_lock.locked():
|
||||
return _http_error(409, "native folder picker is already open")
|
||||
try:
|
||||
async with self._folder_picker_lock:
|
||||
path = await pick_native_folder()
|
||||
except NativeFolderPickerError as exc:
|
||||
return _http_error(503, str(exc))
|
||||
return _http_json_response({"path": path})
|
||||
|
||||
def _handle_webui_skills(self, request: WsRequest) -> Response:
|
||||
if not self.check_api_token(request):
|
||||
return _http_error(401, "Unauthorized")
|
||||
|
||||
@@ -29,8 +29,11 @@ class TestPruneDreamSessions:
|
||||
import os
|
||||
import time
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
manager = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "runtime",
|
||||
)
|
||||
sessions_dir = manager.sessions_dir
|
||||
|
||||
base_time = time.time() - 100
|
||||
dream_paths = []
|
||||
@@ -50,7 +53,7 @@ class TestPruneDreamSessions:
|
||||
normal_path = sessions_dir / "telegram_123.jsonl"
|
||||
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
||||
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
MemoryStore.prune_dream_sessions(manager, keep=10)
|
||||
|
||||
assert [path.exists() for path in dream_paths] == [False] * 5 + [True] * 10
|
||||
assert normal_path.exists()
|
||||
@@ -59,8 +62,11 @@ class TestPruneDreamSessions:
|
||||
import os
|
||||
import time
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
manager = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "runtime",
|
||||
)
|
||||
sessions_dir = manager.sessions_dir
|
||||
base_time = time.time() - 100
|
||||
current_paths = []
|
||||
|
||||
@@ -81,24 +87,29 @@ class TestPruneDreamSessions:
|
||||
)
|
||||
os.utime(legacy_path, (base_time - 1, base_time - 1))
|
||||
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=1)
|
||||
MemoryStore.prune_dream_sessions(manager, keep=1)
|
||||
|
||||
assert [path.exists() for path in current_paths] == [False, True]
|
||||
assert legacy_path.exists()
|
||||
|
||||
def test_noop_when_under_limit(self, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
manager = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "runtime",
|
||||
)
|
||||
sessions_dir = manager.sessions_dir
|
||||
for i in range(3):
|
||||
key = f"dream:20260528-{100000 + i:06d}"
|
||||
path = sessions_dir / f"{SessionManager._storage_key(key)}.jsonl"
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
MemoryStore.prune_dream_sessions(manager, keep=10)
|
||||
assert len(list(sessions_dir.glob("*.jsonl"))) == 3
|
||||
|
||||
def test_empty_dir_noop(self, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
assert list(sessions_dir.iterdir()) == []
|
||||
manager = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "runtime",
|
||||
)
|
||||
MemoryStore.prune_dream_sessions(manager, keep=10)
|
||||
assert list(manager.sessions_dir.glob("*.jsonl")) == []
|
||||
|
||||
@@ -4,6 +4,9 @@ import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from filelock import Timeout
|
||||
|
||||
from nanobot.providers.base import ProviderConversationState
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
@@ -37,14 +40,23 @@ class TestAtomicSave:
|
||||
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
||||
assert tmp_files == []
|
||||
|
||||
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
||||
def test_unique_tmp_file_cleaned_up_on_write_failure(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
mgr = SessionManager(tmp_path)
|
||||
session = Session(key="test:fail")
|
||||
path = mgr._get_session_path("test:fail")
|
||||
tmp_path_file = path.with_suffix(".jsonl.tmp")
|
||||
stale_shared_tmp = path.with_suffix(".jsonl.tmp")
|
||||
unique_tmp = path.with_name(f".{path.name}.save-failure.tmp")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path_file.write_text("stale")
|
||||
stale_shared_tmp.write_text("stale", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.manager.secrets.token_hex",
|
||||
lambda _length: "save-failure",
|
||||
)
|
||||
|
||||
class BadMessage:
|
||||
def __init__(self, data):
|
||||
@@ -64,13 +76,17 @@ class TestAtomicSave:
|
||||
]
|
||||
|
||||
import unittest.mock
|
||||
with unittest.mock.patch("nanobot.session.manager.json.dumps", side_effect=failing_dumps):
|
||||
try:
|
||||
with (
|
||||
unittest.mock.patch(
|
||||
"nanobot.session.manager.json.dumps",
|
||||
side_effect=failing_dumps,
|
||||
),
|
||||
pytest.raises(OSError, match="simulated disk full"),
|
||||
):
|
||||
mgr.save(session)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
assert not tmp_path_file.exists()
|
||||
assert not unique_tmp.exists()
|
||||
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
|
||||
|
||||
def test_overwrite_preserves_latest_data(self, tmp_path: Path):
|
||||
mgr = SessionManager(tmp_path)
|
||||
@@ -102,6 +118,21 @@ class TestAtomicSave:
|
||||
for i in range(5):
|
||||
assert loaded.messages[i]["content"] == f"msg{i}"
|
||||
|
||||
def test_managers_for_same_directory_coordinate_saves(self, tmp_path: Path):
|
||||
workspace = tmp_path / "workspace"
|
||||
sessions_root = tmp_path / "runtime"
|
||||
owner = SessionManager(workspace, sessions_root=sessions_root)
|
||||
peer = SessionManager(workspace, sessions_root=sessions_root)
|
||||
assert owner.sessions_dir == peer.sessions_dir
|
||||
|
||||
session = Session(key="test:peer-manager")
|
||||
peer._jsonl_store._session_files_lock.timeout = 0
|
||||
with owner.locked_session_files(), pytest.raises(Timeout):
|
||||
peer.save(session)
|
||||
|
||||
peer.save(session)
|
||||
assert peer._get_session_path(session.key).is_file()
|
||||
|
||||
def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
|
||||
mgr = SessionManager(tmp_path)
|
||||
secret = "encrypted-reasoning-blob"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.providers.base import ProviderConversationState
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
@@ -981,6 +983,36 @@ def test_enforce_file_cap_correct_archive_with_last_consolidated_in_else_branch(
|
||||
)
|
||||
|
||||
|
||||
def test_enforce_file_cap_restores_session_when_archive_fails():
|
||||
state = ProviderConversationState(
|
||||
kind="openai_responses",
|
||||
provider="openai:test",
|
||||
model="test-model",
|
||||
version=1,
|
||||
payload={"items": []},
|
||||
)
|
||||
session = Session(key="test:archive-failure", provider_state=state)
|
||||
for i in range(8):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
original_messages = session.messages
|
||||
original_updated_at = session.updated_at
|
||||
session.last_consolidated = 2
|
||||
|
||||
def fail_archive(_messages):
|
||||
raise RuntimeError("history unavailable")
|
||||
|
||||
with pytest.raises(RuntimeError, match="history unavailable"):
|
||||
session.enforce_file_cap(on_archive=fail_archive, limit=4)
|
||||
|
||||
assert session.messages is original_messages
|
||||
assert [message["content"] for message in session.messages] == [
|
||||
f"msg{i}" for i in range(8)
|
||||
]
|
||||
assert session.last_consolidated == 2
|
||||
assert session.provider_state is state
|
||||
assert session.updated_at == original_updated_at
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
|
||||
"""last_consolidated after retain_recent_legal_suffix should reflect how
|
||||
many retained messages were inside the old consolidated prefix."""
|
||||
|
||||
@@ -16,6 +16,7 @@ from nanobot.command.builtin import (
|
||||
cmd_dream_restore,
|
||||
)
|
||||
from nanobot.command.router import CommandContext
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.utils.gitstore import CommitInfo
|
||||
|
||||
|
||||
@@ -107,6 +108,13 @@ class _FakeBus:
|
||||
self.outbound.append(message)
|
||||
|
||||
|
||||
def _make_sessions(tmp_path) -> SessionManager:
|
||||
return SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "runtime",
|
||||
)
|
||||
|
||||
|
||||
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
|
||||
store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
|
||||
@@ -118,12 +126,10 @@ def _make_dream_ctx(tmp_path) -> tuple[CommandContext, _FakeBus]:
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
|
||||
bus = _FakeBus()
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
loop = SimpleNamespace(
|
||||
bus=bus,
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
sessions=_make_sessions(tmp_path),
|
||||
)
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
|
||||
return ctx, bus
|
||||
@@ -169,13 +175,11 @@ async def test_dream_internal_run_silences_progress(tmp_path) -> None:
|
||||
metadata={"_stop_reason": "completed"},
|
||||
)
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
dream_runtime = object()
|
||||
loop = SimpleNamespace(
|
||||
bus=bus,
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
sessions=_make_sessions(tmp_path),
|
||||
process_direct=process_direct,
|
||||
dream_runtime=lambda: dream_runtime,
|
||||
)
|
||||
@@ -224,12 +228,10 @@ def _build_runnable_dream(
|
||||
)
|
||||
|
||||
bus = _FakeBus()
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
loop = SimpleNamespace(
|
||||
bus=bus,
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
sessions=_make_sessions(tmp_path),
|
||||
process_direct=process_direct,
|
||||
dream_runtime=lambda: None,
|
||||
)
|
||||
@@ -311,12 +313,10 @@ async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
|
||||
|
||||
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
|
||||
bus = _FakeBus()
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
loop = SimpleNamespace(
|
||||
bus=bus,
|
||||
context=SimpleNamespace(memory=store, timezone="UTC"),
|
||||
sessions=SimpleNamespace(sessions_dir=sessions_dir),
|
||||
sessions=_make_sessions(tmp_path),
|
||||
process_direct=process_direct,
|
||||
dream_runtime=lambda: None,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import errno
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -65,6 +65,7 @@ class TestSaveFsync:
|
||||
session.add_message("user", "hello")
|
||||
directory_fd = 987654
|
||||
with (
|
||||
manager.locked_session_files(),
|
||||
patch("nanobot.session.manager.os.open", return_value=directory_fd) as open_dir,
|
||||
patch(
|
||||
"nanobot.session.manager.os.fsync",
|
||||
@@ -76,7 +77,7 @@ class TestSaveFsync:
|
||||
|
||||
assert manager._get_session_path(session.key).exists()
|
||||
open_dir.assert_called_once_with(str(manager.sessions_dir), os.O_RDONLY)
|
||||
close_dir.assert_called_once_with(directory_fd)
|
||||
assert close_dir.call_args_list.count(call(directory_fd)) == 1
|
||||
|
||||
def test_save_propagates_other_directory_fsync_errors(
|
||||
self, manager: SessionManager
|
||||
@@ -85,6 +86,7 @@ class TestSaveFsync:
|
||||
session = manager.get_or_create("test:directory-fsync-io-error")
|
||||
directory_fd = 987654
|
||||
with (
|
||||
manager.locked_session_files(),
|
||||
patch("nanobot.session.manager.os.open", return_value=directory_fd),
|
||||
patch(
|
||||
"nanobot.session.manager.os.fsync",
|
||||
@@ -95,7 +97,7 @@ class TestSaveFsync:
|
||||
):
|
||||
manager.save(session, fsync=True)
|
||||
|
||||
close_dir.assert_called_once_with(directory_fd)
|
||||
assert close_dir.call_args_list.count(call(directory_fd)) == 1
|
||||
|
||||
|
||||
class TestFlushAll:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import nanobot.session as session_api
|
||||
from nanobot.session import Session, SessionManager
|
||||
from nanobot.session.manager import FILE_MAX_MESSAGES, SessionStore
|
||||
@@ -77,3 +79,31 @@ def test_manager_applies_file_cap_before_store_save(tmp_path) -> None:
|
||||
assert len(session.messages) == FILE_MAX_MESSAGES
|
||||
archiver.assert_called_once()
|
||||
store.save.assert_called_once_with(session, fsync=False)
|
||||
|
||||
|
||||
def test_manager_retries_file_cap_archive_after_failure(tmp_path) -> None:
|
||||
store = MagicMock(spec=SessionStore)
|
||||
archiver = MagicMock(side_effect=[RuntimeError("history unavailable"), None])
|
||||
manager = SessionManager(tmp_path, store=store)
|
||||
manager.set_file_cap_archiver(archiver)
|
||||
session = Session(
|
||||
key="cli:retry-large",
|
||||
messages=[
|
||||
{"role": "user", "content": str(index)}
|
||||
for index in range(FILE_MAX_MESSAGES + 1)
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="history unavailable"):
|
||||
manager.save(session)
|
||||
|
||||
assert len(session.messages) == FILE_MAX_MESSAGES + 1
|
||||
store.save.assert_not_called()
|
||||
|
||||
manager.save(session)
|
||||
|
||||
assert len(session.messages) == FILE_MAX_MESSAGES
|
||||
assert archiver.call_count == 2
|
||||
assert archiver.call_args_list[0].args[0][0]["content"] == "0"
|
||||
assert archiver.call_args_list[1].args[0][0]["content"] == "0"
|
||||
store.save.assert_called_once_with(session, fsync=False)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.webui import native_folder_picker as picker
|
||||
|
||||
|
||||
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
|
||||
script = tmp_path / "picker.py"
|
||||
script.write_text(f"{body}\n", encoding="utf-8")
|
||||
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
|
||||
selected = tmp_path / "project"
|
||||
selected.mkdir()
|
||||
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
||||
monkeypatch.setattr(
|
||||
picker,
|
||||
"_picker_command",
|
||||
lambda: command,
|
||||
)
|
||||
|
||||
assert await picker.pick_native_folder() == str(selected)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
|
||||
selected = tmp_path / "project"
|
||||
selected.mkdir()
|
||||
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
||||
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
|
||||
original_spawn = picker.asyncio.create_subprocess_exec
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
async def capture_spawn(*args, **kwargs):
|
||||
captured_env.update(kwargs["env"])
|
||||
return await original_spawn(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
|
||||
|
||||
assert await picker.pick_native_folder() == str(selected)
|
||||
assert captured_env["HOME"] == str(tmp_path)
|
||||
assert "OPENAI_API_KEY" not in captured_env
|
||||
|
||||
|
||||
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
|
||||
monkeypatch.setattr(picker.sys, "platform", "linux")
|
||||
monkeypatch.setenv("DISPLAY", ":42")
|
||||
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
|
||||
|
||||
environment = picker._picker_environment()
|
||||
|
||||
assert environment["DISPLAY"] == ":42"
|
||||
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
|
||||
assert "ANTHROPIC_API_KEY" not in environment
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
|
||||
command = _picker_command(tmp_path, "raise SystemExit(1)")
|
||||
monkeypatch.setattr(
|
||||
picker,
|
||||
"_picker_command",
|
||||
lambda: command,
|
||||
)
|
||||
|
||||
assert await picker.pick_native_folder() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None:
|
||||
missing = tmp_path / "missing"
|
||||
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
|
||||
monkeypatch.setattr(
|
||||
picker,
|
||||
"_picker_command",
|
||||
lambda: command,
|
||||
)
|
||||
|
||||
with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"):
|
||||
await picker.pick_native_folder()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None:
|
||||
monkeypatch.setattr(picker, "_picker_command", lambda: None)
|
||||
|
||||
assert picker.native_folder_picker_available() is False
|
||||
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
|
||||
await picker.pick_native_folder()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
|
||||
command = _picker_command(tmp_path, "raise AssertionError('not started')")
|
||||
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
||||
|
||||
async def fail_spawn(*args, **kwargs):
|
||||
raise OSError("executable disappeared")
|
||||
|
||||
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
|
||||
|
||||
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
|
||||
await picker.pick_native_folder()
|
||||
@@ -72,6 +72,7 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
|
||||
assert payload["default_scope"]["access_mode"] == "full"
|
||||
assert payload["default_access_mode"] == "default"
|
||||
assert payload["controls"]["can_change_project"] is True
|
||||
assert payload["controls"]["can_pick_folder"] is False
|
||||
|
||||
|
||||
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
||||
@@ -91,6 +92,22 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
||||
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
|
||||
assert payload["controls"]["can_pick_folder"] is False
|
||||
|
||||
|
||||
def test_workspace_payload_advertises_native_folder_picker(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,
|
||||
folder_picker_available=True,
|
||||
)
|
||||
|
||||
assert payload["controls"]["can_pick_folder"] is True
|
||||
|
||||
|
||||
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -50,6 +52,20 @@ def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
||||
assert rows[0]["model_preset"] == "fast"
|
||||
|
||||
|
||||
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:unique-index-temp")
|
||||
session.add_message("user", "hello")
|
||||
manager.save(session)
|
||||
stale_shared_tmp = manager.sessions_dir / ".webui_session_index.json.tmp"
|
||||
stale_shared_tmp.write_text("stale", encoding="utf-8")
|
||||
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "hello"
|
||||
|
||||
assert stale_shared_tmp.read_text(encoding="utf-8") == "stale"
|
||||
assert not list(manager.sessions_dir.glob(".webui_session_index.json.*.tmp"))
|
||||
|
||||
|
||||
def test_webui_session_list_indexes_workspace_scope_and_preserves_null(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -133,6 +149,88 @@ def test_webui_session_list_does_not_cache_old_snapshot_with_new_signature(
|
||||
assert session_list_index.indexed_workspace_scope(second)[1]["access_mode"] == "restricted"
|
||||
|
||||
|
||||
def test_webui_session_scan_does_not_overlap_session_save(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = SessionManager(
|
||||
tmp_path / "workspace",
|
||||
sessions_root=tmp_path / "runtime",
|
||||
)
|
||||
session = manager.get_or_create("websocket:windows-reader")
|
||||
session.add_message("user", "before")
|
||||
manager.save(session)
|
||||
session_path = manager._get_session_path(session.key)
|
||||
session.messages[0]["content"] = "after"
|
||||
|
||||
reader_open = threading.Event()
|
||||
release_reader = threading.Event()
|
||||
save_started = threading.Event()
|
||||
save_lock_attempted = threading.Event()
|
||||
write_entered = threading.Event()
|
||||
original_open = open
|
||||
store = manager._jsonl_store
|
||||
original_acquire = store._session_files_lock.acquire
|
||||
original_save_unlocked = store._save_unlocked
|
||||
|
||||
class BlockingReader:
|
||||
def __init__(self, file):
|
||||
self.file = file
|
||||
|
||||
def __enter__(self):
|
||||
entered = self.file.__enter__()
|
||||
reader_open.set()
|
||||
if not release_reader.wait(5):
|
||||
raise AssertionError("timed out waiting to release the session reader")
|
||||
return entered
|
||||
|
||||
def __exit__(self, *args):
|
||||
try:
|
||||
return self.file.__exit__(*args)
|
||||
finally:
|
||||
reader_open.clear()
|
||||
|
||||
def blocking_open(path, *args, **kwargs):
|
||||
file = original_open(path, *args, **kwargs)
|
||||
if Path(path) == session_path:
|
||||
return BlockingReader(file)
|
||||
return file
|
||||
|
||||
def observed_acquire(*args, **kwargs):
|
||||
if save_started.is_set():
|
||||
save_lock_attempted.set()
|
||||
return original_acquire(*args, **kwargs)
|
||||
|
||||
def observed_save_unlocked(session, *, fsync=False):
|
||||
write_entered.set()
|
||||
assert not reader_open.is_set(), "save entered while the canonical file was open"
|
||||
return original_save_unlocked(session, fsync=fsync)
|
||||
|
||||
monkeypatch.setattr(session_list_index, "open", blocking_open, raising=False)
|
||||
monkeypatch.setattr(store._session_files_lock, "acquire", observed_acquire)
|
||||
monkeypatch.setattr(store, "_save_unlocked", observed_save_unlocked)
|
||||
|
||||
def save_session() -> None:
|
||||
save_started.set()
|
||||
manager.save(session)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
list_future = executor.submit(list_webui_sessions, manager)
|
||||
try:
|
||||
assert reader_open.wait(5)
|
||||
save_future = executor.submit(save_session)
|
||||
assert save_lock_attempted.wait(5)
|
||||
assert not write_entered.is_set()
|
||||
finally:
|
||||
release_reader.set()
|
||||
|
||||
assert list_future.result(timeout=5)[0]["preview"] == "before"
|
||||
save_future.result(timeout=5)
|
||||
|
||||
assert write_entered.is_set()
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "after"
|
||||
|
||||
|
||||
def test_webui_session_list_rejects_invalid_internal_model_preset_metadata(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -217,6 +217,7 @@ interface ThreadComposerProps {
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
workspaceError?: string | null;
|
||||
onPickWorkspaceFolder?: () => Promise<string | null>;
|
||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||
pendingQueueKey?: string | null;
|
||||
transcriptionProvider?: string | null;
|
||||
@@ -970,6 +971,7 @@ export function ThreadComposer({
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
workspaceError = null,
|
||||
onPickWorkspaceFolder,
|
||||
onWorkspaceScopeChange,
|
||||
pendingQueueKey = null,
|
||||
transcriptionProvider = null,
|
||||
@@ -2600,6 +2602,7 @@ export function ThreadComposer({
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onPickFolder={onPickWorkspaceFolder}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -661,6 +661,14 @@ export function ThreadShell({
|
||||
forkBoundaryMessageCount,
|
||||
} = useSessionHistory(historyKey);
|
||||
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
|
||||
const response = await client.requestMutation<{ path: unknown }>(
|
||||
"workspace.pick_folder",
|
||||
{},
|
||||
300_000,
|
||||
);
|
||||
return typeof response.path === "string" ? response.path : null;
|
||||
}, [client]);
|
||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
@@ -1458,6 +1466,9 @@ export function ThreadShell({
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
workspaceError={workspaceError}
|
||||
onPickWorkspaceFolder={
|
||||
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
||||
}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
pendingQueueKey={temporary ? null : chatId}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
@@ -1503,6 +1514,9 @@ export function ThreadShell({
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
workspaceError={workspaceError}
|
||||
onPickWorkspaceFolder={
|
||||
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
||||
}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
|
||||
@@ -51,6 +51,7 @@ export function WorkspaceProjectPicker({
|
||||
defaultScope,
|
||||
controls,
|
||||
error,
|
||||
onPickFolder,
|
||||
onChange,
|
||||
}: {
|
||||
isHero: boolean;
|
||||
@@ -61,6 +62,7 @@ export function WorkspaceProjectPicker({
|
||||
defaultScope: WorkspaceScopePayload | null;
|
||||
controls: WorkspacesPayload["controls"] | null;
|
||||
error?: string | null;
|
||||
onPickFolder?: () => Promise<string | null>;
|
||||
onChange?: (scope: WorkspaceScopePayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -79,7 +81,7 @@ export function WorkspaceProjectPicker({
|
||||
&& !!defaultScope
|
||||
&& !!onChange
|
||||
&& controls?.can_change_project !== false;
|
||||
const pickFolder = getRuntimeHost().pickFolder;
|
||||
const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
|
||||
const nativeProjectPicker = !!pickFolder;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -363,6 +363,7 @@ export interface WorkspacesPayload {
|
||||
controls: {
|
||||
can_change_project: boolean;
|
||||
can_use_full_access: boolean;
|
||||
can_pick_folder?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1260,6 +1260,45 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses the gateway folder picker for a locally hosted WebUI", async () => {
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project");
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "full" as const,
|
||||
restrict_to_workspace: false,
|
||||
};
|
||||
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{
|
||||
can_change_project: true,
|
||||
can_use_full_access: true,
|
||||
can_pick_folder: true,
|
||||
}}
|
||||
onPickWorkspaceFolder={pickFolder}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
|
||||
expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument();
|
||||
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
project_path: "/Users/test/gateway-project",
|
||||
project_name: "gateway-project",
|
||||
access_mode: "full",
|
||||
restrict_to_workspace: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses the web path menu when no native host picker is available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const defaultScope = {
|
||||
|
||||
Reference in New Issue
Block a user