mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
* fix(gateway): recover degraded WebSocket listener * test(websocket): model listener readiness in startup stub * fix(tui): keep launcher alive during gateway recovery * test(websocket): colocate listener lifecycle coverage
539 lines
20 KiB
Python
539 lines
20 KiB
Python
"""Launch the TypeScript terminal client against the local gateway."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
from nanobot import __version__
|
|
from nanobot.cli.process_identity import named_executable
|
|
from nanobot.cli.runtime_config import _model_display
|
|
from nanobot.cli.webui_support import (
|
|
_gateway_health_ready,
|
|
_gateway_instance_command,
|
|
_host_for_local_browser,
|
|
_webui_endpoint_reachable,
|
|
)
|
|
from nanobot.config.paths import get_data_dir
|
|
from nanobot.config.schema import Config
|
|
from nanobot.webui.session_identity import is_webui_session_key, webui_chat_id
|
|
|
|
if TYPE_CHECKING:
|
|
from nanobot.gateway import GatewayClientLease
|
|
|
|
|
|
class TuiUnavailableError(RuntimeError):
|
|
"""Raised when the native TypeScript TUI cannot run on this installation."""
|
|
|
|
|
|
class TuiSessionError(ValueError):
|
|
"""Raised when a session selector cannot be opened by the native TUI."""
|
|
|
|
|
|
_TUI_RELEASE_FILES = (
|
|
"THIRD_PARTY_NOTICES.txt",
|
|
"RELINKING.md",
|
|
"SOURCE_OFFER.md",
|
|
"LICENSE",
|
|
"BUN-1.3.13-LICENSE.md",
|
|
"LGPL-2.0.txt",
|
|
"LGPL-2.1.txt",
|
|
"nanobot-tui-source.tar.gz",
|
|
)
|
|
_TUI_RELEASE_LIMITS = {
|
|
"THIRD_PARTY_NOTICES.txt": 4 * 1024 * 1024,
|
|
"RELINKING.md": 256 * 1024,
|
|
"SOURCE_OFFER.md": 256 * 1024,
|
|
"LICENSE": 256 * 1024,
|
|
"BUN-1.3.13-LICENSE.md": 1024 * 1024,
|
|
"LGPL-2.0.txt": 256 * 1024,
|
|
"LGPL-2.1.txt": 256 * 1024,
|
|
"nanobot-tui-source.tar.gz": 20 * 1024 * 1024,
|
|
"MANIFEST.sha256": 64 * 1024,
|
|
}
|
|
# Keep in sync with TUI_DETACH_EXIT_CODE in tui/src/index.ts.
|
|
_TUI_DETACH_EXIT_CODE = 90
|
|
_GATEWAY_READY_TIMEOUT_S = 20.0
|
|
_GATEWAY_READY_POLL_S = 0.1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _GatewayHandle:
|
|
base_url: str
|
|
lease: GatewayClientLease | None = None
|
|
|
|
|
|
def launch_tui(
|
|
config: Config,
|
|
*,
|
|
config_path: Path,
|
|
workspace_override: str | None,
|
|
session_id: str | None,
|
|
theme: str,
|
|
) -> int:
|
|
"""Run the native TUI against the shared local gateway."""
|
|
chat_id = _initial_tui_chat_id(session_id)
|
|
tui_workspace = _initial_tui_workspace(workspace_override)
|
|
command = _resolve_tui_command()
|
|
base_url, bootstrap_secret = _tui_gateway_connection(config)
|
|
gateway: _GatewayHandle | None = None
|
|
process: subprocess.Popen[Any] | None = None
|
|
try:
|
|
env = os.environ.copy()
|
|
env.pop("NANOBOT_TUI_WS_URL", None)
|
|
env.pop("NANOBOT_TUI_API_TOKEN", None)
|
|
env.update(
|
|
{
|
|
"NANOBOT_TUI_BOOTSTRAP_URL": f"{base_url}/webui/bootstrap",
|
|
"NANOBOT_TUI_API_URL": base_url,
|
|
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
|
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
|
"NANOBOT_TUI_WORKSPACE": str(tui_workspace),
|
|
"NANOBOT_TUI_VERSION": __version__,
|
|
"NANOBOT_TUI_ACCESS": (
|
|
"workspace access" if config.tools.restrict_to_workspace else "full access"
|
|
),
|
|
"NANOBOT_TUI_THEME": theme,
|
|
"NANOBOT_TUI_GATEWAY_STOP_COMMAND": _gateway_instance_command(
|
|
"stop",
|
|
config_path=config_path,
|
|
workspace=workspace_override,
|
|
),
|
|
}
|
|
)
|
|
if bootstrap_secret:
|
|
env["NANOBOT_TUI_BOOTSTRAP_SECRET"] = bootstrap_secret
|
|
else:
|
|
env.pop("NANOBOT_TUI_BOOTSTRAP_SECRET", None)
|
|
if chat_id:
|
|
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
|
else:
|
|
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
|
try:
|
|
process = subprocess.Popen(command, env=env)
|
|
except OSError as exc:
|
|
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
|
|
gateway = _ensure_gateway(
|
|
config,
|
|
config_path=config_path,
|
|
workspace_override=workspace_override,
|
|
wait_until_ready=False,
|
|
)
|
|
exit_code = process.wait()
|
|
if exit_code == _TUI_DETACH_EXIT_CODE:
|
|
lease = gateway.lease
|
|
if lease is not None:
|
|
lease.mark_persistent()
|
|
return 0
|
|
return exit_code
|
|
except BaseException:
|
|
if process is not None and process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
raise
|
|
finally:
|
|
lease = getattr(gateway, "lease", None) if gateway is not None else None
|
|
if lease is not None:
|
|
# Returning to the shell must not wait for process termination. The
|
|
# gateway's client monitor observes the released last lease and owns
|
|
# the orderly on-demand shutdown.
|
|
lease.release(wait_for_stop=False)
|
|
|
|
|
|
def _resolve_tui_command() -> list[str]:
|
|
override = os.environ.get("NANOBOT_TUI_BIN", "").strip()
|
|
if override:
|
|
executable = Path(override).expanduser().resolve(strict=False)
|
|
if not executable.is_file():
|
|
raise TuiUnavailableError(f"NANOBOT_TUI_BIN does not exist: {executable}")
|
|
return [str(executable)]
|
|
|
|
suffix = ".exe" if os.name == "nt" else ""
|
|
system = {"Windows": "win32", "Darwin": "darwin", "Linux": "linux"}.get(
|
|
platform.system(),
|
|
platform.system().lower(),
|
|
)
|
|
machine = {"x86_64": "x64", "AMD64": "x64", "aarch64": "arm64"}.get(
|
|
platform.machine(),
|
|
platform.machine().lower(),
|
|
)
|
|
if system == "win32" and machine == "arm64":
|
|
raise TuiUnavailableError(
|
|
"the native TUI is not available on Windows ARM64 because Bun FFI is disabled "
|
|
"on that platform; use the classic prompt until the upstream runtime supports it"
|
|
)
|
|
asset = f"nanobot-tui-{system}-{machine}{suffix}"
|
|
source_dir = _source_checkout_tui_dir()
|
|
if source_dir is not None:
|
|
bun = shutil.which("bun")
|
|
if not bun:
|
|
raise TuiUnavailableError(
|
|
"this source checkout requires Bun to run its matching TUI; "
|
|
"install Bun, then run `nanobot agent` again"
|
|
)
|
|
return _resolve_source_tui_command(source_dir, bun)
|
|
|
|
packaged = Path(__file__).resolve().parents[1] / "tui" / "bin" / asset
|
|
if packaged.is_file():
|
|
return [str(packaged)]
|
|
|
|
downloaded = _download_release_tui(asset)
|
|
if downloaded is not None:
|
|
return [str(downloaded)]
|
|
|
|
raise TuiUnavailableError(
|
|
f"no native TUI archive is published for nanobot {__version__} on this platform; "
|
|
"current source installs must be editable and keep their checkout and Bun available, "
|
|
"while released packages need a matching GitHub release archive; use "
|
|
"`nanobot agent --classic` if intentional"
|
|
)
|
|
|
|
|
|
def _source_checkout_tui_dir() -> Path | None:
|
|
"""Return this checkout's TUI source, never a neighboring unrelated directory."""
|
|
return _tui_source_dir(Path(__file__).resolve().parents[2])
|
|
|
|
|
|
def _tui_source_dir(project_root: Path) -> Path | None:
|
|
project_root = project_root.resolve(strict=False)
|
|
source_dir = project_root / "tui"
|
|
if (project_root / "pyproject.toml").is_file() and (source_dir / "package.json").is_file():
|
|
return source_dir
|
|
return None
|
|
|
|
|
|
def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
|
|
dependency = source_dir / "node_modules" / "@opentui" / "core"
|
|
try:
|
|
install = subprocess.run(
|
|
[bun, "install", "--frozen-lockfile"],
|
|
cwd=source_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except OSError as exc:
|
|
raise TuiUnavailableError(f"could not install TUI dependencies: {exc}") from exc
|
|
if install.returncode != 0 or not dependency.is_dir():
|
|
detail = (install.stderr or install.stdout).strip().splitlines()
|
|
suffix = f": {detail[-1]}" if detail else ""
|
|
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
|
|
executable = named_executable(
|
|
bun,
|
|
name="nanobot-tui",
|
|
directory=get_data_dir() / "run" / "executables",
|
|
)
|
|
return [executable, str(source_dir / "src" / "index.ts")]
|
|
|
|
|
|
def _download_release_tui(asset: str) -> Path | None:
|
|
"""Install the complete, version-matched TUI release bundle."""
|
|
if os.environ.get("NANOBOT_TUI_NO_DOWNLOAD") == "1":
|
|
return None
|
|
version = __version__.strip()
|
|
if not version or version.endswith((".dev0", "+dev")):
|
|
return None
|
|
|
|
target_dir = get_data_dir() / "bin" / "tui" / version
|
|
cached = _cached_release_tui(target_dir, asset)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
base = f"https://github.com/HKUDS/nanobot/releases/download/v{version}"
|
|
archive_name = f"{asset}.zip"
|
|
try:
|
|
checksum = _read_release_asset(f"{base}/{archive_name}.sha256", max_bytes=1024)
|
|
expected = _release_checksum(checksum, archive_name)
|
|
if expected is None:
|
|
return None
|
|
archive = _read_release_asset(f"{base}/{archive_name}", max_bytes=200 * 1024 * 1024)
|
|
except (OSError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError):
|
|
return None
|
|
if hashlib.sha256(archive).hexdigest() != expected:
|
|
raise TuiUnavailableError("downloaded TUI archive failed checksum verification")
|
|
files = _verified_release_archive(archive, asset)
|
|
|
|
temporary: dict[str, Path] = {}
|
|
try:
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
for name, content in files.items():
|
|
path = target_dir / name
|
|
pending = path.with_name(f"{path.name}.tmp-{os.getpid()}")
|
|
pending.write_bytes(content)
|
|
if name == asset and os.name != "nt":
|
|
pending.chmod(0o755)
|
|
temporary[name] = pending
|
|
for name in _release_bundle_names(asset):
|
|
temporary[name].replace(target_dir / name)
|
|
except OSError:
|
|
for path in temporary.values():
|
|
path.unlink(missing_ok=True)
|
|
_clear_cached_release(target_dir, asset)
|
|
return None
|
|
return target_dir / asset
|
|
|
|
|
|
def _release_bundle_names(asset: str) -> tuple[str, ...]:
|
|
return (asset, *_TUI_RELEASE_FILES, "MANIFEST.sha256")
|
|
|
|
|
|
def _release_checksum(raw: bytes, archive_name: str) -> str | None:
|
|
try:
|
|
parts = raw.decode("utf-8").split()
|
|
except UnicodeDecodeError:
|
|
return None
|
|
if len(parts) != 2 or parts[1] != archive_name:
|
|
return None
|
|
digest = parts[0].lower()
|
|
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
|
return None
|
|
return digest
|
|
|
|
|
|
def _release_manifest(raw: bytes, asset: str) -> dict[str, str]:
|
|
expected_names = set(_release_bundle_names(asset)[:-1])
|
|
try:
|
|
lines = raw.decode("utf-8").splitlines()
|
|
except UnicodeDecodeError as exc:
|
|
raise TuiUnavailableError("TUI release manifest is not valid UTF-8") from exc
|
|
checksums: dict[str, str] = {}
|
|
for line in lines:
|
|
digest, separator, name = line.partition(" ")
|
|
digest = digest.lower()
|
|
if (
|
|
separator != " "
|
|
or name not in expected_names
|
|
or name in checksums
|
|
or len(digest) != 64
|
|
or any(character not in "0123456789abcdef" for character in digest)
|
|
):
|
|
raise TuiUnavailableError("TUI release manifest is malformed")
|
|
checksums[name] = digest
|
|
if set(checksums) != expected_names:
|
|
raise TuiUnavailableError("TUI release manifest is incomplete")
|
|
return checksums
|
|
|
|
|
|
def _verified_release_archive(raw: bytes, asset: str) -> dict[str, bytes]:
|
|
expected_names = set(_release_bundle_names(asset))
|
|
files: dict[str, bytes] = {}
|
|
try:
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
|
entries = archive.infolist()
|
|
names = [entry.filename for entry in entries if not entry.is_dir()]
|
|
if len(names) != len(entries) or len(names) != len(set(names)):
|
|
raise TuiUnavailableError("TUI release archive contains invalid entries")
|
|
if set(names) != expected_names:
|
|
raise TuiUnavailableError("TUI release archive is incomplete")
|
|
for entry in entries:
|
|
limit = 150 * 1024 * 1024 if entry.filename == asset else _TUI_RELEASE_LIMITS[
|
|
entry.filename
|
|
]
|
|
if entry.file_size == 0 or entry.file_size > limit:
|
|
raise TuiUnavailableError(
|
|
f"TUI release file has an invalid size: {entry.filename}"
|
|
)
|
|
files[entry.filename] = archive.read(entry)
|
|
except zipfile.BadZipFile as exc:
|
|
raise TuiUnavailableError("downloaded TUI archive is not a valid ZIP file") from exc
|
|
|
|
checksums = _release_manifest(files["MANIFEST.sha256"], asset)
|
|
for name, expected in checksums.items():
|
|
if hashlib.sha256(files[name]).hexdigest() != expected:
|
|
raise TuiUnavailableError(f"TUI release file failed verification: {name}")
|
|
return files
|
|
|
|
|
|
def _cached_release_tui(target_dir: Path, asset: str) -> Path | None:
|
|
target = target_dir / asset
|
|
manifest = target_dir / "MANIFEST.sha256"
|
|
if not target.is_file() and not manifest.exists():
|
|
return None
|
|
try:
|
|
checksums = _release_manifest(manifest.read_bytes(), asset)
|
|
for name, expected in checksums.items():
|
|
if hashlib.sha256((target_dir / name).read_bytes()).hexdigest() != expected:
|
|
raise OSError("cached release checksum mismatch")
|
|
if os.name != "nt":
|
|
target.chmod(0o755)
|
|
except (OSError, TuiUnavailableError):
|
|
_clear_cached_release(target_dir, asset)
|
|
return None
|
|
return target
|
|
|
|
|
|
def _clear_cached_release(target_dir: Path, asset: str) -> None:
|
|
for name in _release_bundle_names(asset):
|
|
try:
|
|
(target_dir / name).unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _read_release_asset(url: str, *, max_bytes: int) -> bytes:
|
|
request = urllib.request.Request(url, headers={"User-Agent": f"nanobot/{__version__}"})
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and int(content_length) > max_bytes:
|
|
raise OSError("release asset exceeds size limit")
|
|
body = response.read(max_bytes + 1)
|
|
if len(body) > max_bytes:
|
|
raise OSError("release asset exceeds size limit")
|
|
return body
|
|
|
|
|
|
def _ensure_gateway(
|
|
config: Config,
|
|
*,
|
|
config_path: Path,
|
|
workspace_override: str | None,
|
|
wait_until_ready: bool = True,
|
|
) -> _GatewayHandle:
|
|
from nanobot.gateway import (
|
|
GatewayClientLease,
|
|
GatewayInstance,
|
|
GatewayRuntime,
|
|
)
|
|
|
|
base_url, _bootstrap_secret = _tui_gateway_connection(config)
|
|
instance = GatewayInstance.resolve(
|
|
config_path=config_path,
|
|
workspace=workspace_override,
|
|
)
|
|
runtime = GatewayRuntime(paths=instance.paths)
|
|
lease = GatewayClientLease(runtime, kind="tui")
|
|
lease.acquire()
|
|
try:
|
|
def ready(status: object) -> bool:
|
|
management_ready = getattr(status, "ready", None)
|
|
if not isinstance(management_ready, bool):
|
|
management_ready = _gateway_health_ready(
|
|
config.gateway.host,
|
|
config.gateway.port,
|
|
)
|
|
return _webui_endpoint_reachable(base_url) and management_ready
|
|
|
|
def wait_for_ready(log_path: object) -> _GatewayHandle:
|
|
deadline = time.monotonic() + _GATEWAY_READY_TIMEOUT_S
|
|
while time.monotonic() < deadline:
|
|
current = runtime.status()
|
|
if not current.running:
|
|
break
|
|
if current.port not in {None, config.gateway.port}:
|
|
break
|
|
if ready(current):
|
|
return _GatewayHandle(base_url=base_url, lease=lease)
|
|
time.sleep(_GATEWAY_READY_POLL_S)
|
|
|
|
current = runtime.status()
|
|
if current.running:
|
|
raise TuiUnavailableError(
|
|
"local gateway process is running but its WebSocket/WebUI listener "
|
|
"is unavailable; channel recovery did not restore it. "
|
|
"Run `nanobot gateway status` and inspect logs at "
|
|
f"{log_path}; if it remains degraded, run `nanobot gateway restart`."
|
|
)
|
|
raise TuiUnavailableError(
|
|
f"local gateway did not become ready; logs: {log_path}"
|
|
)
|
|
|
|
status = runtime.status()
|
|
if status.running:
|
|
if status.port not in {None, config.gateway.port}:
|
|
raise TuiUnavailableError(
|
|
"the matching gateway instance is running on a different port; "
|
|
"restart it or use `nanobot agent --classic`"
|
|
)
|
|
if not wait_until_ready:
|
|
return _GatewayHandle(base_url=base_url, lease=lease)
|
|
if ready(status):
|
|
return _GatewayHandle(base_url=base_url, lease=lease)
|
|
return wait_for_ready(status.log_path)
|
|
elif _webui_endpoint_reachable(base_url):
|
|
raise TuiUnavailableError(
|
|
"the configured gateway port belongs to a different nanobot instance; "
|
|
"stop that instance or use `nanobot agent --classic`"
|
|
)
|
|
|
|
result = lease.ensure_on_demand_gateway(
|
|
instance.start_options(port=config.gateway.port)
|
|
)
|
|
if not result.ok and result.message != "gateway_already_running":
|
|
raise TuiUnavailableError(
|
|
f"could not start the local gateway ({result.message}); "
|
|
f"logs: {result.status.log_path}"
|
|
)
|
|
|
|
if result.message == "gateway_already_running" and result.status.port not in {
|
|
None,
|
|
config.gateway.port,
|
|
}:
|
|
raise TuiUnavailableError(
|
|
"the matching gateway instance is running on a different port; "
|
|
"restart it or use `nanobot agent --classic`"
|
|
)
|
|
if not wait_until_ready:
|
|
return _GatewayHandle(base_url=base_url, lease=lease)
|
|
return wait_for_ready(result.status.log_path)
|
|
except BaseException:
|
|
lease.release(timeout_s=5)
|
|
raise
|
|
|
|
|
|
def _tui_gateway_connection(config: Config) -> tuple[str, str]:
|
|
"""Read the small bootstrap subset without importing the WebSocket runtime."""
|
|
raw: object = getattr(config.channels, "websocket", None)
|
|
settings = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
|
|
host = _host_for_local_browser(str(settings.get("host") or "127.0.0.1"))
|
|
try:
|
|
port = int(settings.get("port") or 8765)
|
|
except (TypeError, ValueError):
|
|
port = 8765
|
|
secret = str(
|
|
settings.get("tokenIssueSecret")
|
|
or settings.get("token_issue_secret")
|
|
or settings.get("token")
|
|
or ""
|
|
).strip()
|
|
return f"http://{host}:{port}", secret
|
|
|
|
|
|
def _websocket_chat_id(session_id: str) -> str | None:
|
|
"""Map the CLI selector to the WebSocket namespace used by the native TUI."""
|
|
if is_webui_session_key(session_id):
|
|
return webui_chat_id(session_id)
|
|
if ":" in session_id:
|
|
raise TuiSessionError(
|
|
"the native TUI can open only WebSocket sessions; use --classic to resume "
|
|
f"{session_id!r}"
|
|
)
|
|
return session_id or None
|
|
|
|
|
|
def _initial_tui_chat_id(session_id: str | None) -> str | None:
|
|
"""Start fresh unless the caller explicitly selects a TUI chat."""
|
|
if session_id is not None:
|
|
return _websocket_chat_id(session_id)
|
|
return None
|
|
|
|
|
|
def _initial_tui_workspace(workspace_override: str | None) -> Path:
|
|
"""Use the launch directory unless the caller explicitly selects a workspace."""
|
|
workspace = Path(workspace_override) if workspace_override is not None else Path.cwd()
|
|
return workspace.expanduser().resolve(strict=False)
|