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
+26 -7
View File
@@ -68,11 +68,13 @@ class ContextBuilder:
skill_names: list[str] | None = None,
channel: str | None = None,
session_summary: str | None = None,
workspace: Path | None = None,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
parts = [self._get_identity(channel=channel)]
root = workspace or self.workspace
parts = [self._get_identity(channel=channel, workspace=root)]
bootstrap = self._load_bootstrap_files()
bootstrap = self._load_bootstrap_files(root)
if bootstrap:
parts.append(bootstrap)
@@ -106,9 +108,10 @@ class ContextBuilder:
return "\n\n---\n\n".join(parts)
def _get_identity(self, channel: str | None = None) -> str:
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
"""Get the core identity section."""
workspace_path = str(self.workspace.expanduser().resolve())
root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -152,12 +155,13 @@ class ContextBuilder:
return _to_blocks(left) + _to_blocks(right)
def _load_bootstrap_files(self) -> str:
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load all bootstrap files from workspace."""
parts = []
root = workspace or self.workspace
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
file_path = root / filename
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
@@ -185,11 +189,18 @@ class ContextBuilder:
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
current_runtime_lines: Sequence[str] | None = None,
workspace: Path | None = None,
runtime_state: Any | None = None,
inbound_message: Any | None = None,
skip_runtime_lines: bool = False,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
extra = [
*goal_state_runtime_lines(session_metadata),
]
if runtime_state is not None and inbound_message is not None:
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context(
@@ -210,7 +221,15 @@ class ContextBuilder:
else:
merged = user_content + [{"type": "text", "text": runtime_ctx}]
messages = [
{"role": "system", "content": self.build_system_prompt(skill_names, channel=channel, session_summary=session_summary)},
{
"role": "system",
"content": self.build_system_prompt(
skill_names,
channel=channel,
session_summary=session_summary,
workspace=root,
),
},
*history,
]
if messages[-1].get("role") == current_role:
+58 -13
View File
@@ -25,8 +25,14 @@ from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRun
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.security.workspace_access import (
WorkspaceScopeResolver,
bind_workspace_scope,
reset_workspace_scope,
)
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
@@ -114,7 +120,6 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time)
turn_latency_ms: int | None = None
@@ -241,6 +246,10 @@ class AgentLoop:
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace
self.workspace_scopes = WorkspaceScopeResolver(
default_workspace=workspace,
default_restrict_to_workspace=restrict_to_workspace,
)
self._start_time = time.time()
self._last_usage: dict[str, int] = {}
self._pending_turn_latency_ms: dict[str, int] = {}
@@ -470,6 +479,7 @@ class AgentLoop:
provider_snapshot_loader=self._provider_snapshot_loader,
image_generation_provider_configs=self._image_generation_provider_configs,
timezone=self.context.timezone or "UTC",
workspace_sandbox=self.workspace_scopes.sandbox_status,
)
loader = ToolLoader()
registered = loader.load(ctx, self.tools)
@@ -493,7 +503,7 @@ class AgentLoop:
session_key: str | None = None,
) -> None:
"""Update context for all tools that need routing info."""
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.context import ContextAware
if session_key is not None:
effective_key = session_key
@@ -575,6 +585,7 @@ class AgentLoop:
pending_summary: str | None,
) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata)
return self.context.build_messages(
history=history,
current_message=image_generation_prompt(msg.content, msg.metadata),
@@ -583,7 +594,10 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace),
session_metadata=session.metadata,
workspace=scope.project_path,
runtime_state=self,
inbound_message=msg,
)
async def _dispatch_command_inline(
@@ -733,7 +747,21 @@ class AgentLoop:
return items
active_session_key = session.key if session else session_key
effective_scope = self.workspace_scopes.for_turn(
channel=channel,
message_metadata=metadata,
session_metadata=session.metadata if session is not None else None,
)
request_ctx = RequestContext(
channel=channel,
chat_id=chat_id,
message_id=message_id,
session_key=active_session_key,
metadata=dict(metadata or {}),
)
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
request_token = bind_request_context(request_ctx)
workspace_token = bind_workspace_scope(effective_scope)
# Build continuation message that embeds the active goal objective so
# the LLM can see it even if earlier Runtime Context was truncated.
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
@@ -753,7 +781,7 @@ class AgentLoop:
hook=hook,
error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True,
workspace=self.workspace,
workspace=effective_scope.project_path,
session_key=session.key if session else None,
context_window_tokens=self.context_window_tokens,
context_block_limit=self.context_block_limit,
@@ -774,6 +802,8 @@ class AgentLoop:
goal_continue_message=_goal_continue,
))
finally:
reset_workspace_scope(workspace_token)
reset_request_context(request_token)
reset_file_states(file_state_token)
self._last_usage = result.usage
if result.stop_reason == "max_iterations":
@@ -1063,6 +1093,7 @@ class AgentLoop:
}
history = session.get_history(**_hist_kwargs)
current_role = "assistant" if is_subagent else "user"
workspace_scope = self.workspace_scopes.for_message(msg, session.metadata)
messages = self.context.build_messages(
history=history,
@@ -1072,7 +1103,11 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata, current_runtime_lines=agent_context.runtime_lines(self, msg, self.context.workspace, skip=is_subagent),
session_metadata=session.metadata,
workspace=workspace_scope.project_path,
runtime_state=self,
inbound_message=msg,
skip_runtime_lines=is_subagent,
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@@ -1248,6 +1283,7 @@ class AgentLoop:
if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key)
mark_webui_session(ctx.session, msg.metadata)
self.workspace_scopes.persist_message_scope(ctx.session, msg)
if self._restore_runtime_checkpoint(ctx.session):
self.sessions.save(ctx.session)
@@ -1315,7 +1351,10 @@ class AgentLoop:
)
ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
ctx.msg,
ctx.session,
ctx.history,
ctx.pending_summary,
)
ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session
@@ -1618,10 +1657,16 @@ class AgentLoop:
channel=channel, sender_id="user", chat_id=chat_id,
content=content, media=media or [],
)
return await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)
try:
return await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)
finally:
if channel == "websocket":
await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
self._webui_turns.discard(session_key)
+52 -21
View File
@@ -16,6 +16,12 @@ from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.security.workspace_access import (
WorkspaceScope,
bind_workspace_scope,
reset_workspace_scope,
workspace_sandbox_status,
)
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig
@@ -128,6 +134,10 @@ class SubagentManager:
config=cfg,
workspace=str(root.resolve()),
file_state_store=FileStates(),
workspace_sandbox=workspace_sandbox_status(
restrict_to_workspace=cfg.restrict_to_workspace,
workspace=root,
),
)
ToolLoader().load(ctx, registry, scope="subagent")
return registry
@@ -146,6 +156,7 @@ class SubagentManager:
session_key: str | None = None,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> str:
"""Spawn a subagent to execute a task in the background."""
task_id = str(uuid.uuid4())[:8]
@@ -162,7 +173,14 @@ class SubagentManager:
bg_task = asyncio.create_task(
self._run_subagent(
task_id, task, display_label, origin, status, origin_message_id, temperature
task_id,
task,
display_label,
origin,
status,
origin_message_id,
temperature,
workspace_scope,
)
)
self._running_tasks[task_id] = bg_task
@@ -191,6 +209,7 @@ class SubagentManager:
status: SubagentStatus,
origin_message_id: str | None = None,
temperature: float | None = None,
workspace_scope: WorkspaceScope | None = None,
) -> None:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
@@ -200,8 +219,13 @@ class SubagentManager:
status.iteration = payload.get("iteration", status.iteration)
try:
tools = self._build_tools()
system_prompt = self._build_subagent_prompt()
root = workspace_scope.project_path if workspace_scope is not None else self.workspace
cfg = None
if workspace_scope is not None:
cfg = self._subagent_tools_config()
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
tools = self._build_tools(workspace=root, tools_config=cfg)
system_prompt = self._build_subagent_prompt(workspace=root)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": task},
@@ -213,21 +237,27 @@ class SubagentManager:
if self._llm_wall_timeout_for_session
else None
)
result = await self.runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
temperature=temperature,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status),
max_iterations_message="Task completed but no final response was generated.",
error_message=None,
fail_on_tool_error=True,
checkpoint_callback=_on_checkpoint,
session_key=sess_key,
llm_timeout_s=llm_timeout,
))
token = bind_workspace_scope(workspace_scope) if workspace_scope is not None else None
try:
result = await self.runner.run(AgentRunSpec(
initial_messages=messages,
tools=tools,
model=self.model,
temperature=temperature,
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=_SubagentHook(task_id, status),
max_iterations_message="Task completed but no final response was generated.",
error_message=None,
fail_on_tool_error=True,
checkpoint_callback=_on_checkpoint,
session_key=sess_key,
workspace=root,
llm_timeout_s=llm_timeout,
))
finally:
if token is not None:
reset_workspace_scope(token)
status.phase = "done"
status.stop_reason = result.stop_reason
@@ -321,20 +351,21 @@ class SubagentManager:
lines.append(f"- {result.error}")
return "\n".join(lines) or (result.error or "Error: subagent execution failed.")
def _build_subagent_prompt(self) -> str:
def _build_subagent_prompt(self, workspace: Path | None = None) -> str:
"""Build a focused system prompt for the subagent."""
from nanobot.agent.context import ContextBuilder
from nanobot.agent.skills import SkillsLoader
time_ctx = ContextBuilder._build_runtime_context(None, None)
root = workspace or self.workspace
skills_summary = SkillsLoader(
self.workspace,
root,
disabled_skills=self.disabled_skills,
).build_skills_summary()
return render_template(
"agent/subagent_system.md",
time_ctx=time_ctx,
workspace=str(self.workspace),
workspace=str(root),
skills_summary=skills_summary or "",
)
+7 -69
View File
@@ -88,11 +88,11 @@ def _format_summary(summary: _PatchSummary) -> str:
items=ObjectSchema(
path=StringSchema("Relative path to the file to edit."),
action=StringSchema(
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).",
enum=["replace", "add", "delete"],
"Operation type: replace or add.",
enum=["replace", "add"],
),
old_text=StringSchema(
"Exact text to search for in the file. Required for replace and delete.",
"Exact text to search for in the file. Required for replace.",
nullable=True,
),
new_text=StringSchema(
@@ -124,7 +124,8 @@ class ApplyPatchTool(_FsTool):
def description(self) -> str:
return (
"Default tool for code edits. Supports multi-file changes in a single call. "
"Provide a list of structured edits, each specifying a file path, action (replace/add/delete), and the text to change. "
"Provide a list of structured edits, each specifying a file path, action "
"(replace/add), and the exact text to change. "
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
"Use edit_file only for small exact replacements on a single file."
)
@@ -140,7 +141,6 @@ class ApplyPatchTool(_FsTool):
raise _PatchError("must provide edits")
writes: dict[Path, str] = {}
deletes: set[Path] = set()
summaries: list[_PatchSummary] = []
for edit in edits:
@@ -183,7 +183,6 @@ class ApplyPatchTool(_FsTool):
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
action_name = "update"
else:
@@ -191,7 +190,6 @@ class ApplyPatchTool(_FsTool):
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
writes[source] = new_norm
deletes.discard(source)
added = _text_line_count(new_norm)
deleted = 0
action_name = "add"
@@ -246,7 +244,6 @@ class ApplyPatchTool(_FsTool):
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
@@ -254,62 +251,6 @@ class ApplyPatchTool(_FsTool):
)
)
elif action == "delete":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for delete: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
if norm_old == norm_content:
deletes.add(source)
writes.pop(source, None)
added, deleted = 0, _text_line_count(content)
summaries.append(
_PatchSummary(
action="delete", path=path, added=added, deleted=deleted
)
)
else:
new_norm = (
norm_content[:pos] + norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
else:
raise _PatchError(f"unknown action: {action}")
@@ -319,13 +260,10 @@ class ApplyPatchTool(_FsTool):
)
backups: dict[Path, bytes | None] = {}
for path in set(writes) | deletes:
for path in writes:
backups[path] = path.read_bytes() if path.exists() else None
try:
for path in deletes:
if path.exists():
path.unlink()
for path, content in writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="")
@@ -339,7 +277,7 @@ class ApplyPatchTool(_FsTool):
path.write_bytes(data)
raise
for path in set(writes) | deletes:
for path in writes:
self._file_states.record_write(path)
return "Patch applied:\n" + "\n".join(
_format_summary(summary) for summary in summaries
+8 -2
View File
@@ -9,6 +9,7 @@ from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
@@ -113,7 +114,12 @@ class CliAppsTool(Tool):
working_dir: str | None = None,
timeout: int | None = None,
) -> str:
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
access = current_tool_workspace(
self.workspace,
restrict_to_workspace=self.restrict_to_workspace,
)
workspace = access.project_path or self.workspace
manager = CliAppManager(workspace=workspace, runtime=self.runtime)
try:
return manager.run(
name,
@@ -121,7 +127,7 @@ class CliAppsTool(Tool):
json_output=bool(json),
working_dir=working_dir,
timeout=timeout,
restrict_to_workspace=self.restrict_to_workspace,
restrict_to_workspace=access.restrict_to_workspace,
)
except CliAppError as exc:
return f"Error: {exc.message}"
+24
View File
@@ -1,9 +1,15 @@
"""Runtime context for tool construction."""
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, runtime_checkable
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
"nanobot_tool_request_context",
default=None,
)
@dataclass(frozen=True)
class RequestContext:
@@ -21,6 +27,23 @@ class ContextAware(Protocol):
...
def bind_request_context(ctx: RequestContext) -> Token[RequestContext | None]:
return _CURRENT_REQUEST_CONTEXT.set(ctx)
def reset_request_context(token: Token[RequestContext | None]) -> None:
_CURRENT_REQUEST_CONTEXT.reset(token)
def current_request_context() -> RequestContext | None:
return _CURRENT_REQUEST_CONTEXT.get()
def current_request_session_key() -> str | None:
ctx = current_request_context()
return ctx.session_key if ctx else None
@dataclass
class ToolContext:
config: Any
@@ -33,3 +56,4 @@ class ToolContext:
provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None
timezone: str = "UTC"
workspace_sandbox: Any | None = None
+23 -2
View File
@@ -10,6 +10,7 @@ from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
@@ -43,6 +44,7 @@ class ExecSessionInfo:
idle_s: float
remaining_s: float
returncode: int | None
owner_session_key: str | None = None
class _ExecSession:
@@ -54,11 +56,13 @@ class _ExecSession:
command: str,
cwd: str,
timeout: int | None,
owner_session_key: str | None = None,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.owner_session_key = owner_session_key
self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
@@ -175,6 +179,7 @@ class ExecSessionManager:
login: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> tuple[str, _SessionPoll]:
async with self._lock:
await self._cleanup_locked()
@@ -188,6 +193,7 @@ class ExecSessionManager:
command=command,
cwd=cwd,
timeout=timeout,
owner_session_key=owner_session_key,
)
self._sessions[session_id] = session
@@ -206,12 +212,19 @@ class ExecSessionManager:
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> _SessionPoll:
async with self._lock:
await self._cleanup_locked()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if (
owner_session_key
and session.owner_session_key
and session.owner_session_key != owner_session_key
):
raise KeyError(session_id)
if chars:
error = await session.write(chars)
@@ -236,7 +249,7 @@ class ExecSessionManager:
self._sessions.pop(session_id, None)
return poll
async def list(self) -> list[ExecSessionInfo]:
async def list(self, *, owner_session_key: str | None = None) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
@@ -249,8 +262,12 @@ class ExecSessionManager:
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
owner_session_key=session.owner_session_key,
)
for session_id, session in sorted(self._sessions.items())
if not owner_session_key
or not session.owner_session_key
or session.owner_session_key == owner_session_key
]
async def _cleanup_locked(self) -> None:
@@ -477,6 +494,7 @@ class WriteStdinTool(Tool):
terminate=terminate,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=output_limit,
owner_session_key=current_request_session_key(),
)
return format_session_poll(session_id, poll)
except KeyError:
@@ -510,6 +528,7 @@ class WriteStdinTool(Tool):
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
owner_session_key=current_request_session_key(),
)
first = False
if poll.output:
@@ -573,7 +592,9 @@ class ListExecSessionsTool(Tool):
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list()
sessions = await self._manager.list(
owner_session_key=current_request_session_key(),
)
if not sessions:
return "No active exec sessions."
lines = []
+23 -3
View File
@@ -10,6 +10,7 @@ from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -28,10 +29,18 @@ class _FsTool(Tool):
allowed_dir: Path | None = None,
extra_allowed_dirs: list[Path] | None = None,
file_states: FileStates | None = None,
restrict_to_workspace: bool | None = None,
sandbox_restricts_workspace: bool = False,
):
self._workspace = workspace
self._allowed_dir = allowed_dir
self._extra_allowed_dirs = extra_allowed_dirs
self._restrict_to_workspace = (
bool(restrict_to_workspace)
if restrict_to_workspace is not None
else allowed_dir is not None
)
self._sandbox_restricts_workspace = sandbox_restricts_workspace
# Explicit state is used by isolated runners like Dream/subagents.
# Main AgentLoop tools leave this unset and resolve state from the
# current async task, which keeps shared tool instances session-safe.
@@ -46,13 +55,16 @@ class _FsTool(Tool):
ctx.config.restrict_to_workspace
or ctx.config.exec.sandbox
)
sandbox_restricts = bool(ctx.config.exec.sandbox)
allowed_dir = Path(ctx.workspace) if restrict else None
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
extra_read = [BUILTIN_SKILLS_DIR]
return cls(
workspace=Path(ctx.workspace),
allowed_dir=allowed_dir,
extra_allowed_dirs=extra_read,
file_states=ctx.file_state_store,
restrict_to_workspace=ctx.config.restrict_to_workspace,
sandbox_restricts_workspace=sandbox_restricts,
)
@property
@@ -62,13 +74,21 @@ class _FsTool(Tool):
return current_file_states(self._fallback_file_states)
def _resolve(self, path: str) -> Path:
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
sandbox_restricts_workspace=self._sandbox_restricts_workspace,
)
return resolve_workspace_path(
path,
self._workspace,
self._allowed_dir,
access.project_path,
access.allowed_root,
self._extra_allowed_dirs,
)
def _display_workspace(self) -> Path | None:
return current_tool_workspace(self._workspace).project_path
# ---------------------------------------------------------------------------
# read_file
+15 -17
View File
@@ -14,6 +14,7 @@ from nanobot.agent.tools.schema import (
StringSchema,
tool_parameters_schema,
)
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.providers.image_generation import (
@@ -21,6 +22,7 @@ from nanobot.providers.image_generation import (
ImageGenerationProvider,
get_image_gen_provider,
)
from nanobot.security.workspace_policy import WorkspaceBoundaryError, resolve_allowed_path
from nanobot.utils.artifacts import (
ArtifactError,
generated_image_tool_result,
@@ -131,18 +133,22 @@ class ImageGenerationTool(Tool):
return cls(**kwargs)
def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser()
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
access = current_tool_workspace(self.workspace, restrict_to_workspace=True)
workspace = access.project_path or self.workspace
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise ImageGenerationError(f"reference image not found: {value}") from exc
allowed_roots = [self.workspace.resolve(), get_media_dir().resolve()]
if not any(_is_relative_to(resolved, root) for root in allowed_roots):
resolved = resolve_allowed_path(
value,
workspace=workspace,
allowed_root=access.allowed_root,
extra_allowed_roots=[get_media_dir()] if access.allowed_root is not None else None,
strict=True,
)
except WorkspaceBoundaryError as exc:
raise ImageGenerationError(
"reference_images must be inside the workspace or nanobot media directory"
)
) from exc
except OSError as exc:
raise ImageGenerationError(f"reference image not found: {value}") from exc
if not resolved.is_file():
raise ImageGenerationError(f"reference image is not a file: {value}")
raw = resolved.read_bytes()
@@ -201,11 +207,3 @@ class ImageGenerationTool(Tool):
return generated_image_tool_result(artifacts)
except (ArtifactError, ImageGenerationError, OSError) as exc:
return f"Error: {exc}"
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
+9 -4
View File
@@ -8,6 +8,7 @@ from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_tool_workspace
from nanobot.bus.events import OutboundMessage
from nanobot.config.paths import get_workspace_path
@@ -149,15 +150,19 @@ class MessageTool(Tool, ContextAware):
def _resolve_media(self, media: list[str]) -> list[str]:
"""Resolve local media attachments and enforce workspace restriction when enabled."""
resolved: list[str] = []
allowed_dir = self._workspace if self._restrict_to_workspace else None
access = current_tool_workspace(
self._workspace,
restrict_to_workspace=self._restrict_to_workspace,
)
workspace = access.project_path or self._workspace
for p in media:
if p.startswith(("http://", "https://")):
resolved.append(p)
elif not self._restrict_to_workspace:
elif not access.restrict_to_workspace:
path = Path(p).expanduser()
resolved.append(p if path.is_absolute() else str(self._workspace / path))
resolved.append(p if path.is_absolute() else str(workspace / path))
else:
resolved.append(str(resolve_workspace_path(p, self._workspace, allowed_dir)))
resolved.append(str(resolve_workspace_path(p, workspace, access.allowed_root)))
return resolved
async def execute(
+11 -23
View File
@@ -3,21 +3,15 @@
from pathlib import Path
from nanobot.config.paths import get_media_dir
WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
from nanobot.security.workspace_policy import (
is_path_within,
resolve_allowed_path,
)
def is_under(path: Path, directory: Path) -> bool:
"""Return True when path resolves under directory."""
try:
path.relative_to(directory.resolve())
return True
except ValueError:
return False
return is_path_within(path, directory)
def resolve_workspace_path(
@@ -27,16 +21,10 @@ def resolve_workspace_path(
extra_allowed_dirs: list[Path] | None = None,
) -> Path:
"""Resolve path against workspace and enforce allowed directory containment."""
p = Path(path).expanduser()
if not p.is_absolute() and workspace:
p = workspace / p
resolved = p.resolve()
if allowed_dir:
media_path = get_media_dir().resolve()
all_dirs = [allowed_dir, media_path, *(extra_allowed_dirs or [])]
if not any(is_under(resolved, d) for d in all_dirs):
raise PermissionError(
f"Path {path} is outside allowed directory {allowed_dir}"
+ WORKSPACE_BOUNDARY_NOTE
)
return resolved
extra_roots = [get_media_dir(), *(extra_allowed_dirs or [])] if allowed_dir else None
return resolve_allowed_path(
path,
workspace=workspace,
allowed_root=allowed_dir,
extra_allowed_roots=extra_roots,
)
+3
View File
@@ -42,6 +42,9 @@ class RuntimeState(Protocol):
@property
def exec_config(self) -> Any: ...
@property
def workspace_sandbox(self) -> Any: ...
@property
def subagents(self) -> Any: ...
+3 -2
View File
@@ -101,9 +101,10 @@ class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
def _display_path(self, target: Path, root: Path) -> str:
if self._workspace:
workspace = self._display_workspace()
if workspace:
with suppress(ValueError):
return target.relative_to(self._workspace).as_posix()
return target.relative_to(workspace).as_posix()
return target.relative_to(root).as_posix()
def _iter_files(self, root: Path) -> Iterable[Path]:
+15 -6
View File
@@ -3,16 +3,18 @@
from __future__ import annotations
import time
from typing import Any
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config.schema import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
class MyToolConfig(Base):
"""Self-inspection tool configuration."""
@@ -33,6 +35,12 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False
def _is_subagent_status(value: Any) -> bool:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
class MyTool(Tool, ContextAware):
"""Check and set the agent loop's runtime configuration."""
@@ -68,6 +76,7 @@ class MyTool(Tool, ContextAware):
"_current_iteration", # updated by runner only
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked
"workspace_sandbox", # read-only view of workspace enforcement level
})
_DENIED_ATTRS = frozenset({
@@ -214,7 +223,7 @@ class MyTool(Tool, ContextAware):
# ------------------------------------------------------------------
@staticmethod
def _format_status(st: SubagentStatus, indent: str = " ") -> str:
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
elapsed = time.monotonic() - st.started_at
tool_summary = ", ".join(
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
@@ -232,14 +241,14 @@ class MyTool(Tool, ContextAware):
@staticmethod
def _format_value(val: Any, key: str = "") -> str:
if isinstance(val, SubagentStatus):
if _is_subagent_status(val):
header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
return MyTool._format_value(val._task_statuses, key)
if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus):
if isinstance(val, dict) and val and _is_subagent_status(next(iter(val.values()))):
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items():
@@ -349,7 +358,7 @@ class MyTool(Tool, ContextAware):
parts.append(self._format_value(getattr(state, k, None), k))
parts.append(self._format_value(state.model_preset, "model_preset"))
# Other useful top-level keys shown in description
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k))
# Token usage
+44 -14
View File
@@ -25,10 +25,13 @@ from nanobot.agent.tools.exec_session import (
clamp_session_int,
format_session_poll,
)
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.security.workspace_policy import is_path_within
_IS_WINDOWS = sys.platform == "win32"
@@ -140,6 +143,7 @@ class ExecTool(Tool):
working_dir=ctx.workspace,
timeout=cfg.timeout,
restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox,
path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys,
@@ -154,6 +158,8 @@ class ExecTool(Tool):
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
@@ -183,6 +189,9 @@ class ExecTool(Tool):
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@@ -313,6 +322,7 @@ class ExecTool(Tool):
shell_program=prepared.shell_program,
login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
owner_session_key=current_request_session_key(),
max_output_chars=clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
@@ -346,29 +356,39 @@ class ExecTool(Tool):
shell: str | None = None,
login: bool | None = None,
) -> _PreparedCommand | str:
cwd = working_dir or self.working_dir or os.getcwd()
access = current_tool_workspace(
self.working_dir,
restrict_to_workspace=self.restrict_to_workspace,
sandbox_restricts_workspace=bool(self.sandbox),
)
workspace_root = str(access.project_path) if access.project_path is not None else self.working_dir
cwd = working_dir or workspace_root or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
# workspace when restrict_to_workspace is enabled (#2826). Without
# this, a caller can pass working_dir="/etc" and then all absolute
# paths under /etc would pass the _guard_command check that anchors
# on cwd.
if self.restrict_to_workspace and self.working_dir:
if access.restrict_to_workspace and workspace_root:
try:
requested = Path(cwd).expanduser().resolve()
workspace_root = Path(self.working_dir).expanduser().resolve()
resolved_root = Path(workspace_root).expanduser().resolve()
except Exception:
return (
"Error: working_dir could not be resolved"
+ _WORKSPACE_BOUNDARY_NOTE
)
if requested != workspace_root and workspace_root not in requested.parents:
if not is_path_within(requested, resolved_root):
return (
"Error: working_dir is outside the configured workspace"
+ _WORKSPACE_BOUNDARY_NOTE
)
guard_error = self._guard_command(command, cwd)
guard_error = self._guard_command(
command,
cwd,
restrict_to_workspace=access.restrict_to_workspace,
)
if guard_error:
return guard_error
@@ -379,7 +399,7 @@ class ExecTool(Tool):
self.sandbox,
)
else:
workspace = self.working_dir or cwd
workspace = workspace_root or cwd
command = wrap_command(self.sandbox, command, workspace, cwd)
cwd = str(Path(workspace).resolve())
@@ -528,7 +548,13 @@ class ExecTool(Tool):
env[key] = val
return env
def _guard_command(self, command: str, cwd: str) -> str | None:
def _guard_command(
self,
command: str,
cwd: str,
*,
restrict_to_workspace: bool | None = None,
) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
@@ -548,11 +574,17 @@ class ExecTool(Tool):
return "Error: Command blocked by allowlist filter (not in allowlist)"
from nanobot.security.network import contains_internal_url
if contains_internal_url(cmd):
if contains_internal_url(
cmd,
allow_loopback=current_scope_allows_loopback(
enabled=self.webui_allow_local_service_access,
),
):
# The runner turns this marker into a non-retryable security hint.
return "Error: Command blocked by safety guard (internal/private URL detected)"
if self.restrict_to_workspace:
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
if should_restrict:
if "..\\" in cmd or "../" in cmd:
return (
"Error: Command blocked by safety guard (path traversal detected)"
@@ -577,11 +609,9 @@ class ExecTool(Tool):
continue
media_path = get_media_dir().resolve()
if (p.is_absolute()
and cwd_path not in p.parents
and p != cwd_path
and media_path not in p.parents
and p != media_path
if p.is_absolute() and not (
is_path_within(p, cwd_path)
or is_path_within(p, media_path)
):
return (
"Error: Command blocked by safety guard (path outside working dir)"
+2
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import NumberSchema, StringSchema, tool_parameters_schema
from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
@@ -91,4 +92,5 @@ class SpawnTool(Tool, ContextAware):
session_key=self._session_key.get(),
origin_message_id=self._origin_message_id.get(),
temperature=temperature,
workspace_scope=current_workspace_scope(),
)
+12 -4
View File
@@ -20,6 +20,7 @@ import httpx
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir
from nanobot.security.workspace_policy import is_path_within
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
@@ -32,6 +33,7 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({
".csv",
".drawio",
@@ -362,6 +364,12 @@ def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
def _catalog_description(app: dict[str, Any]) -> str:
"""Return catalog copy without implying vendor endorsement."""
description = str(app.get("description") or "")
return _ENDORSEMENT_WORD_RE.sub("", description).strip()
class CliAppManager:
"""Manage CLI-Anything registry entries and local install state."""
@@ -554,7 +562,7 @@ class CliAppManager:
"name": name,
"display_name": app.get("display_name") or name,
"category": app.get("category") or "uncategorized",
"description": app.get("description") or "",
"description": _catalog_description(app),
"requires": app.get("requires") or "",
"source": app.get("_source") or "harness",
"entry_point": entry_point,
@@ -630,7 +638,7 @@ class CliAppManager:
app_id=name,
display_name=str(app.get("display_name") or name),
version=str(app.get("version") or ""),
description=str(app.get("description") or ""),
description=_catalog_description(app),
category=str(app.get("category") or "uncategorized"),
source=f"cli-anything:{app.get('_source') or 'harness'}",
logo_url=logo_url,
@@ -802,7 +810,7 @@ class CliAppManager:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = str(app.get("description") or f"Use {display} from nanobot.")
description = _catalog_description(app) or f"Use {display} from nanobot."
return f"""---
name: {_safe_skill_name(name)}
description: >-
@@ -1018,7 +1026,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
cwd = cwd.resolve(strict=False)
workspace = self.workspace.resolve(strict=False)
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
if restrict_to_workspace and not is_path_within(cwd, workspace):
raise CliAppError("working_dir is outside the configured workspace")
return cwd
+10 -1
View File
@@ -57,11 +57,17 @@ class ChannelManager:
*,
session_manager: "SessionManager | None" = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
):
self.config = config
self.bus = bus
self._session_manager = session_manager
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
@@ -107,12 +113,15 @@ class ChannelManager:
if cls.name == "websocket":
if self._session_manager is not None:
kwargs["session_manager"] = self._session_manager
static_path = _default_webui_dist()
static_path = _default_webui_dist() if self._webui_static_dist else None
if static_path is not None:
kwargs["static_dist_path"] = static_path
kwargs["workspace_path"] = self.config.workspace_path
kwargs["restrict_to_workspace"] = self.config.tools.restrict_to_workspace
if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name
kwargs["runtime_surface"] = self._webui_runtime_surface
kwargs["runtime_capabilities_overrides"] = self._webui_runtime_capabilities
channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key
+3 -5
View File
@@ -11,6 +11,8 @@ from typing import Any, Literal, TypeAlias
from pydantic import Field
from nanobot.security.workspace_policy import is_path_within
try:
import nh3
from mistune import create_markdown
@@ -344,11 +346,7 @@ class MatrixChannel(BaseChannel):
"""Check path is inside workspace (when restriction enabled)."""
if not self._restrict_to_workspace or not self._workspace:
return True
try:
path.resolve(strict=False).relative_to(self._workspace)
return True
except ValueError:
return False
return is_path_within(path, self._workspace)
def _collect_outbound_media_candidates(self, media: list[str]) -> list[Path]:
"""Deduplicate and resolve outbound attachment paths."""
+310 -31
View File
@@ -18,19 +18,24 @@ import ssl
import time
import uuid
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger
from pydantic import Field, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve
from websockets.asyncio.server import ServerConnection, serve, unix_serve
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScopeError,
)
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@@ -48,9 +53,15 @@ from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_c
from nanobot.webui.settings_api import (
WebUISettingsError,
create_model_configuration,
decorate_settings_payload,
login_oauth_provider,
logout_oauth_provider,
runtime_capabilities,
settings_payload,
update_agent_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_web_search_settings,
)
@@ -73,6 +84,9 @@ from nanobot.webui.transcript import (
build_webui_thread_response,
rewrite_local_markdown_images,
)
from nanobot.webui.workspaces import (
WebUIWorkspaceController,
)
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
@@ -100,6 +114,41 @@ def _normalize_config_path(path: str) -> str:
return _strip_trailing_slash(path)
def _case_insensitive_header(headers: Any, key: str) -> str:
"""Read a header from websockets/http test stubs without assuming casing."""
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def _safe_host_header(value: str) -> str:
"""Return a safe Host header value, or empty when it should not be echoed."""
value = value.strip()
if not value:
return ""
if re.fullmatch(r"\[[0-9A-Fa-f:.]+\](?::\d{1,5})?", value):
return value
if re.fullmatch(r"[A-Za-z0-9.-]+(?::\d{1,5})?", value):
return value
return ""
def _host_for_url(host: str, port: int) -> str:
host = host.strip()
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{host}:{port}"
class WebSocketConfig(Base):
"""WebSocket server channel configuration.
@@ -123,6 +172,7 @@ class WebSocketConfig(Base):
enabled: bool = False
host: str = "127.0.0.1"
port: int = 8765
unix_socket_path: str = ""
path: str = "/"
token: str = ""
token_issue_path: str = ""
@@ -141,6 +191,19 @@ class WebSocketConfig(Base):
ssl_certfile: str = ""
ssl_keyfile: str = ""
@field_validator("unix_socket_path")
@classmethod
def unix_socket_path_format(cls, value: str) -> str:
value = value.strip()
if not value:
return ""
if "\x00" in value:
raise ValueError("unix_socket_path must not contain NUL bytes")
path = Path(value).expanduser()
if not path.is_absolute():
raise ValueError("unix_socket_path must be an absolute path")
return str(path)
@field_validator("path")
@classmethod
def path_must_start_with_slash(cls, value: str) -> str:
@@ -503,7 +566,10 @@ class WebSocketChannel(BaseChannel):
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
restrict_to_workspace: bool = False,
runtime_model_name: Callable[[], str | None] | None = None,
runtime_surface: str = "browser",
runtime_capabilities_overrides: dict[str, Any] | None = None,
):
if isinstance(config, dict):
config = WebSocketConfig.model_validate(config)
@@ -530,7 +596,20 @@ class WebSocketChannel(BaseChannel):
if workspace_path is not None
else get_workspace_path()
).resolve(strict=False)
self._default_restrict_to_workspace = restrict_to_workspace
self._webui_workspaces = WebUIWorkspaceController(
session_manager=self._session_manager,
default_workspace=self._workspace_path,
default_restrict_to_workspace=self._default_restrict_to_workspace,
)
self._runtime_model_name = runtime_model_name
self._runtime_surface = (
"native" if runtime_surface in {"native", "desktop"} else "browser"
)
self._runtime_capabilities = runtime_capabilities(
self._runtime_surface,
runtime_capabilities_overrides,
)
self._settings_restart_sections: set[str] = set()
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is
@@ -695,6 +774,9 @@ class WebSocketChannel(BaseChannel):
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request)
@@ -707,15 +789,27 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/model-configurations/create":
return self._handle_settings_model_configuration_create(request)
if got == "/api/settings/model-configurations/update":
return self._handle_settings_model_configuration_update(request)
if got == "/api/settings/provider/update":
return self._handle_settings_provider_update(request)
if got == "/api/settings/provider/oauth-login":
return await self._handle_settings_provider_oauth(request, "login")
if got == "/api/settings/provider/oauth-logout":
return await self._handle_settings_provider_oauth(request, "logout")
if got == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/network-safety/update":
return self._handle_settings_network_safety_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
@@ -773,6 +867,12 @@ class WebSocketChannel(BaseChannel):
return connection.respond(403, "Forbidden")
return self._authorize_websocket_handshake(connection, query)
# API clients should never receive the SPA shell for an unknown route.
# Returning HTML here makes the WebUI fail with "Unexpected token <"
# when a dev server is pointed at an older gateway.
if got.startswith("/api/"):
return _http_error(404, "API route not found")
# 5. Static SPA serving (only if a build directory was wired in).
if self._static_dist_path is not None:
response = self._serve_static(got)
@@ -832,15 +932,32 @@ class WebSocketChannel(BaseChannel):
# while the REST surface keeps validating the other until TTL expiry.
self._issued_tokens[token] = expiry
self._api_tokens[token] = expiry
ws_url = self._bootstrap_ws_url(request)
return _http_json_response(
{
"token": token,
"ws_path": self._expected_path(),
"ws_url": ws_url,
"expires_in": self.config.token_ttl_s,
"model_name": _resolve_bootstrap_model_name(self._runtime_model_name),
"runtime_surface": self._runtime_surface,
"runtime_capabilities": self._runtime_capabilities,
}
)
def _bootstrap_ws_url(self, request: Any) -> str:
"""Absolute WS URL clients should prefer over a dev-server proxy."""
headers = getattr(request, "headers", {}) or {}
host = _safe_host_header(_case_insensitive_header(headers, "Host"))
if not host:
host = _host_for_url(self.config.host, self.config.port)
proto = _case_insensitive_header(headers, "X-Forwarded-Proto")
proto = proto.split(",", 1)[0].strip().lower()
secure = proto in {"https", "wss"} or bool(self.config.ssl_certfile.strip())
scheme = "wss" if secure else "ws"
return f"{scheme}://{host}{self._expected_path()}"
def _handle_sessions_list(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -859,13 +976,29 @@ class WebSocketChannel(BaseChannel):
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
scope = self._webui_workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload()
cleaned.append(row)
return _http_json_response({"sessions": cleaned})
def _handle_workspaces(self, connection: Any, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self._webui_workspaces.payload(controls_available=_is_localhost(connection))
)
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(self._with_settings_restart_state(settings_payload()))
return _http_json_response(
self._with_settings_restart_state(
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
)
)
def _with_settings_restart_state(
self,
@@ -876,14 +1009,16 @@ class WebSocketChannel(BaseChannel):
"""Keep restart-required state alive for this gateway process."""
if section and payload.get("requires_restart"):
self._settings_restart_sections.add(section)
if self._settings_restart_sections:
payload = dict(payload)
sections = sorted(self._settings_restart_sections)
payload = dict(payload)
if sections:
payload["requires_restart"] = True
payload["restart_required_sections"] = sorted(self._settings_restart_sections)
else:
payload = dict(payload)
payload["restart_required_sections"] = []
return payload
return decorate_settings_payload(
payload,
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
restart_required_sections=sections,
)
def _handle_commands(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
@@ -939,6 +1074,16 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_model_configuration_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_model_configuration(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_provider_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -949,6 +1094,19 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
async def _handle_settings_provider_oauth(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query)
else:
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload))
def _handle_settings_web_search_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -957,7 +1115,7 @@ class WebSocketChannel(BaseChannel):
payload = update_web_search_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="web"))
return _http_json_response(self._with_settings_restart_state(payload, section="browser"))
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
@@ -969,6 +1127,16 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_network_safety_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = update_network_safety_settings(query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="runtime"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -1058,13 +1226,19 @@ class WebSocketChannel(BaseChannel):
return _http_error(400, "invalid session key")
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
scope = self._webui_workspaces.scope_for_session_key(decoded_key)
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_media,
augment_assistant_text=self._rewrite_local_markdown_images,
augment_assistant_text=lambda text: rewrite_local_markdown_images(
text,
workspace_path=scope.project_path,
sign_path=self._sign_or_stage_media_path,
),
)
if data is None:
return _http_error(404, "webui thread not found")
data["workspace_scope"] = scope.payload()
return _http_json_response(data)
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None:
@@ -1359,34 +1533,63 @@ class WebSocketChannel(BaseChannel):
await self._connection_loop(connection)
self.logger.info(
"WebSocket server listening on {}://{}:{}{}",
scheme,
self.config.host,
self.config.port,
self.config.path,
"WebSocket server listening on {}",
(
f"unix:{self.config.unix_socket_path}{self.config.path}"
if self.config.unix_socket_path
else f"{scheme}://{self.config.host}:{self.config.port}{self.config.path}"
),
)
if self.config.token_issue_path:
self.logger.info(
"WebSocket token issue route: {}://{}:{}{}",
scheme,
self.config.host,
self.config.port,
_normalize_config_path(self.config.token_issue_path),
"WebSocket token issue route: {}",
(
f"unix:{self.config.unix_socket_path}{_normalize_config_path(self.config.token_issue_path)}"
if self.config.unix_socket_path
else (
f"{scheme}://{self.config.host}:{self.config.port}"
f"{_normalize_config_path(self.config.token_issue_path)}"
)
),
)
async def runner() -> None:
async with serve(
handler,
self.config.host,
self.config.port,
process_request=process_request,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
ssl=ssl_context,
):
socket_path = self.config.unix_socket_path
if socket_path:
path_obj = Path(socket_path)
path_obj.parent.mkdir(parents=True, exist_ok=True)
with suppress(FileNotFoundError):
path_obj.unlink()
server = await unix_serve(
handler,
socket_path,
process_request=process_request,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
)
with suppress(OSError):
path_obj.chmod(0o600)
else:
server = await serve(
handler,
self.config.host,
self.config.port,
process_request=process_request,
max_size=self.config.max_message_bytes,
ping_interval=self.config.ping_interval_s,
ping_timeout=self.config.ping_timeout_s,
ssl=ssl_context,
)
try:
assert self._stop_event is not None
await self._stop_event.wait()
finally:
server.close()
await server.wait_closed()
if socket_path:
with suppress(FileNotFoundError):
Path(socket_path).unlink()
self._server_task = asyncio.create_task(runner())
await self._server_task
@@ -1530,8 +1733,25 @@ class WebSocketChannel(BaseChannel):
t = envelope.get("type")
if t == "new_chat":
new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_new_chat(
envelope,
controls_available=_is_localhost(connection),
),
)
if scope is None:
return
self._webui_workspaces.persist_scope(new_id, scope)
self._attach(connection, new_id)
await self._send_event(connection, "attached", chat_id=new_id)
await self._send_event(
connection,
"session_updated",
chat_id=new_id,
scope="metadata",
workspace_scope=scope.payload(),
)
await self._hydrate_after_subscribe(new_id)
return
if t == "attach":
@@ -1543,6 +1763,32 @@ class WebSocketChannel(BaseChannel):
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
return
if t == "set_workspace_scope":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_set_request(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=_is_localhost(connection),
),
chat_id=cid,
)
if scope is None:
return
self._webui_workspaces.persist_scope(cid, scope)
await self._send_event(
connection,
"session_updated",
chat_id=cid,
scope="metadata",
workspace_scope=scope.payload(),
)
return
if t == "message":
cid = envelope.get("chat_id")
content = envelope.get("content")
@@ -1574,6 +1820,18 @@ class WebSocketChannel(BaseChannel):
if not content.strip() and not media_paths:
await self._send_event(connection, "error", detail="missing content")
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_message(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=_is_localhost(connection),
),
chat_id=cid,
)
if scope is None:
return
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
@@ -1587,6 +1845,8 @@ class WebSocketChannel(BaseChannel):
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets"))
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._webui_workspaces.persist_scope(cid, scope)
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
@@ -1605,6 +1865,25 @@ class WebSocketChannel(BaseChannel):
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def _workspace_scope_or_error(
self,
connection: Any,
resolver: Callable[[], Any],
*,
chat_id: str | None = None,
) -> Any | None:
try:
return resolver()
except WorkspaceScopeError as exc:
await self._send_event(
connection,
"error",
detail="workspace_scope_rejected",
reason=exc.message,
**({"chat_id": chat_id} if chat_id else {}),
)
return None
async def stop(self) -> None:
if not self._running:
return
+138 -1
View File
@@ -720,11 +720,144 @@ def gateway(
_run_gateway(cfg, port=port)
def _load_or_create_desktop_config(config: str | None, workspace: str | None) -> Config:
"""Load the desktop-owned config, creating it on first launch."""
from nanobot.config.loader import (
get_config_path,
load_config,
resolve_config_env_vars,
save_config,
set_config_path,
)
from nanobot.config.schema import Config as NanobotConfig
config_path = Path(config).expanduser().resolve() if config else get_config_path()
set_config_path(config_path)
created = False
if config_path.exists():
try:
loaded = resolve_config_env_vars(load_config(config_path))
except ValueError as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
else:
loaded = NanobotConfig()
created = True
if workspace:
workspace_path = Path(workspace).expanduser()
loaded.agents.defaults.workspace = str(workspace_path)
created = True
if created:
save_config(loaded, config_path)
return loaded
def _configure_desktop_gateway(
config: Config,
*,
webui_port: int,
webui_socket: str | None,
token_issue_secret: str,
) -> None:
"""Force a local WebSocket-only gateway for the desktop app process."""
config.gateway.host = "127.0.0.1"
config.gateway.port = webui_port
config.gateway.heartbeat.enabled = False
extras = dict(getattr(config.channels, "__pydantic_extra__", None) or {})
for name, section in list(extras.items()):
if name == "websocket":
continue
if isinstance(section, dict):
extras[name] = {**section, "enabled": False}
else:
with suppress(Exception):
setattr(section, "enabled", False)
extras[name] = section
websocket_cfg = extras.get("websocket")
if not isinstance(websocket_cfg, dict):
websocket_cfg = {}
websocket_cfg.update(
{
"enabled": True,
"host": "127.0.0.1",
"port": webui_port,
"unix_socket_path": webui_socket or "",
"path": "/",
"token_issue_secret": token_issue_secret,
"websocket_requires_token": True,
"allow_from": ["*"],
"streaming": True,
}
)
extras["websocket"] = websocket_cfg
config.channels.__pydantic_extra__ = extras
@app.command("desktop-gateway", hidden=True)
def desktop_gateway(
webui_port: int = typer.Option(0, "--webui-port", min=0, max=65535),
webui_socket: str | None = typer.Option(None, "--webui-socket", help="Unix socket path for desktop IPC"),
token_issue_secret: str = typer.Option(..., "--token-issue-secret"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Desktop workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Desktop config file"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the private local gateway used by nanobot Desktop."""
if not token_issue_secret.strip():
console.print("[red]Error: --token-issue-secret is required[/red]")
raise typer.Exit(1)
if webui_port <= 0 and not (webui_socket or "").strip():
console.print("[red]Error: --webui-port or --webui-socket is required[/red]")
raise typer.Exit(1)
if verbose:
logger.remove(_log_handler_id)
logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <5}</level> | "
"<cyan>{extra[channel]}</cyan> | "
"<level>{message}</level>"
),
level="DEBUG",
colorize=None,
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
)
cfg = _load_or_create_desktop_config(config, workspace)
_configure_desktop_gateway(
cfg,
webui_port=webui_port,
webui_socket=webui_socket,
token_issue_secret=token_issue_secret,
)
_run_gateway(
cfg,
port=webui_port,
webui_static_dist=False,
webui_runtime_surface="native",
webui_runtime_capabilities={
"can_restart_engine": True,
"can_pick_folder": True,
"can_open_logs": True,
"can_export_diagnostics": True,
},
health_server_enabled=False,
)
def _run_gateway(
config: Config,
*,
port: int | None = None,
open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.tools.cron import CronTool
@@ -957,6 +1090,9 @@ def _run_gateway(
bus,
session_manager=session_manager,
webui_runtime_model_name=_webui_runtime_model_name,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
)
def _pick_heartbeat_target() -> tuple[str, str]:
@@ -1088,8 +1224,9 @@ def _run_gateway(
tasks = [
agent.run(),
channels.start_all(),
_health_server(config.gateway.host, port),
]
if health_server_enabled:
tasks.append(_health_server(config.gateway.host, port))
if open_browser_url:
tasks.append(_open_browser_when_ready())
await asyncio.gather(*tasks)
+15 -1
View File
@@ -295,7 +295,16 @@ class ToolsConfig(Base):
image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
)
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
restrict_to_workspace: bool = False # policy intent: keep tool access inside workspace when possible
webui_allow_local_service_access: bool = Field(
default=True,
validation_alias=AliasChoices(
"webuiAllowLocalServiceAccess",
"webui_allow_local_service_access",
"allowLocalPreviewAccess",
"allow_local_preview_access",
),
) # allow WebUI Full Access shell checks against localhost services; legacy allowLocalPreviewAccess still reads
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
@@ -314,6 +323,11 @@ class Config(BaseSettings):
validation_alias=AliasChoices("modelPresets", "model_presets"),
)
def __init__(self, **values: Any) -> None:
if not type(self).__pydantic_complete__:
_resolve_tool_config_refs()
super().__init__(**values)
@model_validator(mode="after")
def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets:
+34 -9
View File
@@ -15,7 +15,7 @@ from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import (
consume_sse,
consume_sse_with_reasoning,
convert_messages,
convert_tools,
)
@@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream()."""
@@ -62,28 +63,36 @@ class OpenAICodexProvider(LLMProvider):
"tool_choice": tool_choice or "auto",
"parallel_tool_calls": True,
}
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
reasoning_options = _build_reasoning_options(reasoning_effort)
if reasoning_options:
body["reasoning"] = reasoning_options
if tools:
body["tools"] = convert_tools(tools)
try:
try:
content, tool_calls, finish_reason = await _request_codex(
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(
content, tool_calls, finish_reason, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
reasoning_content=reasoning_content,
)
except Exception as e:
response = _codex_error_response(e)
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
@@ -118,7 +127,6 @@ class OpenAICodexProvider(LLMProvider):
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
return await self._call_codex(
messages,
tools,
@@ -126,6 +134,7 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort,
tool_choice,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
)
@@ -139,6 +148,16 @@ def _strip_model_prefix(model: str) -> str:
return model
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
"""Opt in to visible summaries without changing provider-default effort."""
if reasoning_effort and reasoning_effort.lower() == "none":
return {"effort": "none"}
options = {"summary": "auto"}
if reasoning_effort:
options["effort"] = reasoning_effort
return options
def _build_headers(account_id: str, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
@@ -176,8 +195,9 @@ async def _request_codex(
body: dict[str, Any],
verify: bool,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
) -> tuple[str, list[ToolCallRequest], str, str | None]:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
@@ -194,7 +214,12 @@ async def _request_codex(
error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
)
return await consume_sse(response, on_content_delta, on_tool_call_delta)
return await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
)
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
@@ -10,6 +10,7 @@ from nanobot.providers.openai_responses.parsing import (
FINISH_REASON_MAP,
consume_sdk_stream,
consume_sse,
consume_sse_with_reasoning,
iter_sse,
map_finish_reason,
parse_response_output,
@@ -22,6 +23,7 @@ __all__ = [
"split_tool_call_id",
"iter_sse",
"consume_sse",
"consume_sse_with_reasoning",
"consume_sdk_stream",
"map_finish_reason",
"parse_response_output",
+103 -4
View File
@@ -65,10 +65,28 @@ async def consume_sse(
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
"""Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
content, tool_calls, finish_reason, _ = await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
return content, tool_calls, finish_reason
async def consume_sse_with_reasoning(
response: httpx.Response,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, str | None]:
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
reasoning_content: str | None = None
streamed_reasoning = False
async for event in iter_sse(response):
event_type = event.get("type")
@@ -94,6 +112,26 @@ async def consume_sse(
content += delta_text
if on_content_delta and delta_text:
await on_content_delta(delta_text)
elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or ""
if delta_text:
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
await on_reasoning_delta(delta_text)
elif event_type == "response.reasoning_summary_text.done":
text = event.get("text") or ""
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.reasoning_summary_part.done":
part = event.get("part") or {}
text = part.get("text") if part.get("type") == "summary_text" else None
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
@@ -108,7 +146,15 @@ async def consume_sse(
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
arguments = event.get("arguments") or ""
tool_call_buffers[call_id]["arguments"] = arguments
if on_tool_call_delta:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments": str(arguments),
})
elif event_type == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "function_call":
@@ -117,6 +163,13 @@ async def consume_sse(
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(buf.get("name") or item.get("name") or ""),
"arguments": str(args_raw),
})
try:
args = json.loads(args_raw)
except Exception:
@@ -135,14 +188,44 @@ async def consume_sse(
arguments=args,
)
)
elif item.get("type") == "reasoning" and not reasoning_content:
summary = _extract_reasoning_summary_from_output([item])
if summary:
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type == "response.completed":
status = (event.get("response") or {}).get("status")
response_obj = event.get("response") or {}
status = response_obj.get("status")
finish_reason = map_finish_reason(status)
if not reasoning_content:
summary = _extract_reasoning_summary_from_output(response_obj.get("output") or [])
if summary:
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type in {"error", "response.failed"}:
detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
return content, tool_calls, finish_reason
return content, tool_calls, finish_reason, reasoning_content
def _extract_reasoning_summary_from_output(output: Any) -> str | None:
parts: list[str] = []
for item in output or []:
if not isinstance(item, dict):
dump = getattr(item, "model_dump", None)
item = dump() if callable(dump) else vars(item)
if item.get("type") != "reasoning":
continue
for summary in item.get("summary") or []:
if not isinstance(summary, dict):
dump = getattr(summary, "model_dump", None)
summary = dump() if callable(dump) else vars(summary)
if summary.get("type") == "summary_text" and summary.get("text"):
parts.append(summary["text"])
return "".join(parts) or None
def parse_response_output(response: Any) -> LLMResponse:
@@ -230,6 +313,7 @@ async def consume_sdk_stream(
content = ""
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
tool_call_args_emitted: set[str] = set()
finish_reason = "stop"
usage: dict[str, int] = {}
reasoning_content: str | None = None
@@ -272,7 +356,15 @@ async def consume_sdk_stream(
elif event_type == "response.function_call_arguments.done":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = getattr(event, "arguments", "") or ""
arguments = getattr(event, "arguments", "") or ""
tool_call_buffers[call_id]["arguments"] = arguments
if on_tool_call_delta:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(tool_call_buffers[call_id].get("name") or ""),
"arguments": str(arguments),
})
elif event_type == "response.output_item.done":
item = getattr(event, "item", None)
if item and getattr(item, "type", None) == "function_call":
@@ -281,6 +373,13 @@ async def consume_sdk_stream(
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
if on_tool_call_delta and str(call_id) not in tool_call_args_emitted:
tool_call_args_emitted.add(str(call_id))
await on_tool_call_delta({
"call_id": str(call_id),
"name": str(buf.get("name") or getattr(item, "name", None) or ""),
"arguments": str(args_raw),
})
try:
args = json.loads(args_raw)
except Exception:
+27 -3
View File
@@ -42,9 +42,14 @@ def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
return any(addr in net for net in _BLOCKED_NETWORKS)
def validate_url_target(url: str) -> tuple[bool, str]:
def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
"""Validate a URL is safe to fetch: scheme, hostname, and resolved IPs.
``allow_loopback`` is intentionally narrow: it only permits literal
loopback hosts (localhost, 127.0.0.0/8, ::1) when every resolved address is
loopback. It does not allow RFC1918, link-local, metadata, or public DNS
names that happen to resolve to loopback.
Returns (ok, error_message). When ok is True, error_message is empty.
"""
try:
@@ -66,11 +71,16 @@ def validate_url_target(url: str) -> tuple[bool, str]:
except socket.gaierror:
return False, f"Cannot resolve hostname: {hostname}"
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
for info in infos:
try:
addr = ipaddress.ip_address(info[4][0])
except ValueError:
continue
addrs.append(addr)
if allow_loopback and _is_allowed_loopback_target(hostname, addrs):
return True, ""
for addr in addrs:
if _is_private(addr):
return False, f"Blocked: {hostname} resolves to private/internal address {addr}"
@@ -109,11 +119,25 @@ def validate_resolved_url(url: str) -> tuple[bool, str]:
return True, ""
def contains_internal_url(command: str) -> bool:
def contains_internal_url(command: str, *, allow_loopback: bool = False) -> bool:
"""Return True if the command string contains a URL targeting an internal/private address."""
for m in _URL_RE.finditer(command):
url = m.group(0)
ok, _ = validate_url_target(url)
ok, _ = validate_url_target(url, allow_loopback=allow_loopback)
if not ok:
return True
return False
def _is_allowed_loopback_target(
hostname: str,
addrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address],
) -> bool:
if not addrs or not all(addr.is_loopback for addr in addrs):
return False
normalized = hostname.rstrip(".").lower()
if normalized == "localhost":
return True
with suppress(ValueError):
return ipaddress.ip_address(hostname).is_loopback
return False
+430
View File
@@ -0,0 +1,430 @@
"""Workspace access scope and sandbox capability helpers."""
from __future__ import annotations
import os
from contextvars import ContextVar, Token
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
WorkspaceAccessMode = Literal["restricted", "full"]
WORKSPACE_SCOPE_METADATA_KEY = "workspace_scope"
_ACCESS_MODES = {"restricted", "full"}
_TRUE_VALUES = {"1", "true", "yes", "on", "enabled"}
_FALSE_VALUES = {"0", "false", "no", "off", "disabled", ""}
_PROVIDER_LABELS = {
"none": "None",
"unknown": "Unknown system sandbox",
"macos_app_sandbox": "macOS App Sandbox",
"bwrap": "Bubblewrap",
}
_CURRENT_WORKSPACE_SCOPE: ContextVar["WorkspaceScope | None"] = ContextVar(
"nanobot_workspace_scope",
default=None,
)
class WorkspaceScopeError(ValueError):
"""Raised when a requested WebUI workspace scope is invalid."""
status = 400
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(frozen=True)
class WorkspaceSandboxStatus:
"""Resolved workspace sandbox state for runtime display and tooling."""
restrict_to_workspace: bool
workspace_root: str
level: str
enforced: bool
provider: str
provider_label: str
summary: str
def as_dict(self) -> dict[str, object]:
return {
"restrict_to_workspace": self.restrict_to_workspace,
"workspace_root": self.workspace_root,
"level": self.level,
"enforced": self.enforced,
"provider": self.provider,
"provider_label": self.provider_label,
"summary": self.summary,
}
@dataclass(frozen=True)
class WorkspaceScope:
"""Effective project root and access mode for one agent turn."""
project_path: Path
access_mode: WorkspaceAccessMode
restrict_to_workspace: bool
sandbox_status: WorkspaceSandboxStatus
source_channel: str | None = None
@property
def project_name(self) -> str:
return self.project_path.name or str(self.project_path)
def metadata(self) -> dict[str, str]:
return {
"project_path": str(self.project_path),
"access_mode": self.access_mode,
}
def payload(self) -> dict[str, Any]:
return {
**self.metadata(),
"project_name": self.project_name,
"restrict_to_workspace": self.restrict_to_workspace,
"sandbox_status": self.sandbox_status.as_dict(),
}
@dataclass(frozen=True)
class ToolWorkspace:
"""Workspace policy resolved for a tool call."""
project_path: Path | None
restrict_to_workspace: bool
scope: WorkspaceScope | None = None
@property
def allowed_root(self) -> Path | None:
if self.restrict_to_workspace and self.project_path is not None:
return self.project_path
return None
@dataclass(frozen=True)
class WorkspaceScopeResolver:
"""Resolve the effective workspace scope at an agent turn boundary."""
default_workspace: str | Path
default_restrict_to_workspace: bool
scoped_channel: str = "websocket"
@property
def sandbox_status(self) -> WorkspaceSandboxStatus:
return self.default().sandbox_status
def default(self) -> WorkspaceScope:
return default_workspace_scope(
self.default_workspace,
self.default_restrict_to_workspace,
)
def for_message(
self,
msg: Any,
session_metadata: Any,
) -> WorkspaceScope:
return self.for_turn(
channel=getattr(msg, "channel", None),
message_metadata=getattr(msg, "metadata", None),
session_metadata=session_metadata,
)
def for_turn(
self,
*,
channel: str | None,
message_metadata: Any,
session_metadata: Any,
) -> WorkspaceScope:
if channel != self.scoped_channel:
return self.default()
return resolve_effective_workspace_scope(
message_metadata=message_metadata,
session_metadata=session_metadata,
default_workspace=self.default_workspace,
default_restrict_to_workspace=self.default_restrict_to_workspace,
source_channel=channel,
)
def persist_message_scope(self, session: Any, msg: Any) -> None:
if getattr(msg, "channel", None) != self.scoped_channel:
return
metadata = getattr(msg, "metadata", None)
if not isinstance(metadata, dict):
return
raw = metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
if isinstance(raw, dict):
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(raw)
def workspace_sandbox_status(
*,
restrict_to_workspace: bool,
workspace: str | Path,
environ: dict[str, str] | None = None,
) -> WorkspaceSandboxStatus:
"""Return how workspace restriction is enforced in the current host."""
workspace_root = str(Path(workspace).expanduser().resolve(strict=False))
provider = _env_system_provider(environ)
if not restrict_to_workspace:
return WorkspaceSandboxStatus(
restrict_to_workspace=False,
workspace_root=workspace_root,
level="off",
enforced=False,
provider="none",
provider_label=_provider_label("none"),
summary="Workspace restriction is disabled.",
)
if provider:
label = _provider_label(provider)
return WorkspaceSandboxStatus(
restrict_to_workspace=True,
workspace_root=workspace_root,
level="system",
enforced=True,
provider=provider,
provider_label=label,
summary=f"Workspace restriction is system-enforced by {label}.",
)
return WorkspaceSandboxStatus(
restrict_to_workspace=True,
workspace_root=workspace_root,
level="application",
enforced=False,
provider="none",
provider_label=_provider_label("none"),
summary="Workspace restriction uses nanobot application-level guards.",
)
def default_access_mode(restrict_to_workspace: bool) -> WorkspaceAccessMode:
return "restricted" if restrict_to_workspace else "full"
def build_workspace_scope(
project_path: str | Path,
access_mode: str,
*,
source_channel: str | None = None,
) -> WorkspaceScope:
mode = _normalize_access_mode(access_mode)
root = Path(project_path).expanduser().resolve(strict=False)
restrict = mode == "restricted"
return WorkspaceScope(
project_path=root,
access_mode=mode,
restrict_to_workspace=restrict,
sandbox_status=workspace_sandbox_status(
restrict_to_workspace=restrict,
workspace=root,
),
source_channel=source_channel,
)
def default_workspace_scope(
workspace: str | Path,
restrict_to_workspace: bool,
*,
source_channel: str | None = None,
) -> WorkspaceScope:
return build_workspace_scope(
workspace,
default_access_mode(restrict_to_workspace),
source_channel=source_channel,
)
def validate_workspace_scope_payload(
raw: Any,
*,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
"""Validate a client-requested workspace scope."""
if raw is None:
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
if not isinstance(raw, dict):
raise WorkspaceScopeError("workspace_scope must be an object")
raw_path = raw.get("project_path") or raw.get("path")
if raw_path is None or raw_path == "":
raw_path = str(Path(default_workspace).expanduser().resolve(strict=False))
if not isinstance(raw_path, str):
raise WorkspaceScopeError("project_path must be a string")
if "\0" in raw_path:
raise WorkspaceScopeError("project_path contains invalid characters")
project = Path(raw_path).expanduser()
if not project.is_absolute():
raise WorkspaceScopeError("project_path must be absolute")
project = project.resolve(strict=False)
if not project.is_dir():
raise WorkspaceScopeError("project_path must be an existing directory")
raw_mode = raw.get("access_mode")
if raw_mode is None:
raw_mode = default_access_mode(default_restrict_to_workspace)
if not isinstance(raw_mode, str):
raise WorkspaceScopeError("access_mode must be a string")
return build_workspace_scope(project, raw_mode, source_channel=source_channel)
def workspace_scope_from_metadata(
metadata: Any,
*,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
"""Resolve persisted metadata, falling back safely for old or stale sessions."""
if not isinstance(metadata, dict):
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
try:
return validate_workspace_scope_payload(
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
except WorkspaceScopeError:
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=source_channel,
)
def resolve_effective_workspace_scope(
*,
message_metadata: Any,
session_metadata: Any,
default_workspace: str | Path,
default_restrict_to_workspace: bool,
source_channel: str | None = None,
) -> WorkspaceScope:
if isinstance(message_metadata, dict) and WORKSPACE_SCOPE_METADATA_KEY in message_metadata:
return workspace_scope_from_metadata(
message_metadata,
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
return workspace_scope_from_metadata(
session_metadata,
default_workspace=default_workspace,
default_restrict_to_workspace=default_restrict_to_workspace,
source_channel=source_channel,
)
def bind_workspace_scope(scope: WorkspaceScope) -> Token[WorkspaceScope | None]:
return _CURRENT_WORKSPACE_SCOPE.set(scope)
def reset_workspace_scope(token: Token[WorkspaceScope | None]) -> None:
_CURRENT_WORKSPACE_SCOPE.reset(token)
def current_workspace_scope() -> WorkspaceScope | None:
return _CURRENT_WORKSPACE_SCOPE.get()
def current_tool_workspace(
default_workspace: str | Path | None,
*,
restrict_to_workspace: bool = False,
sandbox_restricts_workspace: bool = False,
) -> ToolWorkspace:
"""Return the workspace/access policy for the current tool call."""
scope = current_workspace_scope()
project_path = (
scope.project_path
if scope is not None
else Path(default_workspace).expanduser() if default_workspace is not None else None
)
restrict = (
scope.restrict_to_workspace
if scope is not None
else bool(restrict_to_workspace)
) or sandbox_restricts_workspace
return ToolWorkspace(
project_path=project_path,
restrict_to_workspace=restrict,
scope=scope,
)
def current_scope_allows_loopback(*, enabled: bool) -> bool:
"""Return True when the current WebUI Full Access turn may touch loopback URLs."""
scope = current_workspace_scope()
return bool(
enabled
and scope is not None
and scope.source_channel == "websocket"
and scope.access_mode == "full"
and not scope.restrict_to_workspace
)
def _env_system_provider(environ: dict[str, str] | None = None) -> str | None:
env = environ if environ is not None else os.environ
explicit_provider = env.get("NANOBOT_WORKSPACE_SANDBOX_PROVIDER")
enforced = env.get("NANOBOT_WORKSPACE_SANDBOX_ENFORCED")
compatibility = env.get("NANOBOT_SANDBOX_ENFORCED")
marker = enforced if enforced is not None else compatibility
if marker is None:
return None
normalized_marker = marker.strip().lower()
if normalized_marker in _FALSE_VALUES:
return None
if normalized_marker in _TRUE_VALUES:
return _normalize_provider(explicit_provider)
return _normalize_provider(marker)
def _normalize_provider(value: str | None) -> str:
if not value:
return "unknown"
normalized = value.strip().lower().replace("-", "_").replace(" ", "_")
return normalized or "unknown"
def _provider_label(provider: str) -> str:
if provider in _PROVIDER_LABELS:
return _PROVIDER_LABELS[provider]
return provider.replace("_", " ").title()
def _normalize_access_mode(value: str) -> WorkspaceAccessMode:
mode = value.strip().lower().replace("_", "-")
if mode == "restrict":
mode = "restricted"
if mode == "full-access":
mode = "full"
if mode not in _ACCESS_MODES:
raise WorkspaceScopeError("access_mode must be restricted or full")
return mode # type: ignore[return-value]
+85
View File
@@ -0,0 +1,85 @@
"""Workspace path boundary helpers.
These helpers are application-level guards. They make path decisions
consistent across tools, but they are not a replacement for an OS sandbox.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
WORKSPACE_BOUNDARY_NOTE = (
" (this is a hard policy boundary, not a transient failure; "
"do not retry with shell tricks or alternative tools, and ask "
"the user how to proceed if the resource is genuinely required)"
)
class WorkspaceBoundaryError(PermissionError):
"""Raised when a requested path escapes an allowed workspace boundary."""
def resolve_path(path: str | Path, workspace: str | Path | None = None, *, strict: bool = False) -> Path:
"""Resolve *path*, interpreting relative paths against *workspace* when set."""
candidate = Path(path).expanduser()
if not candidate.is_absolute() and workspace is not None:
candidate = Path(workspace).expanduser() / candidate
return candidate.resolve(strict=strict)
def is_path_within(path: str | Path, root: str | Path) -> bool:
"""Return True when *path* resolves to *root* or a descendant of *root*."""
try:
resolved_path = Path(path).expanduser().resolve(strict=False)
resolved_root = Path(root).expanduser().resolve(strict=False)
resolved_path.relative_to(resolved_root)
return True
except (OSError, RuntimeError, TypeError, ValueError):
return False
def is_path_allowed(path: str | Path, roots: Iterable[str | Path]) -> bool:
"""Return True when *path* is inside any allowed root."""
return any(is_path_within(path, root) for root in roots)
def require_path_within(
path: str | Path,
root: str | Path,
*,
message: str | None = None,
) -> Path:
"""Resolve *path* and require it to be inside *root*."""
resolved = Path(path).expanduser().resolve(strict=False)
if not is_path_within(resolved, root):
raise WorkspaceBoundaryError(
message
or f"Path {path} is outside allowed directory {Path(root).expanduser()}"
+ WORKSPACE_BOUNDARY_NOTE
)
return resolved
def resolve_allowed_path(
path: str | Path,
*,
workspace: str | Path | None = None,
allowed_root: str | Path | None = None,
extra_allowed_roots: Iterable[str | Path] | None = None,
strict: bool = False,
) -> Path:
"""Resolve a path and enforce containment in allowed roots when configured."""
resolved = resolve_path(path, workspace, strict=False)
if allowed_root is None:
return resolve_path(path, workspace, strict=strict) if strict else resolved
roots = [allowed_root, *(extra_allowed_roots or [])]
if not is_path_allowed(resolved, roots):
raise WorkspaceBoundaryError(
f"Path {path} is outside allowed directory {Path(allowed_root).expanduser()}"
+ WORKSPACE_BOUNDARY_NOTE
)
if strict:
return resolve_path(path, workspace, strict=True)
return resolved
+8 -5
View File
@@ -299,6 +299,7 @@ def build_file_edit_end_event(
deleted=deleted,
approximate=False,
binary=(after.binary or after.oversized or after.unreadable) and not counted,
operation="delete" if tracker.before.exists and not after.exists else None,
)
@@ -324,6 +325,7 @@ def build_file_edit_live_event(
*,
added: int,
deleted: int = 0,
operation: str | None = None,
) -> dict[str, Any]:
"""Build an approximate in-progress event while tool-call arguments stream."""
return _event_payload(
@@ -333,6 +335,7 @@ def build_file_edit_live_event(
added=added,
deleted=deleted,
approximate=True,
operation=operation,
)
@@ -454,15 +457,14 @@ class StreamingFileEditTracker:
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
segment = state.arguments[segment_start:segment_end]
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
action_match = re.search(r'"action"\s*:\s*"(replace|add)"', segment)
action = action_match.group(1) if action_match else "replace"
old_text = _extract_json_string_prefix(segment, "old_text") or ""
new_text = _extract_json_string_prefix(segment, "new_text") or ""
added = _text_line_count(new_text) if action in ("replace", "add") else 0
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
delete_file = action == "delete"
deleted = _text_line_count(old_text) if action == "replace" else 0
file_state = state.patch_files.get(raw_path)
if file_state is None:
@@ -475,8 +477,6 @@ class StreamingFileEditTracker:
)
file_state = _StreamingPatchFileState(tracker=tracker)
state.patch_files[raw_path] = file_state
if delete_file and added == 0 and deleted == 0 and file_state.tracker.before.countable:
deleted = _text_line_count(file_state.tracker.before.text or "")
if not file_state.should_emit(added, deleted, now):
continue
file_state.mark_emitted(added, deleted, now)
@@ -916,6 +916,7 @@ def _event_payload(
deleted: int,
approximate: bool,
binary: bool = False,
operation: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"version": 1,
@@ -931,6 +932,8 @@ def _event_payload(
}
if binary:
payload["binary"] = True
if operation:
payload["operation"] = operation
return payload
+4 -4
View File
@@ -124,7 +124,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="playwright",
display_name="Playwright",
category="browser",
description="Local browser inspection and automation with the official Playwright MCP server.",
description="Local browser inspection and automation with Playwright's MCP server.",
docs_url="https://playwright.dev/docs/getting-started-mcp",
transport="stdio",
install_supported=True,
@@ -216,7 +216,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="microsoft-learn",
display_name="Microsoft Learn",
category="docs",
description="Search and fetch official Microsoft Learn documentation through Microsoft's hosted MCP server.",
description="Search and fetch Microsoft Learn documentation through Microsoft's hosted MCP server.",
docs_url="https://learn.microsoft.com/en-us/training/support/mcp",
transport="streamableHttp",
install_supported=True,
@@ -307,7 +307,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="figma",
display_name="Figma",
category="design",
description="Read design context from Figma using the official local Dev Mode MCP server.",
description="Read design context from Figma using the local Dev Mode MCP server.",
docs_url="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server",
transport="streamableHttp",
install_supported=True,
@@ -325,7 +325,7 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
name="github",
display_name="GitHub",
category="code",
description="Repository, issue, and pull request workflows via GitHub's official MCP server.",
description="Repository, issue, and pull request workflows via GitHub's MCP server.",
docs_url="https://github.com/github/github-mcp-server",
transport="stdio",
install_supported=True,
+350 -6
View File
@@ -7,7 +7,9 @@ settings payload shape and the allowlisted config mutations exposed to WebUI.
from __future__ import annotations
import re
from typing import Any
import time
from contextlib import suppress
from typing import Any, Literal
from zoneinfo import ZoneInfo
from nanobot.config.loader import get_config_path, load_config, save_config
@@ -17,8 +19,48 @@ from nanobot.providers.image_generation import (
image_gen_provider_names,
)
from nanobot.providers.registry import PROVIDERS, find_by_name
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.workspaces import (
read_webui_default_access_mode,
write_webui_default_access_mode,
)
QueryParams = dict[str, list[str]]
RuntimeSurface = Literal["browser", "native"]
_RUNTIME_CAPABILITIES = {
"can_restart_engine": False,
"can_pick_folder": False,
"can_open_logs": False,
"can_export_diagnostics": False,
}
_NATIVE_RUNTIME_CAPABILITIES = {
**_RUNTIME_CAPABILITIES,
"can_restart_engine": True,
"can_pick_folder": True,
"can_open_logs": True,
"can_export_diagnostics": True,
}
_BROWSER_RESTART_BEHAVIOR_BY_SECTION = {
"appearance": "none",
"models": "none",
"providers": "none",
"runtime": "engineRestart",
"browser": "engineRestart",
"image": "engineRestart",
"apps": "engineRestart",
"advanced": "appRestart",
}
_NATIVE_RESTART_BEHAVIOR_BY_SECTION = {
**_BROWSER_RESTART_BEHAVIOR_BY_SECTION,
"runtime": "engineRestart",
"browser": "engineRestart",
"image": "engineRestart",
"apps": "engineRestart",
}
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
@@ -55,6 +97,70 @@ class WebUISettingsError(ValueError):
self.status = status
def _normalize_surface(surface: str | None) -> RuntimeSurface:
return "native" if surface in {"native", "desktop"} else "browser"
def runtime_capabilities(
surface: str | None = "browser",
overrides: dict[str, Any] | None = None,
) -> dict[str, bool]:
"""Return the capability flags exposed to the WebUI runtime."""
base = (
_NATIVE_RUNTIME_CAPABILITIES
if _normalize_surface(surface) == "native"
else _RUNTIME_CAPABILITIES
)
result = dict(base)
for key, value in (overrides or {}).items():
if key in result:
result[key] = bool(value)
return result
def restart_behavior_by_section(surface: str | None = "browser") -> dict[str, str]:
return dict(
_NATIVE_RESTART_BEHAVIOR_BY_SECTION
if _normalize_surface(surface) == "native"
else _BROWSER_RESTART_BEHAVIOR_BY_SECTION
)
def decorate_settings_payload(
payload: dict[str, Any],
*,
surface: str | None = "browser",
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Attach runtime-surface metadata without changing the core settings shape."""
surface_value = _normalize_surface(surface)
sections = restart_required_sections
if sections is None:
raw_sections = payload.get("restart_required_sections") or []
sections = [str(section) for section in raw_sections if isinstance(section, str)]
sections = sorted(dict.fromkeys(sections))
result = dict(payload)
result["surface"] = surface_value
result["runtime_surface"] = surface_value
result["runtime_capabilities"] = runtime_capabilities(
surface_value,
runtime_capability_overrides,
)
result["restart_behavior_by_section"] = restart_behavior_by_section(surface_value)
result["restart_required_sections"] = sections
if sections:
result["requires_restart"] = True
else:
result["requires_restart"] = bool(result.get("requires_restart", False))
result["apply_state"] = apply_state or {
"status": "pending" if result["requires_restart"] else "idle",
"sections": sections,
}
return result
def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
@@ -83,9 +189,57 @@ def _provider_requires_api_key(spec: Any) -> bool:
return True
def _oauth_provider_status(spec: Any) -> dict[str, Any]:
if not getattr(spec, "is_oauth", False):
return {"configured": False, "account": None, "expires_at": None, "login_supported": False}
if spec.name == "openai_codex":
try:
from oauth_cli_kit import get_token as get_codex_token
except Exception:
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": False,
}
token = None
with suppress(Exception):
token = get_codex_token()
expires_at = getattr(token, "expires", None) if token else None
return {
"configured": bool(token and token.access),
"account": getattr(token, "account_id", None) if token else None,
"expires_at": expires_at,
"login_supported": True,
}
if spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import get_github_copilot_login_status
except Exception:
return {
"configured": False,
"account": None,
"expires_at": None,
"login_supported": False,
}
token = None
with suppress(Exception):
token = get_github_copilot_login_status()
return {
"configured": bool(token and token.access and token.expires > int(time.time() * 1000)),
"account": getattr(token, "account_id", None) if token else None,
"expires_at": getattr(token, "expires", None) if token else None,
"login_supported": True,
}
return {"configured": False, "account": None, "expires_at": None, "login_supported": False}
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if spec.is_oauth:
return True
return bool(_oauth_provider_status(spec)["configured"])
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
@@ -144,6 +298,7 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
"name": name,
"label": spec.label if spec is not None else name,
"configured": configured,
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
"api_key_hint": _mask_secret_hint(
getattr(provider_config, "api_key", None)
),
@@ -156,7 +311,14 @@ def _image_generation_provider_rows(config: Any) -> list[dict[str, Any]]:
return rows
def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
def settings_payload(
*,
requires_restart: bool = False,
surface: str | None = "browser",
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default"
@@ -179,17 +341,27 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None or spec.is_oauth:
if provider_config is None:
continue
oauth_status = _oauth_provider_status(spec) if spec.is_oauth else None
row = {
"name": spec.name,
"label": spec.label,
"configured": _provider_configured_for_settings(spec, provider_config),
"configured": (
bool(oauth_status["configured"])
if oauth_status is not None
else _provider_configured_for_settings(spec, provider_config)
),
"auth_type": "oauth" if spec.is_oauth else "api_key",
"api_key_required": _provider_requires_api_key(spec),
"api_key_hint": _mask_secret_hint(provider_config.api_key),
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
}
if oauth_status is not None:
row["oauth_account"] = oauth_status["account"]
row["oauth_expires_at"] = oauth_status["expires_at"]
row["oauth_login_supported"] = oauth_status["login_supported"]
if spec.name == "openai":
row["api_type"] = provider_config.api_type
providers.append(row)
@@ -241,7 +413,11 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
)
exec_config = config.tools.exec
return {
sandbox_status = workspace_sandbox_status(
restrict_to_workspace=config.tools.restrict_to_workspace,
workspace=config.workspace_path,
)
payload = {
"agent": {
"model": effective_preset.model,
"provider": selected_provider,
@@ -312,6 +488,11 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
},
"advanced": {
"restrict_to_workspace": config.tools.restrict_to_workspace,
"workspace_sandbox": sandbox_status.as_dict(),
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
"webui_default_access_mode": read_webui_default_access_mode(),
"private_service_protection_enabled": True,
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
"mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable,
@@ -320,6 +501,13 @@ def settings_payload(*, requires_restart: bool = False) -> dict[str, Any]:
},
"requires_restart": requires_restart,
}
return decorate_settings_payload(
payload,
surface=surface,
runtime_capability_overrides=runtime_capability_overrides,
restart_required_sections=restart_required_sections,
apply_state=apply_state,
)
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
@@ -444,6 +632,54 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
return settings_payload()
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name or name == "default":
raise WebUISettingsError("model configuration is required")
config = load_config()
preset = config.model_presets.get(name)
if preset is None:
raise WebUISettingsError("unknown model configuration")
changed = False
label = _query_first_alias(query, "label", "displayName")
if label is not None:
label = label.strip()
if not label:
raise WebUISettingsError("label is required")
if preset.label != label:
preset.label = label
changed = True
model = _query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
raise WebUISettingsError("model is required")
if preset.model != model:
preset.model = model
changed = True
provider = _query_first(query, "provider")
if provider is not None:
provider = provider.strip()
if not provider:
raise WebUISettingsError("provider is required")
_validate_configured_provider(config, provider)
if preset.provider != provider:
preset.provider = provider
changed = True
if config.agents.defaults.model_preset != name:
config.agents.defaults.model_preset = name
changed = True
if changed:
save_config(config)
return settings_payload()
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
@@ -495,6 +731,114 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required)
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None or not spec.is_oauth:
raise WebUISettingsError("unknown OAuth provider")
if spec.name == "openai_codex":
try:
from oauth_cli_kit import get_token, login_oauth_interactive
except ImportError:
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
token = None
with suppress(Exception):
token = get_token()
if not (token and token.access):
messages: list[str] = []
token = login_oauth_interactive(
print_fn=lambda message: messages.append(str(message)),
prompt_fn=lambda _prompt: "",
)
if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload()
if spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import (
get_github_copilot_login_status,
login_github_copilot,
)
except ImportError:
raise WebUISettingsError("GitHub Copilot OAuth support is unavailable", status=500) from None
token = get_github_copilot_login_status()
if not token:
token = login_github_copilot(print_fn=lambda _message: None)
if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload()
raise WebUISettingsError("OAuth login is not supported for this provider")
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
spec = find_by_name(provider_name)
if spec is None or not spec.is_oauth:
raise WebUISettingsError("unknown OAuth provider")
if spec.name == "openai_codex":
try:
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
except ImportError:
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
elif spec.name == "github_copilot":
try:
from nanobot.providers.github_copilot_provider import get_storage
except ImportError:
raise WebUISettingsError("GitHub Copilot OAuth support is unavailable", status=500) from None
token_path = get_storage().get_token_path()
else:
raise WebUISettingsError("OAuth logout is not supported for this provider")
for path in (token_path, token_path.with_suffix(".lock")):
with suppress(FileNotFoundError):
path.unlink()
return settings_payload()
def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
raw_allow = (
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
)
raw_default_access_mode = _query_first_alias(query, "webui_default_access_mode", "webuiDefaultAccessMode")
if raw_allow is None and raw_default_access_mode is None:
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
config = load_config()
changed = False
if raw_allow is not None:
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
if config.tools.webui_allow_local_service_access != webui_allow_local_service_access:
config.tools.webui_allow_local_service_access = webui_allow_local_service_access
changed = True
if changed:
save_config(config)
if raw_default_access_mode is not None:
default_access_mode = raw_default_access_mode.strip().lower()
if default_access_mode == "restricted":
default_access_mode = "default"
if default_access_mode not in {"default", "full"}:
raise WebUISettingsError("webui_default_access_mode must be default or full")
try:
write_webui_default_access_mode(default_access_mode)
except ValueError as exc:
raise WebUISettingsError(str(exc)) from exc
return settings_payload(requires_restart=changed)
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
+4 -1
View File
@@ -38,6 +38,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"pinned_keys": [],
"archived_keys": [],
"title_overrides": {},
"project_name_overrides": {},
"tags_by_key": {},
"collapsed_groups": {},
"view": {
@@ -136,6 +137,9 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
state["project_name_overrides"] = _clean_title_overrides(
raw.get("project_name_overrides")
)
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["view"] = _clean_view(raw.get("view"))
@@ -190,4 +194,3 @@ def write_webui_sidebar_state(raw: dict[str, Any]) -> dict[str, Any]:
finally:
os.close(dir_fd)
return state
+166 -19
View File
@@ -28,6 +28,11 @@ _INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".webp",
".gif",
})
_FILE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({
"write_file",
"edit_file",
"apply_patch",
})
def rewrite_local_markdown_images(
@@ -200,6 +205,19 @@ def _tool_event_key(event: dict[str, Any]) -> str:
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
def _tool_event_file_edit_key(event: dict[str, Any]) -> str | None:
call_id = event.get("call_id")
if not isinstance(call_id, str) or not call_id:
return None
name = event.get("name")
if not isinstance(name, str) or not name:
fn = event.get("function")
name = fn.get("name") if isinstance(fn, dict) else ""
if not isinstance(name, str) or name not in _FILE_EDIT_TOOL_NAMES:
return None
return f"{call_id}|{name}"
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(previous, list) or not previous:
return incoming
@@ -222,6 +240,87 @@ def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[di
return merged
def _file_edit_key(edit: dict[str, Any]) -> str:
call_id = str(edit.get("call_id") or "")
tool = str(edit.get("tool") or "")
if call_id:
return f"{call_id}|{tool}"
return f"{tool}|{edit.get('path') or ''}"
def _message_has_file_edit_for_tool_event(
message: dict[str, Any],
event: dict[str, Any],
) -> bool:
key = _tool_event_file_edit_key(event)
if not key:
return False
edits = message.get("fileEdits")
if not isinstance(edits, list):
return False
return any(isinstance(edit, dict) and _file_edit_key(edit) == key for edit in edits)
def _filter_covered_file_edit_tool_events(
messages: list[dict[str, Any]],
events: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not events:
return events
return [
event
for event in events
if not any(_message_has_file_edit_for_tool_event(message, event) for message in messages)
]
def _strip_covered_file_edit_tool_hints(
message: dict[str, Any],
edits: list[dict[str, Any]],
) -> dict[str, Any]:
incoming_keys = {
_file_edit_key(edit)
for edit in edits
if isinstance(edit, dict)
}
events = message.get("toolEvents")
if not incoming_keys or not isinstance(events, list):
return message
kept_events: list[dict[str, Any]] = []
removed_trace_lines: set[str] = set()
changed = False
for event in events:
if not isinstance(event, dict):
continue
key = _tool_event_file_edit_key(event)
if key and key in incoming_keys:
changed = True
removed_trace_lines.update(tool_trace_lines_from_events([event]))
continue
kept_events.append(event)
if not changed:
return message
raw_traces = message.get("traces")
if isinstance(raw_traces, list):
previous_traces = [trace for trace in raw_traces if isinstance(trace, str)]
else:
content = message.get("content")
previous_traces = [content] if isinstance(content, str) and content else []
next_traces = [trace for trace in previous_traces if trace not in removed_trace_lines]
next_message = {
**message,
"traces": next_traces,
"content": next_traces[-1] if next_traces else "",
}
if kept_events:
next_message["toolEvents"] = kept_events
else:
next_message.pop("toolEvents", None)
return next_message
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
@@ -343,6 +442,40 @@ def replay_transcript_to_ui_messages(
return None
return str(last.get("id"))
def demote_interrupted_assistant(segment: str) -> None:
nonlocal buffer_message_id, buffer_parts
for i in range(len(messages) - 1, -1, -1):
candidate = messages[i]
if candidate.get("role") == "user":
break
content = candidate.get("content")
if (
candidate.get("role") != "assistant"
or candidate.get("kind") == "trace"
or not candidate.get("isStreaming")
or not isinstance(content, str)
or not content.strip()
or candidate.get("media")
):
continue
reasoning_parts = [
part
for part in (candidate.get("reasoning"), content)
if isinstance(part, str) and part.strip()
]
messages[i] = {
**candidate,
"content": "",
"reasoning": "\n\n".join(reasoning_parts),
"reasoningStreaming": False,
"isStreaming": False,
"activitySegmentId": candidate.get("activitySegmentId") or segment,
}
if buffer_message_id == candidate.get("id"):
buffer_message_id = None
buffer_parts = []
return
def close_reasoning(prev: list[dict[str, Any]]) -> None:
for i in range(len(prev) - 1, -1, -1):
if prev[i].get("reasoningStreaming"):
@@ -404,13 +537,6 @@ def replay_transcript_to_ui_messages(
active_activity_segment_id = None
active_file_edit_segment_id = None
def _file_edit_key(edit: dict[str, Any]) -> str:
call_id = str(edit.get("call_id") or "")
tool = str(edit.get("tool") or "")
if call_id:
return f"{call_id}|{tool}"
return f"{tool}|{edit.get('path') or ''}"
def find_file_edit_trace_index(
segment: str | None,
edits: list[dict[str, Any]],
@@ -420,16 +546,23 @@ def replay_transcript_to_ui_messages(
candidate = messages[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") != "trace" or not candidate.get("fileEdits"):
if candidate.get("kind") != "trace":
continue
if segment and candidate.get("activitySegmentId") == segment:
return i
existing_edits = candidate.get("fileEdits")
if not isinstance(existing_edits, list):
continue
for existing in existing_edits:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
return i
if isinstance(existing_edits, list):
for existing in existing_edits:
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
return i
existing_tool_events = candidate.get("toolEvents")
if isinstance(existing_tool_events, list):
for event in existing_tool_events:
if not isinstance(event, dict):
continue
key = _tool_event_file_edit_key(event)
if key and key in incoming_keys:
return i
return None
def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
@@ -437,11 +570,16 @@ def replay_transcript_to_ui_messages(
if not edits:
return
segment = active_file_edit_segment_id
if not segment:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
demote_interrupted_assistant(segment)
target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None:
last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment
last = _strip_covered_file_edit_tool_hints(last, edits)
else:
if not segment:
segment = _new_activity_segment(activate=False)
@@ -620,12 +758,21 @@ def replay_transcript_to_ui_messages(
continue
if kind in ("tool_hint", "progress"):
structured_events = _normalize_tool_events(rec.get("tool_events"))
structured = tool_trace_lines_from_events(rec.get("tool_events"))
visible_structured_events = _filter_covered_file_edit_tool_events(messages, structured_events)
structured = tool_trace_lines_from_events(visible_structured_events)
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if structured:
trace_lines = structured
elif structured_events:
trace_lines = []
elif isinstance(text, str) and text:
trace_lines = [text]
else:
trace_lines = []
if not trace_lines:
continue
segment = _ensure_activity_segment()
demote_interrupted_assistant(segment)
last = messages[-1] if messages else None
if (
last
@@ -636,7 +783,7 @@ def replay_transcript_to_ui_messages(
prev_traces = list(last.get("traces") or [last.get("content")])
if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
if not added and not structured_events:
if not added and not visible_structured_events:
continue
else:
merged_traces = prev_traces + trace_lines
@@ -644,8 +791,8 @@ def replay_transcript_to_ui_messages(
**last,
"traces": merged_traces,
"content": merged_traces[-1],
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
if structured_events
"toolEvents": _merge_tool_events(last.get("toolEvents"), visible_structured_events)
if visible_structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
}
@@ -658,7 +805,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
**({"toolEvents": structured_events} if structured_events else {}),
**({"toolEvents": visible_structured_events} if visible_structured_events else {}),
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
+283
View File
@@ -0,0 +1,283 @@
"""Persisted WebUI project workspace state."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.security.workspace_access import (
WORKSPACE_SCOPE_METADATA_KEY,
WorkspaceScope,
WorkspaceScopeError,
build_workspace_scope,
default_workspace_scope,
validate_workspace_scope_payload,
)
WEBUI_WORKSPACE_STATE_SCHEMA_VERSION = 1
_MAX_STATE_FILE_BYTES = 128 * 1024
_DEFAULT_ACCESS_MODES = {"default", "full"}
_LEGACY_RESTRICTED_DEFAULT_ACCESS_MODE = "restricted"
_WEBUI_SCOPE_CHANNEL = "websocket"
def webui_workspace_state_path() -> Path:
return get_webui_dir() / "workspace-state.json"
def default_webui_workspace_state() -> dict[str, Any]:
return {
"schema_version": WEBUI_WORKSPACE_STATE_SCHEMA_VERSION,
"default_access_mode": "default",
"updated_at": None,
}
def normalize_webui_workspace_state(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raw = {}
state = default_webui_workspace_state()
updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
default_access_mode = raw.get("default_access_mode")
if default_access_mode in _DEFAULT_ACCESS_MODES:
state["default_access_mode"] = default_access_mode
return state
def read_webui_workspace_state() -> dict[str, Any]:
path = webui_workspace_state_path()
if not path.is_file():
return default_webui_workspace_state()
try:
if path.stat().st_size > _MAX_STATE_FILE_BYTES:
logger.warning("webui workspace state too large, ignoring: {}", path)
return default_webui_workspace_state()
with open(path, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("read webui workspace state failed {}: {}", path, e)
return default_webui_workspace_state()
return normalize_webui_workspace_state(raw)
def write_webui_workspace_state(raw: dict[str, Any]) -> dict[str, Any]:
state = normalize_webui_workspace_state(raw)
state["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
encoded = json.dumps(
state,
ensure_ascii=False,
indent=2,
sort_keys=True,
).encode("utf-8")
if len(encoded) > _MAX_STATE_FILE_BYTES:
raise ValueError("workspace state is too large")
path = webui_workspace_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "wb") as f:
f.write(encoded)
f.write(b"\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
try:
dir_fd = os.open(path.parent, os.O_RDONLY)
except OSError:
return state
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
return state
def read_webui_default_access_mode() -> str:
state = read_webui_workspace_state()
mode = state.get("default_access_mode")
return mode if mode in _DEFAULT_ACCESS_MODES else "default"
def write_webui_default_access_mode(mode: str) -> bool:
if mode == _LEGACY_RESTRICTED_DEFAULT_ACCESS_MODE:
mode = "default"
if mode not in _DEFAULT_ACCESS_MODES:
raise ValueError("default access mode must be default or full")
state = read_webui_workspace_state()
changed = state.get("default_access_mode") != mode
if changed:
state["default_access_mode"] = mode
write_webui_workspace_state(state)
return changed
def default_scope_for_webui(
default_workspace: Path,
default_restrict_to_workspace: bool,
) -> WorkspaceScope:
mode = read_webui_default_access_mode()
if mode == "default":
return default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
return build_workspace_scope(default_workspace, mode, source_channel=_WEBUI_SCOPE_CHANNEL)
def workspaces_payload(
*,
default_workspace: Path,
default_restrict_to_workspace: bool,
controls_available: bool,
) -> dict[str, Any]:
default_access_mode = read_webui_default_access_mode()
default_scope = (
default_workspace_scope(
default_workspace,
default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
if default_access_mode == "default"
else build_workspace_scope(default_workspace, default_access_mode, source_channel=_WEBUI_SCOPE_CHANNEL)
)
return {
"schema_version": WEBUI_WORKSPACE_STATE_SCHEMA_VERSION,
"default_access_mode": default_access_mode,
"default_scope": default_scope.payload(),
"controls": {
"can_change_project": controls_available,
"can_use_full_access": controls_available,
},
}
class WebUIWorkspaceController:
"""Own WebUI project scope persistence and validation."""
def __init__(
self,
*,
session_manager: Any | None,
default_workspace: Path,
default_restrict_to_workspace: bool,
) -> None:
self._sessions = session_manager
self._default_workspace = default_workspace
self._default_restrict_to_workspace = default_restrict_to_workspace
def default_scope(self) -> WorkspaceScope:
return default_scope_for_webui(
self._default_workspace,
self._default_restrict_to_workspace,
)
def scope_for_session_key(self, session_key: str) -> WorkspaceScope:
if self._sessions is None:
return self.default_scope()
data = self._sessions.read_session_file(session_key)
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
if not isinstance(metadata, dict) or WORKSPACE_SCOPE_METADATA_KEY not in metadata:
return self.default_scope()
try:
return validate_workspace_scope_payload(
metadata.get(WORKSPACE_SCOPE_METADATA_KEY),
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
except WorkspaceScopeError:
return self.default_scope()
def payload(self, *, controls_available: bool) -> dict[str, Any]:
return workspaces_payload(
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
controls_available=controls_available,
)
def scope_from_envelope(
self,
envelope: dict[str, Any],
*,
session_key: str | None,
controls_available: bool,
) -> WorkspaceScope:
raw = envelope.get(WORKSPACE_SCOPE_METADATA_KEY)
if raw is None and session_key:
scope = self.scope_for_session_key(session_key)
elif raw is None:
scope = self.default_scope()
else:
scope = validate_workspace_scope_payload(
raw,
default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace,
source_channel=_WEBUI_SCOPE_CHANNEL,
)
if not controls_available and scope.metadata() != self.default_scope().metadata():
raise WorkspaceScopeError("workspace controls are localhost-only", status=403)
return scope
def scope_for_new_chat(
self,
envelope: dict[str, Any],
*,
controls_available: bool,
) -> WorkspaceScope:
return self.scope_from_envelope(
envelope,
session_key=None,
controls_available=controls_available,
)
def scope_for_set_request(
self,
envelope: dict[str, Any],
*,
chat_id: str,
chat_running: bool,
controls_available: bool,
) -> WorkspaceScope:
if chat_running:
raise WorkspaceScopeError("chat_running", status=409)
return self.scope_from_envelope(
envelope,
session_key=f"websocket:{chat_id}",
controls_available=controls_available,
)
def scope_for_message(
self,
envelope: dict[str, Any],
*,
chat_id: str,
chat_running: bool,
controls_available: bool,
) -> WorkspaceScope:
scope = self.scope_from_envelope(
envelope,
session_key=f"websocket:{chat_id}",
controls_available=controls_available,
)
if (
WORKSPACE_SCOPE_METADATA_KEY in envelope
and chat_running
and scope.metadata() != self.scope_for_session_key(f"websocket:{chat_id}").metadata()
):
raise WorkspaceScopeError("chat_running", status=409)
return scope
def persist_scope(self, chat_id: str, scope: WorkspaceScope) -> None:
if self._sessions is not None:
session = self._sessions.get_or_create(f"websocket:{chat_id}")
session.metadata["webui"] = True
session.metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._sessions.save(session)