mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
"""Controlled runner for installed CLI Apps."""
|
|
|
|
# pyright: reportIncompatibleMethodOverride=false
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
from pathlib import Path
|
|
from typing import TypedDict
|
|
|
|
from pydantic import Field
|
|
|
|
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
|
from nanobot.agent.tools.context import RequestContext, ToolContext
|
|
from nanobot.agent.tools.schema import (
|
|
ArraySchema,
|
|
BooleanSchema,
|
|
IntegerSchema,
|
|
StringSchema,
|
|
tool_parameters_schema,
|
|
)
|
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
|
from nanobot.apps.cli.utils import runtime_lines_for_request
|
|
from nanobot.config_base import Base
|
|
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
|
|
from nanobot.security.workspace_access import current_tool_workspace
|
|
|
|
|
|
class _CliAppRunKwargs(TypedDict):
|
|
args: list[str]
|
|
json_output: bool
|
|
working_dir: str | None
|
|
timeout: int | None
|
|
restrict_to_workspace: bool
|
|
|
|
|
|
class CliAppsToolConfig(Base):
|
|
"""CLI Apps tool configuration."""
|
|
|
|
enable: bool = True
|
|
install_timeout: int = Field(default=300, ge=1, le=3600)
|
|
run_timeout: int = Field(default=60, ge=1, le=600)
|
|
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
|
|
|
|
|
|
@tool_parameters(
|
|
tool_parameters_schema(
|
|
required=["name"],
|
|
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
|
|
args=ArraySchema(
|
|
StringSchema("One command-line argument."),
|
|
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
|
|
nullable=True,
|
|
),
|
|
json=BooleanSchema(
|
|
description="Whether to prepend --json when supported by the CLI.",
|
|
default=False,
|
|
nullable=True,
|
|
),
|
|
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
|
|
timeout=IntegerSchema(
|
|
description="Timeout in seconds for this CLI call.",
|
|
minimum=1,
|
|
maximum=600,
|
|
nullable=True,
|
|
),
|
|
)
|
|
)
|
|
class CliAppsTool(Tool):
|
|
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
|
|
|
|
config_key = "cli_apps"
|
|
_scopes = {"core", "subagent"}
|
|
|
|
@classmethod
|
|
def config_cls(cls):
|
|
return CliAppsToolConfig
|
|
|
|
@classmethod
|
|
def enabled(cls, ctx: ToolContext) -> bool:
|
|
return ctx.config.cli_apps.enable
|
|
|
|
@classmethod
|
|
def create(cls, ctx: ToolContext) -> Tool:
|
|
cfg = ctx.config.cli_apps
|
|
return cls(
|
|
workspace=Path(ctx.workspace),
|
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
|
runtime=CliAppsRuntimeConfig(
|
|
install_timeout=cfg.install_timeout,
|
|
run_timeout=cfg.run_timeout,
|
|
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
|
|
),
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
workspace: Path,
|
|
restrict_to_workspace: bool = False,
|
|
runtime: CliAppsRuntimeConfig | None = None,
|
|
) -> None:
|
|
self.workspace = workspace
|
|
self.restrict_to_workspace = restrict_to_workspace
|
|
self.runtime = runtime or CliAppsRuntimeConfig()
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "run_cli_app"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
try:
|
|
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
|
|
except Exception:
|
|
installed = []
|
|
installed_note = (
|
|
f" Installed Settings CLI Apps: {', '.join(installed)}."
|
|
if installed
|
|
else " No Settings CLI Apps are currently installed."
|
|
)
|
|
return (
|
|
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
|
|
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
|
|
"unknown names are rejected. Execution uses argv, not shell."
|
|
+ installed_note
|
|
)
|
|
|
|
def runtime_context_provider(self):
|
|
return self._provide_runtime_context
|
|
|
|
async def _provide_runtime_context(
|
|
self,
|
|
request: RequestContext,
|
|
) -> RuntimeContextBlock | None:
|
|
lines = runtime_lines_for_request(
|
|
request.original_user_text or "",
|
|
request.metadata,
|
|
request.workspace or self.workspace,
|
|
)
|
|
content = wrap_runtime_context_lines(lines)
|
|
if not content:
|
|
return None
|
|
return RuntimeContextBlock(source="cli_apps", content=content)
|
|
|
|
async def execute(
|
|
self,
|
|
name: str,
|
|
args: list[str] | None = None,
|
|
json: bool | None = False,
|
|
working_dir: str | None = None,
|
|
timeout: int | None = None,
|
|
) -> str:
|
|
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)
|
|
run_kwargs: _CliAppRunKwargs = {
|
|
"args": args or [],
|
|
"json_output": bool(json),
|
|
"working_dir": working_dir,
|
|
"timeout": timeout,
|
|
"restrict_to_workspace": access.restrict_to_workspace,
|
|
}
|
|
try:
|
|
run_async = inspect.getattr_static(type(manager), "run_async", None)
|
|
if inspect.iscoroutinefunction(run_async):
|
|
return await manager.run_async(name, **run_kwargs)
|
|
return await asyncio.to_thread(manager.run, name, **run_kwargs)
|
|
except CliAppError as exc:
|
|
return ToolResult.error(f"Error: {exc.message}")
|