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:
@@ -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
@@ -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
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,9 @@ class RuntimeState(Protocol):
|
||||
@property
|
||||
def exec_config(self) -> Any: ...
|
||||
|
||||
@property
|
||||
def workspace_sandbox(self) -> Any: ...
|
||||
|
||||
@property
|
||||
def subagents(self) -> Any: ...
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user