feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+23 -2
View File
@@ -10,6 +10,7 @@ from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
@@ -43,6 +44,7 @@ class ExecSessionInfo:
idle_s: float
remaining_s: float
returncode: int | None
owner_session_key: str | None = None
class _ExecSession:
@@ -54,11 +56,13 @@ class _ExecSession:
command: str,
cwd: str,
timeout: int | None,
owner_session_key: str | None = None,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.owner_session_key = owner_session_key
self.started_at = time.monotonic()
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
@@ -175,6 +179,7 @@ class ExecSessionManager:
login: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> tuple[str, _SessionPoll]:
async with self._lock:
await self._cleanup_locked()
@@ -188,6 +193,7 @@ class ExecSessionManager:
command=command,
cwd=cwd,
timeout=timeout,
owner_session_key=owner_session_key,
)
self._sessions[session_id] = session
@@ -206,12 +212,19 @@ class ExecSessionManager:
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None = None,
) -> _SessionPoll:
async with self._lock:
await self._cleanup_locked()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if (
owner_session_key
and session.owner_session_key
and session.owner_session_key != owner_session_key
):
raise KeyError(session_id)
if chars:
error = await session.write(chars)
@@ -236,7 +249,7 @@ class ExecSessionManager:
self._sessions.pop(session_id, None)
return poll
async def list(self) -> list[ExecSessionInfo]:
async def list(self, *, owner_session_key: str | None = None) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
@@ -249,8 +262,12 @@ class ExecSessionManager:
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
owner_session_key=session.owner_session_key,
)
for session_id, session in sorted(self._sessions.items())
if not owner_session_key
or not session.owner_session_key
or session.owner_session_key == owner_session_key
]
async def _cleanup_locked(self) -> None:
@@ -477,6 +494,7 @@ class WriteStdinTool(Tool):
terminate=terminate,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=output_limit,
owner_session_key=current_request_session_key(),
)
return format_session_poll(session_id, poll)
except KeyError:
@@ -510,6 +528,7 @@ class WriteStdinTool(Tool):
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
owner_session_key=current_request_session_key(),
)
first = False
if poll.output:
@@ -573,7 +592,9 @@ class ListExecSessionsTool(Tool):
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list()
sessions = await self._manager.list(
owner_session_key=current_request_session_key(),
)
if not sessions:
return "No active exec sessions."
lines = []