Compare commits

..
Author SHA1 Message Date
chengyongru f2cf5403cb refactor(providers): declare Responses capabilities 2026-08-09 22:42:21 +08:00
104 changed files with 3754 additions and 12034 deletions
+2 -3
View File
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
## 🌐 WebUI
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, temporary chats, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
<p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
@@ -250,10 +250,9 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
Use it to:
- keep separate topics for different tasks and projects;
- use temporary chats when a conversation should not be saved to history or memory;
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
- switch models and workspaces without leaving the conversation;
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
- configure providers, chat channels, Apps, Skills, and Automations from one place.
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
+1 -1
View File
@@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring.
+4 -62
View File
@@ -1971,52 +1971,15 @@ Add MCP servers to your `config.json`:
}
```
MCP servers can run locally over stdio or connect remotely over HTTP:
Two transport modes are supported:
| Connection | Config | Example |
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |
Remote HTTP servers may use browser OAuth instead of static headers. In the
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
**Connect**. For manual configuration, add `auth: "oauth"` and open
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
the config automatically on first click.
```json
{
"tools": {
"mcpServers": {
"notion": {
"type": "streamableHttp",
"url": "https://mcp.notion.com/mcp",
"auth": "oauth"
}
}
}
}
```
nanobot opens the server's authorization page and handles the callback through
the gateway. The tools become available immediately when hot reload succeeds;
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
registration data are stored in the nanobot data directory under
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
from Apps also removes its saved OAuth credentials. Normal gateway startup never
opens a browser or registers a new OAuth client when credentials are
missing—interactive authorization starts only after a user clicks **Connect**.
For a remotely accessed WebUI, HTTPS is recommended. Configure
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
nanobot can register the matching HTTPS callback and finish automatically. A
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
nanobot instead registers a localhost callback and asks you to paste the complete
callback URL from the browser address bar after authorization.
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
> [!IMPORTANT]
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
@@ -2343,27 +2306,6 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
### Agent Plugins v1
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
`skills/<name>/SKILL.md`, or both.
Directory presence means installed; activation is an explicit trust decision in **Apps**.
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
override plugin skills, which override built-ins. Enabled `stdio` servers from `mcp.json` receive
contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit `tools.mcpServers` entries win
name collisions. Invalid manifests, components, nested skills, and escaping paths are ignored.
Enabled plugins run as the nanobot user; declared permissions are descriptive, not an OS sandbox.
The optional `extensions.dev.nanobot.installCommand` is a shell-free argv run once per version
before local enable. Remote setup requires `tools.webuiAllowRemotePackageInstall`. The optional
`extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
WebUI-installed CLI Apps use the same package layout as skills-only plugins. Their external
executables remain managed by the CLI Apps installer; update refreshes the package and uninstall
removes it. Future catalogs can acquire and place packages before using this same activation path.
## Tool Hint Max Length
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
+3 -12
View File
@@ -30,15 +30,10 @@ remote HTTP endpoint.
For local interactive setup:
1. Run `nanobot webui` and open **Apps**.
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
return automatically. From a remote plain-HTTP WebUI, copy the complete
localhost callback URL from the browser address bar and paste it into nanobot.
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
3. Limit the enabled tools when the server exposes more than the task needs.
4. Save and restart when prompted.
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.
5. Mention the integration with `@` in the next message and ask for a small test action.
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
@@ -63,16 +58,12 @@ Restart nanobot and ask a question that requires the MCP tool.
- Prefer `enabledTools` over exposing every tool by default.
- Use `toolTimeout` for slow MCP operations.
- Use HTTP MCP only for endpoints you trust.
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
browser connection from **Apps → MCP**.
- Keep MCP server commands stable and versioned in deployment docs or scripts.
## Security notes
- Stdio MCP starts a local process; review the command before enabling it.
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
token exchange, and redirects.
- OAuth credentials live in the nanobot data directory, not in `config.json`.
- HTTP/SSE MCP uses nanobot's SSRF guard.
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
- Do not place secrets in command arguments when environment variables or
headers can be used.
-6
View File
@@ -270,12 +270,6 @@ http://127.0.0.1:8765
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
| Symptom | Check |
|---|---|
| A temporary chat disappeared after a reload or reconnect | This is expected. Temporary chats exist only for the current WebUI connection and are not saved to history or memory. Use a regular topic for anything you need to retain. |
| A skills.sh install says that `npx` is required | Install Node.js with `npx` on the gateway machine, or choose a SkillHub skill that does not require `npx`. |
| A remote browser says skill installation is disabled | Install from a same-machine WebUI. For a private deployment where every authenticated user is trusted to install third-party skill instructions or scripts, explicitly enable `tools.webuiAllowRemotePackageInstall`. |
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
## Chat App Problems
+23 -66
View File
@@ -1,10 +1,10 @@
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
The WebUI is nanobot's browser workbench for persistent topics, temporary
chats, visible agent activity, workspace controls, Apps, skill discovery,
settings, and Automations in one place.
The WebUI is nanobot's browser workbench for persistent topics, visible
agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place.
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
the `webui/` source directory when you are changing the frontend itself.
@@ -72,14 +72,14 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
| Area | Use it for |
|---|---|
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
| Topics | Start, switch, search, fork, and delete browser topics |
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
| Workspace | Pick the project workspace before asking for file or shell work |
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
| Skills | Inspect available built-in and workspace skills before relying on them |
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
@@ -90,10 +90,6 @@ workspace selection, and linked automations. Use a new topic when you want a
separate context; use fork when you want to continue from an existing point
without changing the original thread.
Drag a topic within its current sidebar group to keep frequently used work in
your preferred order. Drag a topic from the sidebar into the composer when you
want to reference it in the next message instead of switching to it.
The message timeline shows both user-visible replies and agent activity. Long
tool or reasoning sections can be expanded when you need the details.
@@ -107,28 +103,6 @@ File previews follow the active session access mode. Restricted workspace access
previews only files under the selected workspace. Full Access can preview files
outside the workspace when that access mode is allowed by the gateway.
## Temporary Chats
Use a temporary chat for a conversation that should not be added to nanobot's
topic history or long-term memory:
1. Select **New topic**.
2. Select the **Temporary chat** control in the page header.
3. Send the first message.
You can keep more than one temporary chat open and switch between them under
**Temporary chats** in the sidebar while the current WebUI connection remains
open. Reloading or closing the page, restarting the gateway, or losing the
WebSocket connection ends all of them. They cannot be recovered afterward.
Temporary does not mean consequence-free. Requests still go to the configured
model provider, and tools can still change files, run commands, or affect
external services. Temporary chats always use the default workspace in
Restricted mode; the project picker and Full Access are unavailable. Commands
and tools that create durable goals, automations, or subagent work are also
unavailable. Use a regular topic when you need reusable context, scheduled work,
or a result you must retain.
## Workspace and Access
Use the workspace picker before starting project-specific work. This gives the
@@ -171,8 +145,7 @@ clients.
The composer supports plain messages, image attachments, voice input when
transcription is configured, slash commands, and `@` mentions for installed Apps
or MCP presets. Select another topic from the `@` menu to attach a stable
reference, or drag that topic from the sidebar into the composer. Plain text
that happens to start with `@` does not attach history.
reference; plain text that happens to start with `@` does not attach history.
Restricted chats offer topics from the same project, while Full Access chats can
reference any WebUI topic. Nanobot reads a referenced topic only when its history
is relevant and can link it in the response. The model badge shows the current
@@ -204,13 +177,8 @@ turn. The default **Ready** view shows only tools that can be used immediately:
- **Apps** are local command-line adapters that nanobot runs on your machine.
Installing an adapter does not modify the native desktop or web app it
connects to.
- **MCP** lists Model Context Protocol servers. Presets provide known
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
headers. After saving an OAuth server, choose **Connect** to open its sign-in
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
field for pasting the complete localhost callback URL.
- **Integrations** are MCP servers. Presets provide known configurations, and
the custom integration panel accepts stdio, HTTP, and SSE servers.
Apps intentionally does not list nanobot runtime support packages such as
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
@@ -231,25 +199,15 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
It is an optional integration and does not replace nanobot's built-in web search
provider; mention `@parallel-search` when a turn should use it.
After an App or MCP server is available, mention it from the composer with `@`
to attach that tool to the next message.
After an App or integration is available, mention it from the composer with
`@` to attach that tool to the next message.
## Skills
Open **Skills → Installed** to review built-in and workspace-provided skills.
You can search and filter them, inspect their instructions and setup
requirements, enable or disable them, and delete workspace skills you no longer
want.
Open **Skills → Discover** to browse or search skills from skills.sh and
SkillHub. A marketplace skill is copied into the active agent workspace after
you confirm the installation. skills.sh installation requires Node.js with
`npx`; SkillHub installation does not.
Marketplace skills are third-party instructions and may include executable
scripts. Review the source and instructions before installing one, and enable
only skills you trust with the same files, tools, and credentials available to
your agent.
The Skills view shows the skill instructions available to the agent, including
built-in skills and workspace-provided skills. Check this view when you want to
know whether nanobot already has a focused workflow for a task before you ask it
to perform that task.
## Automations
@@ -337,10 +295,10 @@ trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
reverse proxy and use that proxy's HTTPS URL.
Remote WebUI clients with a valid token can view and use Apps and installed
skills. Actions that install missing nanobot support packages or third-party
marketplace skills are blocked by default. To let trusted remote administrators
perform those installations through the WebUI, opt in explicitly:
Remote WebUI clients with a valid token can view and use Apps. Actions that
install missing nanobot support packages, such as adding a channel dependency,
are blocked by default. To let trusted remote administrators change the Python
environment through the WebUI, opt in explicitly:
```json
{
@@ -351,13 +309,12 @@ perform those installations through the WebUI, opt in explicitly:
```
Use this only for a private deployment where every authenticated WebUI user is
trusted to change nanobot's Python environment and install workspace skill
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
package and skill installs disabled unless that is intentional.
trusted to change the Python environment that nanobot runs in. If you publish
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
as remote access and leave package installs disabled unless that is intentional.
Optional feature installs use pip's configured package index, including
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
`PIP_INDEX_URL`.
Leave remote package installs disabled when the WebUI is exposed beyond a
private, trusted network.
+4 -14
View File
@@ -36,7 +36,6 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
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.registry import ToolRegistry
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.agent.turn_delivery import (
TurnDelivery,
@@ -198,11 +197,6 @@ class AgentLoop:
def tool_names(self) -> list[str]:
return self.tools.tool_names
@property
def last_usage(self) -> Mapping[str, int]:
"""Latest aggregate usage exposed through the runtime-control snapshot."""
return self._last_usage
@property
def provider(self) -> LLMProvider:
"""Provider selected for future turn admissions."""
@@ -454,6 +448,7 @@ class AgentLoop:
if model_preset:
self.set_model_preset(model_preset, publish_update=False)
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
self._runtime_vars: dict[str, Any] = {}
self._current_iteration: int = 0
self.commands = CommandRouter()
register_builtin_commands(self.commands)
@@ -485,8 +480,6 @@ class AgentLoop:
config,
provider_snapshot_loader,
)
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls(
bus=bus,
provider=provider,
@@ -501,7 +494,7 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
mcp_servers=config.tools.mcp_servers,
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
@@ -630,13 +623,10 @@ class AgentLoop:
loader = ToolLoader()
registered = loader.load(ctx, self.tools)
# MyTool receives only the explicit runtime-control capability.
# MyTool needs runtime state reference — manual registration
if self.tools_config.my.enable:
self.tools.register(
MyTool(
runtime_control=AgentRuntimeControl(self),
modify_allowed=self.tools_config.my.allow_set,
)
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
)
registered.append("my")
-438
View File
@@ -1,438 +0,0 @@
"""Load and activate locally installed Agent Plugin packages."""
from __future__ import annotations
import json
import os
import re
import subprocess
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import cast
from filelock import FileLock
from loguru import logger
from pydantic import ValidationError
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
from nanobot.config.loader import get_config_path
from nanobot.config.schema import MCPServerConfig
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
_SETUP_TIMEOUT_SECONDS = 600
_MAX_LOGO_BYTES = 256 * 1024
@dataclass(frozen=True)
class AgentPlugin:
"""A validated, locally installed Agent Plugins v1 package."""
name: str
root: Path
version: str
description: str
repository: str
display_name: str
category: str
accent_color: str | None
logo: Path | None
permissions: tuple[str, ...]
install_command: tuple[str, ...]
@dataclass(frozen=True)
class AgentPluginState:
"""Runtime state for one discovered Agent Plugin."""
plugin: AgentPlugin
mcp_servers: tuple[str, ...]
enabled: bool
setup_required: bool
def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return installed packages found under ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
root = _contained_directory(workspace / "plugins", workspace)
if root is None:
return []
plugins: list[AgentPlugin] = []
for candidate in _children(root, "Agent Plugins directory"):
plugin_root = _contained_directory(candidate, root)
if plugin_root is None:
continue
plugin = _load_manifest(plugin_root)
if plugin is not None:
plugins.append(plugin)
return plugins
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
"""Return skills from plugins the user has explicitly enabled."""
return [
skill
for plugin in _discover_agent_plugins(workspace)
if _enabled(workspace, plugin.name)
for skill in _discover_plugin_skills(plugin.name, plugin.root)
]
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
payload = _read_object(plugin_root / "plugin.json", plugin_root)
if payload is None:
return None
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
return None
name = payload.get("name")
if (
not isinstance(name, str)
or len(name) > 64
or _PLUGIN_NAME.fullmatch(name) is None
):
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
return None
extension = payload.get("extensions")
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
nanobot_value = extension_payload.get("dev.nanobot")
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
return AgentPlugin(
name=name,
root=plugin_root,
version=_string(payload.get("version")),
description=_string(payload.get("description")),
repository=_string(payload.get("repository")),
display_name=_string(nanobot.get("displayName")) or name,
category=_string(nanobot.get("category")) or "Plugin",
accent_color=_accent_color(nanobot.get("accentColor")),
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
permissions=_string_tuple(nanobot.get("permissions")),
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
)
def agent_plugin_mcp_servers(
workspace: Path,
configured: dict[str, MCPServerConfig] | None = None,
) -> dict[str, MCPServerConfig]:
"""Merge explicitly enabled plugin MCP servers with user configuration.
User configuration wins on the unlikely event of a namespaced collision.
"""
servers: dict[str, MCPServerConfig] = {}
for plugin in _discover_agent_plugins(workspace):
if not _enabled(workspace, plugin.name):
continue
plugin_servers = _plugin_mcp_servers(workspace, plugin)
for name, server in plugin_servers.items():
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
servers[host_name] = server
configured = configured or {}
if collisions := servers.keys() & configured.keys():
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
return servers | configured
def discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
"""Return component and lifecycle state for discovered plugins."""
return [
AgentPluginState(
plugin=plugin,
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
enabled=_enabled(workspace, plugin.name),
setup_required=bool(plugin.install_command)
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
)
for plugin in _discover_agent_plugins(workspace)
]
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> AgentPlugin:
"""Enable or disable one installed plugin."""
plugin = next((item for item in _discover_agent_plugins(workspace) if item.name == name), None)
if plugin is None:
raise ValueError(f"unknown Agent Plugin '{name}'")
data = _plugin_data_dir(workspace, plugin.name, create=True)
version = plugin.version or "unknown"
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
if enabled:
if plugin.install_command and _setup_version(workspace, plugin.name) != version:
_run_install(plugin, data)
_write_state(data / "setup-version", version)
_write_state(data / "enabled", "1")
else:
(data / "enabled").unlink(missing_ok=True)
return plugin
def _string(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _string_tuple(value: object) -> tuple[str, ...]:
items = cast(list[object], value) if isinstance(value, list) else []
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
def _accent_color(value: object) -> str | None:
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
"""Resolve nanobot's optional packaged logo extension."""
if value is None:
return None
if not isinstance(value, str) or not value.startswith("./"):
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
logo = _contained_file(plugin_root / value[2:], plugin_root)
try:
data = logo.read_bytes() if logo is not None else b""
suffix = logo.suffix.lower() if logo is not None else ""
if len(data) <= _MAX_LOGO_BYTES and (
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
):
return logo
except OSError:
pass
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
"""Validate nanobot's optional, shell-free setup command extension."""
if not isinstance(value, list):
return ()
items = cast(list[object], value)
if not 1 <= len(items) <= 32 or not all(
isinstance(item, str) and 0 < len(item) <= 4096 for item in items
):
return ()
command = cast(str, items[0])
if not command.startswith("./"):
logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root)
return ()
executable = _contained_file(plugin_root / command[2:], plugin_root)
if executable is None:
logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root)
return ()
return (str(executable), *(cast(str, item) for item in items[1:]))
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
payload = _read_object(plugin.root / "mcp.json", plugin.root)
if payload is None:
return {}
raw_servers = payload.get("mcpServers")
if payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA or not isinstance(raw_servers, dict):
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
return {}
data = _plugin_data_dir(workspace, plugin.name, create=True)
servers: dict[str, MCPServerConfig] = {}
for name, raw in cast(dict[str, object], raw_servers).items():
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
continue
server = _plugin_mcp_server(raw, plugin.root, data)
if server is None:
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
continue
servers[name] = server
return servers
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
if not isinstance(raw, dict):
return None
payload = cast(dict[str, object], raw)
if payload.keys() - _MCP_SERVER_FIELDS:
return None
try:
server = MCPServerConfig.model_validate(payload)
except ValidationError:
return None
command = _stdio_command(server.command, root)
cwd = _stdio_cwd(payload.get("cwd"), root, data)
if server.type != "stdio" or command is None or cwd is None:
return None
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
return None
return server.model_copy(
update={
"command": command,
"args": [_expand(item, root, data) for item in server.args],
"env": {
**{key: _expand(value, root, data) for key, value in server.env.items()},
"PLUGIN_ROOT": str(root),
"PLUGIN_DATA": str(data),
},
"cwd": str(cwd),
}
)
def _stdio_command(value: object, root: Path) -> str | None:
if not isinstance(value, str) or not value:
return None
if value.startswith("./"):
executable = _contained_file(root / value[2:], root)
return str(executable) if executable is not None else None
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
return None
return value
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
if value is None:
return root
if not isinstance(value, str):
return None
if value.startswith("./"):
return _contained_directory(root / value[2:], root)
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
if value == placeholder or value.startswith(f"{placeholder}/"):
relative = value[len(placeholder):].lstrip("/")
candidate = (base / relative).resolve()
if not candidate.is_relative_to(base):
return None
if base == data:
candidate.mkdir(parents=True, exist_ok=True)
candidate.chmod(0o700)
return candidate if candidate.is_dir() else None
return None
def _expand(value: str, root: Path, data: Path) -> str:
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
config_root = get_config_path().expanduser().resolve().parent
plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create)
state_root = _private_directory(plugin_root / workspace_id, plugin_root, create=create)
data = state_root / name
return _private_directory(data, state_root, create=True) if create else data
def _private_directory(path: Path, root: Path, *, create: bool) -> Path:
if create:
path.mkdir(parents=True, exist_ok=True)
try:
resolved = path.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
if not resolved.is_relative_to(root):
raise RuntimeError("Agent Plugin data directory escapes its parent")
if create:
resolved.chmod(0o700)
return resolved
def _enabled(workspace: Path, name: str) -> bool:
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
def _setup_version(workspace: Path, name: str) -> str:
try:
return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text(
encoding="utf-8"
).strip()
except (OSError, UnicodeError):
return ""
def _write_state(path: Path, value: str) -> None:
path.write_text(value, encoding="utf-8")
path.chmod(0o600)
def _run_install(plugin: AgentPlugin, data: Path) -> None:
env = {
**{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None},
"PLUGIN_ROOT": str(plugin.root),
"PLUGIN_DATA": str(data),
}
try:
result = subprocess.run(
plugin.install_command,
cwd=plugin.root,
env=env,
capture_output=True,
text=True,
timeout=_SETUP_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"{plugin.display_name} setup timed out") from exc
if result.returncode:
output = (result.stderr or result.stdout).strip()[-2000:]
raise RuntimeError(output or f"{plugin.display_name} setup failed")
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
skills_root = _contained_directory(plugin_root / "skills", plugin_root)
if skills_root is None:
return []
skills: list[tuple[str, Path]] = []
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
skill_root = _contained_directory(candidate, skills_root)
if skill_root is None:
continue
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
if skill_file is None:
continue
try:
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
except (OSError, UnicodeError):
metadata = None
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
continue
skills.append((candidate.name, skill_file))
return skills
def _children(root: Path, label: str) -> list[Path]:
try:
return sorted(root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect {}: {}", label, exc)
return []
def _contained_directory(path: Path, root: Path) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
contained = _contained_file(path, root)
if contained is None:
return None
try:
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
return None
return cast(dict[str, object], value) if isinstance(value, dict) else None
def _contained_file(path: Path, root: Path) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
+28 -62
View File
@@ -17,48 +17,9 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL,
)
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_NAME_LINE = re.compile(r"^name\s*:.*$", re.MULTILINE)
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
def parse_skill_metadata(content: str) -> dict[str, object] | None:
"""Parse a skill document's YAML frontmatter."""
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
return None
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
"""Return whether metadata satisfies the Agent Skills identity contract."""
description = metadata.get("description")
return (
metadata.get("name") == name
and len(name) <= 64
and _SKILL_NAME.fullmatch(name) is not None
and isinstance(description, str)
and 1 <= len(description.strip()) <= 1024
)
def normalize_skill_document(content: str, name: str) -> str | None:
"""Return a valid skill document with a canonical name."""
match = _STRIP_SKILL_FRONTMATTER.match(content)
metadata = parse_skill_metadata(content)
if match is None or metadata is None or not valid_skill_metadata(metadata | {"name": name}, name):
return None
frontmatter, replaced = _SKILL_NAME_LINE.subn(f"name: {name}", match.group(1), count=1)
if not replaced:
frontmatter = f"name: {name}\n{frontmatter}"
return f"---\n{frontmatter.strip()}\n---\n\n{content[match.end():].lstrip()}"
class SkillsLoader:
"""
Loader for agent skills.
@@ -99,25 +60,11 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
from nanobot.agent.plugins import enabled_agent_plugin_skills
plugin_skills = enabled_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills}
for name, path in plugin_skills:
if name in seen_names:
continue
skills.append(
{
"name": name,
"path": str(path),
"source": "plugin",
}
)
seen_names.add(name)
workspace_names = {entry["name"] for entry in skills}
if self.builtin_skills and self.builtin_skills.exists():
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
)
if self.disabled_skills:
@@ -137,11 +84,14 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
entry = next(
(skill for skill in self.list_skills(filter_unavailable=False) if skill["name"] == name),
None,
)
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
roots = [self.workspace_skills]
if self.builtin_skills:
roots.append(self.builtin_skills)
for root in roots:
path = root / name / "SKILL.md"
if path.exists():
return path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
"""
@@ -195,7 +145,6 @@ class SkillsLoader:
sections: list[str] = []
groups = (
("Workspace skills", "workspace", self.workspace_skills),
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
("Built-in skills", "builtin", self.builtin_skills),
)
for label, source, root in groups:
@@ -329,4 +278,21 @@ class SkillsLoader:
Returns:
Metadata dict or None.
"""
return parse_skill_metadata(self.load_skill(name) or "")
content = self.load_skill(name)
if not content or not content.startswith("---"):
return None
match = _STRIP_SKILL_FRONTMATTER.match(content)
if not match:
return None
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
# yaml.safe_load returns native types (int, bool, list, etc.);
# keep values as-is so downstream consumers get correct types.
metadata: dict[str, object] = {}
for key, value in cast(dict[object, object], parsed).items():
metadata[str(key)] = value
return metadata
-5
View File
@@ -5,7 +5,6 @@ import json
import time
import uuid
import warnings
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, TypedDict
@@ -158,10 +157,6 @@ class SubagentManager:
self._task_statuses: dict[str, SubagentStatus] = {}
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
"""Return the observable task statuses used by runtime-control snapshots."""
return self._task_statuses
def set_provider(self, provider: LLMProvider, model: str) -> None:
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
warnings.warn(
+2 -6
View File
@@ -827,8 +827,7 @@ class EditFileTool(_FsTool):
def description(self) -> str:
return (
"Perform a small, exact replacement in one file by replacing "
"old_text with new_text. When replacing text in an existing file, "
"old_text and new_text must be different. Use this for narrow text substitutions "
"old_text with new_text. Use this for narrow text substitutions "
"with old_text copied from read_file. For multi-file, structural, "
"or generated code edits, prefer apply_patch. If old_text matches "
"multiple times, provide more context or set occurrence, line_hint, "
@@ -863,12 +862,9 @@ class EditFileTool(_FsTool):
return ToolResult.error("Error: expected_replacements must be >= 1.")
fp = self._resolve_write(path)
file_exists = fp.exists()
if file_exists and old_text == new_text:
return ToolResult.error("Error: new_text must be different from old_text.")
# Create-file semantics: old_text='' + file doesn't exist → create
if not file_exists:
if not fp.exists():
if old_text == "":
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(new_text, encoding="utf-8")
+1 -1
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
_SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
})
+43 -102
View File
@@ -38,7 +38,6 @@ if TYPE_CHECKING:
from mcp.types import Prompt, Resource
from mcp.types import Tool as MCPToolDefinition
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
from nanobot.config.schema import MCPServerConfig
# Transient connection errors that warrant a single retry.
@@ -185,25 +184,6 @@ def _is_transient(exc: BaseException) -> bool:
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
def _is_transient_connection_failure(exc: BaseException) -> bool:
if isinstance(exc, BaseExceptionGroup):
group = cast(BaseExceptionGroup[BaseException], exc)
return bool(group.exceptions) and all(
_is_transient_connection_failure(nested) for nested in group.exceptions
)
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)
def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
if _is_transient_connection_failure(exc):
logger.warning("MCP server '{}': transient connection failure", name)
logger.opt(exception=exc).debug(
"MCP server '{}' transient connection failure details", name
)
return
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)
def _is_session_terminated(exc: BaseException) -> bool:
"""Return True when the MCP SDK reports a dead client session."""
if _is_transient(exc):
@@ -981,10 +961,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers(
mcp_servers: "dict[str, MCPServerConfig]",
registry: ToolRegistry,
*,
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts.
@@ -998,8 +975,11 @@ async def connect_mcp_servers(
from mcp.client.streamable_http import streamable_http_client
async def open_single_server(
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
) -> bool:
name: str, cfg: "MCPServerConfig"
) -> tuple[str, AsyncExitStack | None]:
server_stack = AsyncExitStack()
await server_stack.__aenter__()
try:
transport_type = cfg.type
if not transport_type:
@@ -1011,7 +991,8 @@ async def connect_mcp_servers(
)
else:
logger.warning("MCP server '{}': no command or url configured, skipping", name)
return False
await server_stack.aclose()
return name, None
if transport_type in {"sse", "streamableHttp"}:
ok, error = validate_url_target(cfg.url)
@@ -1022,30 +1003,8 @@ async def connect_mcp_servers(
_redact_url(cfg.url),
error,
)
return False
oauth_auth: httpx.Auth | None = None
if cfg.auth == "oauth":
if transport_type not in {"sse", "streamableHttp"}:
logger.warning(
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
name,
)
return False
from nanobot.agent.tools.mcp_oauth import (
MCPAuthorizationRequiredError,
create_mcp_oauth_auth,
)
try:
oauth_auth = await create_mcp_oauth_auth(
name,
cfg.url,
(oauth_handlers or {}).get(name),
)
except MCPAuthorizationRequiredError:
logger.info("MCP server '{}': waiting for browser authorization", name)
return False
await server_stack.aclose()
return name, None
if transport_type == "stdio":
command, args, env = _normalize_windows_stdio_command(
@@ -1063,7 +1022,8 @@ async def connect_mcp_servers(
elif transport_type == "sse":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
return False
await server_stack.aclose()
return name, None
def httpx_client_factory(
headers: dict[str, str] | None = None,
@@ -1084,37 +1044,31 @@ async def connect_mcp_servers(
**_pinned_transport_kwargs(),
)
sse_kwargs: dict[str, Any] = {
"httpx_client_factory": httpx_client_factory,
}
if oauth_auth is not None:
sse_kwargs["auth"] = oauth_auth
read, write = await server_stack.enter_async_context(
sse_client(cfg.url, **sse_kwargs)
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
)
elif transport_type == "streamableHttp":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
return False
await server_stack.aclose()
return name, None
http_client_kwargs: dict[str, Any] = {
"headers": cfg.headers or None,
"event_hooks": {"request": [_validate_mcp_request_url]},
"follow_redirects": True,
"timeout": httpx.Timeout(30.0, connect=10.0),
**_pinned_transport_kwargs(),
}
if oauth_auth is not None:
http_client_kwargs["auth"] = oauth_auth
http_client = await server_stack.enter_async_context(
httpx.AsyncClient(**http_client_kwargs)
httpx.AsyncClient(
headers=cfg.headers or None,
event_hooks={"request": [_validate_mcp_request_url]},
follow_redirects=True,
timeout=httpx.Timeout(30.0, connect=10.0),
**_pinned_transport_kwargs(),
)
)
read, write, _ = await server_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
return False
await server_stack.aclose()
return name, None
read = _filter_malformed_mcp_progress_notifications(read, name)
session = await server_stack.enter_async_context(ClientSession(read, write))
@@ -1217,7 +1171,7 @@ async def connect_mcp_servers(
logger.info(
"MCP server '{}': connected, {} capabilities registered", name, registered_count
)
return True
return name, server_stack
except Exception as e:
hint = ""
@@ -1236,8 +1190,10 @@ async def connect_mcp_servers(
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
_log_mcp_connection_failure(name, e, hint)
return False
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
with suppress(Exception):
await server_stack.aclose()
return name, None
async def connect_single_server(
name: str, cfg: "MCPServerConfig"
@@ -1247,30 +1203,30 @@ async def connect_mcp_servers(
close_requested = asyncio.Event()
async def own_connection() -> None:
stack: AsyncExitStack | None = None
try:
async with AsyncExitStack() as stack:
connected = await open_single_server(name, cfg, stack)
if not ready.done():
ready.set_result(connected)
if connected:
await close_requested.wait()
_, stack = await open_single_server(name, cfg)
if not ready.done():
ready.set_result(stack is not None)
if stack is not None:
await close_requested.wait()
except BaseException as exc:
if not ready.done():
ready.set_exception(exc)
raise
finally:
if stack is not None:
await stack.aclose()
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
connection = _OwnedMCPConnection(owner, close_requested)
try:
connected = await ready
except BaseException as exc:
except BaseException:
close_requested.set()
owner.cancel()
with suppress(BaseException):
await asyncio.shield(owner)
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
return name, None
raise
if not connected:
await connection.aclose()
@@ -1283,7 +1239,7 @@ async def connect_mcp_servers(
try:
result = await connect_single_server(name, cfg)
except Exception as e:
_log_mcp_connection_failure(name, e)
logger.exception("MCP server '{}' connection failed: {}", name, e)
continue
if result[1] is not None:
server_stacks[result[0]] = result[1]
@@ -1340,14 +1296,10 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True,
}
try:
from nanobot.agent.plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config())
next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
next_servers = dict(config.tools.mcp_servers)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {
@@ -1360,13 +1312,6 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
current_servers = dict(state._mcp_servers)
current_names = set(current_servers)
next_names = set(next_servers)
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
authorization_pending = {
name
for name, cfg in next_servers.items()
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
}
removed = sorted(current_names - next_names)
added = sorted(next_names - current_names)
changed = sorted(
@@ -1384,13 +1329,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
retry_missing = sorted(
name
for name in next_names
if name not in state._mcp_stacks
and name not in set(added) | set(changed)
and name not in authorization_pending
)
to_connect_names = sorted(
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
if name not in state._mcp_stacks and name not in set(added) | set(changed)
)
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
to_connect = {name: next_servers[name] for name in to_connect_names}
connected: dict[str, MCPConnection] = {}
if to_connect:
-401
View File
@@ -1,401 +0,0 @@
"""OAuth support for remote MCP servers.
This module intentionally owns MCP OAuth end to end. Provider OAuth has a
different lifecycle and storage contract, so sharing a higher-level workflow
would couple unrelated extension boundaries.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import secrets
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypedDict, cast
from filelock import FileLock
from loguru import logger
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from pydantic import AnyHttpUrl, AnyUrl
from nanobot.config.paths import get_data_dir
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback"
_STORE_VERSION = 1
_STORE_LOCK_TIMEOUT_S = 15
_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}"
_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot")
_LOGO_URI = AnyHttpUrl(
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
"webui/public/brand/nanobot_apple_touch.png"
)
class _StoredServer(TypedDict, total=False):
server_fingerprint: str
write_lease: str
tokens: dict[str, Any]
client_info: dict[str, Any]
redirect_uri: str
class _CredentialStore(TypedDict):
version: int
servers: dict[str, _StoredServer]
generations: dict[str, str]
class MCPAuthorizationRequiredError(RuntimeError):
"""Raised when a background MCP connection needs interactive authorization."""
@dataclass(frozen=True)
class MCPOAuthHandlers:
"""Browser callbacks supplied only for a user-initiated OAuth attempt."""
redirect_uri: str
redirect_handler: Callable[[str], Awaitable[None]]
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
reset_credentials: bool = False
def _store_path() -> Path:
return get_data_dir() / "auth" / "mcp.json"
def _server_fingerprint(server_url: str) -> str:
return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest()
def _empty_store() -> _CredentialStore:
return {"version": _STORE_VERSION, "servers": {}, "generations": {}}
def _stored_server(value: object) -> _StoredServer | None:
if not isinstance(value, dict):
return None
raw = cast(dict[object, object], value)
entry: _StoredServer = {}
fingerprint = raw.get("server_fingerprint")
if isinstance(fingerprint, str):
entry["server_fingerprint"] = fingerprint
write_lease = raw.get("write_lease")
if isinstance(write_lease, str) and write_lease:
entry["write_lease"] = write_lease
redirect_uri = raw.get("redirect_uri")
if isinstance(redirect_uri, str):
entry["redirect_uri"] = redirect_uri
tokens = raw.get("tokens")
if isinstance(tokens, dict):
token_values = cast(dict[object, object], tokens)
if all(isinstance(key, str) for key in token_values):
entry["tokens"] = cast(dict[str, Any], token_values)
client_info = raw.get("client_info")
if isinstance(client_info, dict):
client_values = cast(dict[object, object], client_info)
if all(isinstance(key, str) for key in client_values):
entry["client_info"] = cast(dict[str, Any], client_values)
return entry
def _read_store_unlocked(path: Path) -> _CredentialStore:
try:
raw = cast(object, json.loads(path.read_text(encoding="utf-8")))
except FileNotFoundError:
return _empty_store()
except (OSError, ValueError, TypeError) as exc:
logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__)
return _empty_store()
if not isinstance(raw, dict):
return _empty_store()
payload = cast(dict[object, object], raw)
raw_servers = payload.get("servers")
if not isinstance(raw_servers, dict):
return _empty_store()
servers: dict[str, _StoredServer] = {}
for name, value in cast(dict[object, object], raw_servers).items():
entry = _stored_server(value)
if isinstance(name, str) and entry is not None:
servers[name] = entry
generations: dict[str, str] = {}
raw_generations = payload.get("generations")
if isinstance(raw_generations, dict):
for name, value in cast(dict[object, object], raw_generations).items():
if isinstance(name, str) and isinstance(value, str) and value:
generations[name] = value
return {
"version": _STORE_VERSION,
"servers": servers,
"generations": generations,
}
def _with_store_lock(path: Path) -> FileLock:
path.parent.mkdir(parents=True, exist_ok=True)
return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S)
def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(path.parent, 0o700)
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
with suppress(OSError):
os.chmod(path, 0o600)
class MCPOAuthStorage:
"""Persistent MCP SDK token storage, isolated by config name and server URL."""
def __init__(self, server_name: str, server_url: str) -> None:
self.server_name = server_name
self.server_fingerprint = _server_fingerprint(server_url)
self._observed_generation = self._read_generation_sync()
self._write_lease: str | None = None
def _read_generation_sync(self) -> str | None:
path = _store_path()
if not path.exists():
return None
# Writes replace the whole file atomically, so this observes either side
# of a concurrent deletion without blocking the async connection path.
return _read_store_unlocked(path)["generations"].get(self.server_name)
def _generation_is_current(self, payload: _CredentialStore) -> bool:
return payload["generations"].get(self.server_name) == self._observed_generation
def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None:
servers = payload["servers"]
entry = servers.get(self.server_name)
if entry is None or entry.get("server_fingerprint") != self.server_fingerprint:
return None
return entry
def _bind_entry_unlocked(
self,
payload: _CredentialStore,
*,
create: bool,
) -> tuple[_StoredServer | None, bool]:
if not self._generation_is_current(payload):
return None, False
entry = self._entry_unlocked(payload)
if self._write_lease is not None:
if entry is None or entry.get("write_lease") != self._write_lease:
return None, False
return entry, False
if entry is None:
if not create:
return None, False
self._write_lease = secrets.token_urlsafe(24)
entry = _StoredServer(
server_fingerprint=self.server_fingerprint,
write_lease=self._write_lease,
)
payload["servers"][self.server_name] = entry
return entry, True
write_lease = entry.get("write_lease")
changed = not isinstance(write_lease, str) or not write_lease
if changed:
write_lease = secrets.token_urlsafe(24)
entry["write_lease"] = write_lease
self._write_lease = write_lease
return entry, changed
def _read_entry_sync(self) -> _StoredServer | None:
path = _store_path()
with _with_store_lock(path):
payload = _read_store_unlocked(path)
entry, changed = self._bind_entry_unlocked(payload, create=False)
if changed:
_write_store_unlocked(path, payload)
return entry
def _update_entry_sync(
self,
update: Callable[[_StoredServer], None],
*,
create: bool = True,
claim: bool = False,
) -> bool:
path = _store_path()
with _with_store_lock(path):
payload = _read_store_unlocked(path)
if claim:
# A browser flow owns subsequent SDK writes until another flow
# claims the entry or the configured server is removed.
if not self._generation_is_current(payload):
logger.info(
"Ignored stale MCP OAuth credential claim for '{}'",
self.server_name,
)
return False
entry = self._entry_unlocked(payload)
if entry is None:
entry = _StoredServer(server_fingerprint=self.server_fingerprint)
payload["servers"][self.server_name] = entry
self._write_lease = secrets.token_urlsafe(24)
entry["write_lease"] = self._write_lease
else:
entry, _ = self._bind_entry_unlocked(payload, create=create)
if entry is None:
if self._write_lease is not None:
logger.info(
"Ignored stale MCP OAuth credential update for '{}'",
self.server_name,
)
return False
update(entry)
payload["version"] = _STORE_VERSION
_write_store_unlocked(path, payload)
return True
async def get_tokens(self) -> OAuthToken | None:
entry = await asyncio.to_thread(self._read_entry_sync)
raw = entry.get("tokens") if entry is not None else None
if not isinstance(raw, dict):
return None
try:
return OAuthToken.model_validate(raw)
except (ValueError, TypeError):
logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name)
return None
async def set_tokens(self, tokens: OAuthToken) -> None:
raw = tokens.model_dump(mode="json", exclude_none=True)
def update(entry: _StoredServer) -> None:
entry["tokens"] = raw
await asyncio.to_thread(self._update_entry_sync, update)
async def clear_tokens(self) -> None:
def update(entry: _StoredServer) -> None:
entry.pop("tokens", None)
await asyncio.to_thread(self._update_entry_sync, update, create=False)
async def get_client_info(self) -> OAuthClientInformationFull | None:
entry = await asyncio.to_thread(self._read_entry_sync)
raw = entry.get("client_info") if entry is not None else None
if not isinstance(raw, dict):
return None
try:
return OAuthClientInformationFull.model_validate(raw)
except (ValueError, TypeError):
logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name)
return None
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
raw = client_info.model_dump(mode="json", exclude_none=True)
def update(entry: _StoredServer) -> None:
entry["client_info"] = raw
await asyncio.to_thread(self._update_entry_sync, update)
async def redirect_uri(self) -> str | None:
entry = await asyncio.to_thread(self._read_entry_sync)
value = entry.get("redirect_uri") if entry is not None else None
return value if isinstance(value, str) and value else None
async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None:
def update(entry: _StoredServer) -> None:
changed = entry.get("redirect_uri") != redirect_uri
if reset:
entry.pop("tokens", None)
entry.pop("client_info", None)
elif changed:
# Dynamic registrations bind a client to its redirect URI.
entry.pop("client_info", None)
entry["redirect_uri"] = redirect_uri
claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True)
if not claimed:
raise MCPAuthorizationRequiredError("MCP authorization was cancelled")
def has_credentials(self) -> bool:
entry = self._read_entry_sync()
raw_tokens = entry.get("tokens") if entry is not None else None
if not isinstance(raw_tokens, dict):
return False
tokens = cast(dict[str, object], raw_tokens)
access_token = tokens.get("access_token")
return isinstance(access_token, str) and bool(access_token)
async def _missing_callback() -> tuple[str, str | None]:
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
async def create_mcp_oauth_auth(
server_name: str,
server_url: str,
handlers: MCPOAuthHandlers | None = None,
) -> OAuthClientProvider:
"""Build the official MCP SDK OAuth provider for one configured server."""
storage = MCPOAuthStorage(server_name, server_url)
if handlers is not None:
await storage.prepare_redirect_uri(
handlers.redirect_uri,
reset=handlers.reset_credentials,
)
redirect_uri = handlers.redirect_uri
redirect_handler = handlers.redirect_handler
callback_handler = handlers.callback_handler
else:
if not await asyncio.to_thread(storage.has_credentials):
# Do not perform discovery or dynamic registration from a background
# startup. Interactive OAuth begins only after an explicit user action.
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI
async def authorization_required(_authorization_url: str) -> None:
await storage.clear_tokens()
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
redirect_handler = authorization_required
callback_handler = _missing_callback
metadata = OAuthClientMetadata(
redirect_uris=[AnyUrl(redirect_uri)],
token_endpoint_auth_method="none",
client_name="nanobot",
client_uri=_CLIENT_URI,
logo_uri=_LOGO_URI,
software_id="https://github.com/HKUDS/nanobot",
)
return OAuthClientProvider(
server_url,
metadata,
storage,
redirect_handler=redirect_handler,
callback_handler=callback_handler,
timeout=300,
)
def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool:
"""Return whether this exact configured MCP instance has an access token."""
return MCPOAuthStorage(server_name, server_url).has_credentials()
def delete_mcp_oauth_credentials(server_name: str) -> bool:
"""Delete credentials for one config name without touching other MCP instances."""
path = _store_path()
with _with_store_lock(path):
payload = _read_store_unlocked(path)
servers = payload["servers"]
removed = servers.pop(server_name, None) is not None
# Rotate even when no entry exists so a flow created before removal cannot
# claim the name later and resurrect credentials.
payload["generations"][server_name] = secrets.token_urlsafe(24)
_write_store_unlocked(path, payload)
return removed
-319
View File
@@ -1,319 +0,0 @@
"""Explicit runtime state boundary used by :class:`MyTool`."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.config.schema import ModelPresetConfig
from nanobot.utils.llm_runtime import LLMRuntime
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
RUNTIME_SNAPSHOT_KEYS = frozenset({
"model",
"model_preset",
"model_presets",
"max_iterations",
"context_window_tokens",
"workspace",
"provider_retry_mode",
"max_tool_result_chars",
"current_iteration",
"_current_iteration",
"tool_names",
"web_config",
"exec_config",
"subagents",
"_last_usage",
})
RUNTIME_COMMAND_KEYS = frozenset({
"model",
"model_preset",
"max_iterations",
"context_window_tokens",
"provider_retry_mode",
"max_tool_result_chars",
"workspace",
})
@dataclass(frozen=True, slots=True)
class RuntimeSnapshot:
"""Detached, allowlisted values available to self-inspection."""
model: str
model_preset: str | None
model_presets: dict[str, dict[str, object]]
max_iterations: int
context_window_tokens: int
workspace: Path | str
provider_retry_mode: str
max_tool_result_chars: int
current_iteration: int
tool_names: list[str]
web_config: dict[str, object]
exec_config: dict[str, object]
subagent_statuses: dict[str, dict[str, object]]
last_usage: dict[str, int]
scratchpad: dict[str, JsonValue]
def as_mapping(self) -> Mapping[str, object]:
"""Return the fixed public names understood by ``MyTool``."""
values: dict[str, object] = {
"model": self.model,
"model_preset": self.model_preset,
"model_presets": self.model_presets,
"max_iterations": self.max_iterations,
"context_window_tokens": self.context_window_tokens,
"workspace": self.workspace,
"provider_retry_mode": self.provider_retry_mode,
"max_tool_result_chars": self.max_tool_result_chars,
"current_iteration": self.current_iteration,
"_current_iteration": self.current_iteration,
"tool_names": self.tool_names,
"web_config": self.web_config,
"exec_config": self.exec_config,
"subagents": {"_task_statuses": self.subagent_statuses},
"_last_usage": self.last_usage,
}
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
return values
@runtime_checkable
class RuntimeControl(Protocol):
"""The complete runtime capability exposed to ``MyTool``."""
def snapshot(self) -> RuntimeSnapshot: ...
def set_model(self, model: str) -> LLMRuntime: ...
def set_model_preset(
self,
name: str,
*,
session_key: str | None,
) -> LLMRuntime: ...
def set_max_iterations(self, value: int) -> None: ...
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
def set_provider_retry_mode(self, value: str) -> None: ...
def set_max_tool_result_chars(self, value: int) -> None: ...
def set_workspace_display(self, value: str) -> None: ...
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
class _RuntimeControlTarget(Protocol):
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
max_iterations: int
provider_retry_mode: str
max_tool_result_chars: int
web_config: WebToolsConfig
exec_config: ExecToolConfig
subagents: SubagentManager
@property
def model(self) -> str: ...
@property
def model_preset(self) -> str | None: ...
@property
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
@property
def context_window_tokens(self) -> int: ...
@property
def workspace(self) -> Path: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def last_usage(self) -> Mapping[str, int]: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
class AgentRuntimeControl:
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
def __init__(self, target: _RuntimeControlTarget) -> None:
self.__target = target
self.__scratchpad: dict[str, JsonValue] = {}
self.__workspace_display: str | None = None
def snapshot(self) -> RuntimeSnapshot:
target = self.__target
return RuntimeSnapshot(
model=target.model,
model_preset=target.model_preset,
model_presets=_snapshot_model_presets(target.model_presets),
max_iterations=target.max_iterations,
context_window_tokens=target.context_window_tokens,
workspace=(
self.__workspace_display
if self.__workspace_display is not None
else target.workspace
),
provider_retry_mode=target.provider_retry_mode,
max_tool_result_chars=target.max_tool_result_chars,
current_iteration=target.current_iteration,
tool_names=list(target.tool_names),
web_config=_snapshot_web_config(target.web_config),
exec_config=_snapshot_exec_config(target.exec_config),
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
last_usage=dict(target.last_usage),
scratchpad=_snapshot_json_mapping(self.__scratchpad),
)
def set_model(self, model: str) -> LLMRuntime:
return self.__target.set_runtime_model(model)
def set_model_preset(
self,
name: str,
*,
session_key: str | None,
) -> LLMRuntime:
if session_key is not None:
return self.__target.set_session_model_preset(session_key, name)
return self.__target.set_model_preset(name)
def set_max_iterations(self, value: int) -> None:
self.__target.max_iterations = value
self.__target.subagents.max_iterations = value
def set_context_window_tokens(self, value: int) -> LLMRuntime:
return self.__target.set_runtime_context_window(value)
def set_provider_retry_mode(self, value: str) -> None:
self.__target.provider_retry_mode = value
def set_max_tool_result_chars(self, value: int) -> None:
self.__target.max_tool_result_chars = value
def set_workspace_display(self, value: str) -> None:
"""Preserve MyTool display compatibility without changing path enforcement."""
self.__workspace_display = value
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
self.__scratchpad[key] = value
def _snapshot_model_presets(
presets: Mapping[str, ModelPresetConfig],
) -> dict[str, dict[str, object]]:
return {
name: {
"label": preset.label,
"model": preset.model,
"provider": preset.provider,
"max_tokens": preset.max_tokens,
"context_window_tokens": preset.context_window_tokens,
"temperature": preset.temperature,
"reasoning_effort": preset.reasoning_effort,
}
for name, preset in presets.items()
}
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
return {
"enable": config.enable,
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
"proxy": "<configured>" if config.proxy else config.proxy,
"user_agent": config.user_agent,
"search": {
"provider": config.search.provider,
"base_url": config.search.base_url,
"max_results": config.search.max_results,
"timeout": config.search.timeout,
},
"fetch": {
"use_jina_reader": config.fetch.use_jina_reader,
},
}
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
return {
"enable": config.enable,
"timeout": config.timeout,
"path_prepend": config.path_prepend,
"path_append": config.path_append,
"sandbox": config.sandbox,
"sandbox_ro_binds": list(config.sandbox_ro_binds),
"sandbox_rw_binds": list(config.sandbox_rw_binds),
"allowed_env_keys": list(config.allowed_env_keys),
"allow_patterns": list(config.allow_patterns),
"deny_patterns": list(config.deny_patterns),
}
def _snapshot_subagent_statuses(
manager: SubagentManager,
) -> dict[str, dict[str, object]]:
return {
task_id: _snapshot_subagent_status(status)
for task_id, status in manager.runtime_statuses().items()
}
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
return {
"task_id": status.task_id,
"label": status.label,
"task_description": status.task_description,
"started_at": status.started_at,
"phase": status.phase,
"iteration": status.iteration,
"tool_events": [dict(event) for event in status.tool_events],
"usage": dict(status.usage),
"stop_reason": status.stop_reason,
"error": status.error,
}
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {key: _snapshot_json_value(value) for key, value in values.items()}
def _snapshot_json_value(value: JsonValue) -> JsonValue:
if isinstance(value, list):
return [_snapshot_json_value(item) for item in value]
if isinstance(value, dict):
return {
key: _snapshot_json_value(item)
for key, item in value.items()
}
return value
+76
View File
@@ -0,0 +1,76 @@
"""RuntimeState protocol: agent loop state exposed to MyTool."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.utils.llm_runtime import LLMRuntime
class RuntimeState(Protocol):
"""Minimum contract that MyTool requires from its runtime state provider.
In practice, this is always satisfied by ``AgentLoop``. MyTool also
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
for dot-path inspection and modification; those paths are validated at
runtime rather than by this protocol.
"""
@property
def model(self) -> str: ...
@property
def max_iterations(self) -> int: ...
@property
def current_iteration(self) -> int: ...
@property
def tool_names(self) -> list[str]: ...
@property
def workspace(self) -> Path: ...
@property
def provider_retry_mode(self) -> str: ...
@property
def max_tool_result_chars(self) -> int: ...
@property
def context_window_tokens(self) -> int: ...
@property
def web_config(self) -> WebToolsConfig: ...
@property
def exec_config(self) -> ExecToolConfig: ...
@property
def subagents(self) -> SubagentManager: ...
@property
def _runtime_vars(self) -> dict[str, Any]: ...
@property
def _last_usage(self) -> dict[str, int]: ...
def _sync_subagent_runtime_limits(self) -> None: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_session_model_preset(
self,
session_key: str,
name: str,
) -> LLMRuntime: ...
@property
def model_preset(self) -> str | None: ...
+182 -213
View File
@@ -1,7 +1,8 @@
"""MyTool: runtime state inspection and configuration for the agent loop."""
# Tool.execute accepts heterogeneous schemas.
# pyright: reportIncompatibleMethodOverride=false
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
from __future__ import annotations
@@ -13,13 +14,7 @@ from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import current_request_context, current_request_session_key
from nanobot.agent.tools.runtime_control import (
RUNTIME_COMMAND_KEYS,
RUNTIME_SNAPSHOT_KEYS,
JsonValue,
RuntimeControl,
RuntimeSnapshot,
)
from nanobot.agent.tools.runtime_state import RuntimeState
from nanobot.config_base import Base
if TYPE_CHECKING:
@@ -33,28 +28,25 @@ class MyToolConfig(Base):
allow_set: bool = False
def _has_real_attr(obj: Any, key: str) -> bool:
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
if isinstance(obj, dict):
return key in obj
d = getattr(obj, "__dict__", None)
if d is not None and key in d:
return True
for cls in type(obj).__mro__:
if key in cls.__dict__:
return True
return False
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
if not isinstance(value, Mapping):
return False
return all(
field in value
for field in ("task_id", "label", "task_description", "started_at", "phase")
)
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
if not isinstance(value, Mapping):
return False
mapping = cast(Mapping[object, object], value)
return all(isinstance(key, str) for key in mapping)
class MyTool(Tool):
"""Check and set the agent loop's runtime configuration."""
@@ -87,10 +79,7 @@ class MyTool(Tool):
READ_ONLY = frozenset({
"subagents", # observable but replacing it would break the system
"tool_names",
"current_iteration",
"_current_iteration", # updated by runner only
"_last_usage",
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
"web_config", # inspect allowed (e.g. check enable), modify blocked
"model_presets", # config-derived catalog; changes require config reload
@@ -114,6 +103,13 @@ class MyTool(Tool):
"private_key", "access_token", "refresh_token", "auth",
})
@classmethod
def _is_sensitive_field_name(cls, name: str) -> bool:
lowered = name.lower()
return lowered in cls._SENSITIVE_NAMES or any(
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
)
RESTRICTED: dict[str, dict[str, Any]] = {
"max_iterations": {"type": int, "min": 1, "max": 100},
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
@@ -127,15 +123,15 @@ class MyTool(Tool):
"context_window_tokens",
})
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
self._runtime_control = runtime_control
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
self._runtime_state = runtime_state
self._modify_allowed = modify_allowed
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
result._runtime_control = self._runtime_control
result._runtime_state = self._runtime_state
result._modify_allowed = self._modify_allowed
return result
@@ -212,12 +208,9 @@ class MyTool(Tool):
# Path resolution
# ------------------------------------------------------------------
def _resolve_path(
self,
snapshot: RuntimeSnapshot,
path: str,
) -> tuple[object | None, str | None]:
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
parts = path.split(".")
obj: Any = self._runtime_state
for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible"
@@ -225,13 +218,17 @@ class MyTool(Tool):
return None, f"'{part}' is not accessible"
if part.lower() in self._SENSITIVE_NAMES:
return None, f"'{part}' is not accessible"
obj: object = snapshot.as_mapping()
for part in parts:
if not _is_string_mapping(obj):
return None, f"'{part}' not found"
if part not in obj:
return None, f"'{part}' not found in mapping"
obj = obj[part]
try:
if isinstance(obj, Mapping):
mapping = cast(Mapping[str, Any], obj)
if part in mapping:
obj = mapping[part]
else:
return None, f"'{part}' not found in mapping"
else:
obj = getattr(obj, part)
except (KeyError, AttributeError) as e:
return None, f"'{part}' not found: {e}"
return obj, None
@staticmethod
@@ -245,48 +242,20 @@ class MyTool(Tool):
# ------------------------------------------------------------------
@staticmethod
def _format_status(
st: "SubagentStatus | Mapping[str, object]",
indent: str = " ",
) -> str:
if isinstance(st, Mapping):
started_at = st.get("started_at", time.monotonic())
raw_events = st.get("tool_events", [])
phase = st.get("phase", "unknown")
iteration = st.get("iteration", 0)
usage = st.get("usage", {})
error = st.get("error")
stop_reason = st.get("stop_reason")
else:
started_at = st.started_at
raw_events = st.tool_events
phase = st.phase
iteration = st.iteration
usage = st.usage
error = st.error
stop_reason = st.stop_reason
elapsed = time.monotonic() - (
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
)
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
tool_summaries: list[str] = []
for raw_event in tool_events[-5:]:
if not isinstance(raw_event, Mapping):
continue
event = cast(Mapping[str, object], raw_event)
tool_summaries.append(
f"{event.get('name', '?')}({event.get('status', '?')})"
)
tool_summary = ", ".join(tool_summaries) or "none"
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:]
) or "none"
lines = [
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
f"{indent}tools: {tool_summary}",
f"{indent}usage: {usage or 'n/a'}",
f"{indent}usage: {st.usage or 'n/a'}",
]
if error:
lines.append(f"{indent}error: {error}")
if stop_reason:
lines.append(f"{indent}stop_reason: {stop_reason}")
if st.error:
lines.append(f"{indent}error: {st.error}")
if st.stop_reason:
lines.append(f"{indent}stop_reason: {st.stop_reason}")
return "\n".join(lines)
@staticmethod
@@ -295,38 +264,29 @@ class MyTool(Tool):
header = f"Subagent [{val.task_id}] '{val.label}'"
detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}"
if _is_subagent_status_snapshot(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
task_statuses = getattr(val, "_task_statuses", None)
if isinstance(task_statuses, dict):
return MyTool._format_value(task_statuses, key)
if isinstance(val, Mapping):
mapping = cast(Mapping[object, object], val)
else:
mapping = None
if mapping and set(mapping) == {"_task_statuses"}:
task_statuses = mapping["_task_statuses"]
if isinstance(task_statuses, Mapping):
return MyTool._format_value(task_statuses, key)
if (
mapping
and (
_is_subagent_status(next(iter(mapping.values())))
or _is_subagent_status_snapshot(next(iter(mapping.values())))
)
and _is_subagent_status(next(iter(mapping.values())))
):
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(mapping)} subagent(s):"]
for tid, st in mapping.items():
if _is_subagent_status(st):
detail = MyTool._format_status(st, " ")
label = st.label
elif _is_subagent_status_snapshot(st):
detail = MyTool._format_status(st, " ")
label = st.get("label", "?")
else:
continue
lines.append(f" [{tid}] '{label}'\n{detail}")
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
for tid, st in status_mapping.items():
detail = MyTool._format_status(st, " ")
lines.append(f" [{tid}] '{st.label}'\n{detail}")
return "\n".join(lines)
dynamic_value = cast(Any, val)
if hasattr(dynamic_value, "tool_names"):
tool_names: Any = getattr(dynamic_value, "tool_names")
return f"tools: {len(tool_names)} registered — {tool_names}"
# Scalar types — repr is fine
if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val)
@@ -351,6 +311,32 @@ class MyTool(Tool):
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
r = repr(sequence)
return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation
value_type = type(cast(object, val))
cls_name = value_type.__name__
model_fields = cast(object, getattr(value_type, "model_fields", None))
if isinstance(model_fields, Mapping) and model_fields:
fields = list(cast(Mapping[str, object], model_fields).keys())
if len(fields) <= 8:
# Small config objects: show field=value pairs
pairs: list[str] = []
for f in fields:
fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
continue
if isinstance(fv, (str, int, float, bool, type(None))):
pairs.append(f"{f}={fv!r}")
else:
pairs.append(f"{f}=<{type(fv).__name__}>")
preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview
else:
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
fields = [name for name in attributes if not name.startswith("__")]
if fields:
preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else ""
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
r = repr(val)
return f"{key}: {r}" if key else r
@@ -380,12 +366,7 @@ class MyTool(Tool):
runtime = request_ctx.runtime if request_ctx is not None else None
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
return False, None
values: dict[str, object] = {
"model": runtime.model,
"model_preset": runtime.model_preset,
"context_window_tokens": runtime.context_window_tokens,
}
return True, values[key]
return True, getattr(runtime, key)
def _inspect(self, key: str | None) -> str:
if not key:
@@ -394,64 +375,62 @@ class MyTool(Tool):
request_ctx = current_request_context()
if request_ctx is None:
return ToolResult.error("Error: current request context is unavailable")
request_values: dict[str, str | None] = {
"channel": request_ctx.channel,
"chat_id": request_ctx.chat_id,
"sender_id": request_ctx.sender_id,
}
if key == "request":
return self._format_value(request_values, key)
return self._format_value(
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
key,
)
field = key.removeprefix("request.")
if field not in self._REQUEST_FIELDS:
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(request_values[field], key)
return self._format_value(getattr(request_ctx, field), key)
if "." not in key:
found, value = self._current_runtime_value(key)
if found:
return self._format_value(value, key)
snapshot = self._runtime_control.snapshot()
top = key.split(".")[0]
if top in self._DENIED_ATTRS or top.startswith("__"):
return ToolResult.error(f"Error: '{top}' is not accessible")
obj, err = self._resolve_path(snapshot, key)
obj, err = self._resolve_path(key)
if err:
# "scratchpad" alias for _runtime_vars
if key == "scratchpad":
return (
self._format_value(snapshot.scratchpad, "scratchpad")
if snapshot.scratchpad
else "scratchpad is empty"
)
if "." not in key and key in snapshot.scratchpad:
return self._format_value(snapshot.scratchpad[key], key)
rv = self._runtime_state._runtime_vars
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
# Fallback: check _runtime_vars for simple keys stored by modify
if "." not in key and key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return ToolResult.error(f"Error: {err}")
# Guard against mock auto-generated attributes
if "." not in key and not _has_real_attr(self._runtime_state, key):
if key in self._runtime_state._runtime_vars:
return self._format_value(self._runtime_state._runtime_vars[key], key)
return ToolResult.error(f"Error: '{key}' not found")
return self._format_value(obj, key)
def _inspect_all(self) -> str:
snapshot = self._runtime_control.snapshot()
values = snapshot.as_mapping()
state = self._runtime_state
parts: list[str] = []
# RESTRICTED keys
for k in self.RESTRICTED:
found, value = self._current_runtime_value(k)
parts.append(self._format_value(value if found else values[k], k))
parts.append(self._format_value(value if found else getattr(state, k, None), k))
found, value = self._current_runtime_value("model_preset")
parts.append(self._format_value(
value if found else snapshot.model_preset,
value if found else state.model_preset,
"model_preset",
))
for k in (
"workspace",
"provider_retry_mode",
"max_tool_result_chars",
"_current_iteration",
"web_config",
"exec_config",
"subagents",
):
parts.append(self._format_value(values[k], k))
if snapshot.last_usage:
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
if snapshot.scratchpad:
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
# 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", "workspace_sandbox", "subagents"):
if _has_real_attr(state, k):
parts.append(self._format_value(getattr(state, k, None), k))
# Token usage
usage = state._last_usage
if usage:
parts.append(self._format_value(usage, "_last_usage"))
rv = state._runtime_vars
if rv:
parts.append(self._format_value(rv, "scratchpad"))
return "\n".join(parts)
# -- modify --
@@ -475,49 +454,48 @@ class MyTool(Tool):
if leaf.lower() in self._SENSITIVE_NAMES:
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
return ToolResult.error(f"Error: '{leaf}' is not accessible")
snapshot = self._runtime_control.snapshot()
_parent, err = self._resolve_path(snapshot, parent_path)
parent, err = self._resolve_path(parent_path)
if err:
return ToolResult.error(f"Error: {err}")
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
if isinstance(parent, dict):
parent[leaf] = value
else:
setattr(parent, leaf, value)
self._audit("modify", f"{key} = {value!r}")
return f"Set {key} = {value!r}"
if key == "model_preset":
return self._modify_model_preset(value)
if key in self.RESTRICTED:
return self._modify_restricted(key, value)
if key in RUNTIME_COMMAND_KEYS:
return self._modify_runtime_setting(key, value)
if key in RUNTIME_SNAPSHOT_KEYS:
self._audit("modify", f"READ_ONLY {key}")
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
return self._modify_scratchpad(key, value)
return self._modify_free(key, value)
def _modify_model_preset(self, value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
name = value.strip()
session_key = current_request_session_key()
old = self._runtime_control.snapshot().model_preset
try:
runtime = self._runtime_control.set_model_preset(
name,
session_key=session_key,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
if session_key:
try:
runtime = self._runtime_state.set_session_model_preset(
session_key,
name,
)
except (KeyError, ValueError) as exc:
message = str(exc.args[0]) if exc.args else str(exc)
punctuation = "" if message.endswith((".", "!", "?")) else "."
return ToolResult.error(f"Error: {message}{punctuation}")
self._audit("modify", f"model_preset = {name!r}")
return (
f"Set model_preset = {name!r} for the next turn; "
f"model will be {runtime.model!r}; "
f"context_window_tokens will be {runtime.context_window_tokens!r}"
)
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
result = self._modify_free("model_preset", name)
if isinstance(result, ToolResult) and result.is_error:
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
return (
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
f"context_window_tokens is now {runtime.context_window_tokens!r}"
f"{result}; model is now {self._runtime_state.model!r}; "
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
)
def _modify_restricted(self, key: str, value: Any) -> str:
@@ -530,7 +508,7 @@ class MyTool(Tool):
value = expected(value)
except (ValueError, TypeError):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
old = self._runtime_control.snapshot().as_mapping()[key]
old = getattr(self._runtime_state, key)
if "min" in spec and value < spec["min"]:
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
if "max" in spec and value > spec["max"]:
@@ -543,46 +521,41 @@ class MyTool(Tool):
"during an active session; use a configured model_preset"
)
if key == "model":
self._runtime_control.set_model(cast(str, value))
self._runtime_state.set_runtime_model(cast(str, value))
elif key == "context_window_tokens":
self._runtime_control.set_context_window_tokens(cast(int, value))
self._runtime_state.set_runtime_context_window(cast(int, value))
else:
self._runtime_control.set_max_iterations(cast(int, value))
setattr(self._runtime_state, key, value)
if key == "max_iterations" and hasattr(
self._runtime_state,
"_sync_subagent_runtime_limits",
):
self._runtime_state._sync_subagent_runtime_limits()
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_runtime_setting(self, key: str, value: Any) -> str:
old = self._runtime_control.snapshot().as_mapping()[key]
if key == "workspace":
if not isinstance(value, str):
return ToolResult.error(
f"Error: 'workspace' expects str, got {type(value).__name__}"
)
self._runtime_control.set_workspace_display(value)
self._audit("modify", f"workspace: {old!r} -> {value!r}")
return f"Set workspace = {value!r} (was {old!r})"
old_t = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
)
if key == "provider_retry_mode":
self._runtime_control.set_provider_retry_mode(cast(str, value))
elif key == "max_tool_result_chars":
self._runtime_control.set_max_tool_result_chars(cast(int, value))
else:
raise AssertionError(f"Unhandled runtime command: {key}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
def _modify_scratchpad(self, key: str, value: Any) -> str:
def _modify_free(self, key: str, value: Any) -> str:
if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, key)
if isinstance(old, (str, int, float, bool)):
old_t: type[Any] = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass # int → float coercion allowed
elif old_t is not new_t:
self._audit(
"modify",
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
)
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
try:
setattr(self._runtime_state, key, value)
except (ValueError, KeyError) as e:
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
self._audit("modify", f"REJECTED {key}: {message}")
return ToolResult.error(f"Error: {message}")
self._audit("modify", f"{key}: {old!r} -> {value!r}")
return f"Set {key} = {value!r} (was {old!r})"
if callable(value):
self._audit("modify", f"REJECTED callable {key}")
return ToolResult.error("Error: cannot store callable values")
@@ -590,16 +563,12 @@ class MyTool(Tool):
if err:
self._audit("modify", f"REJECTED {key}: {err}")
return ToolResult.error(f"Error: {err}")
try:
self._runtime_control.set_scratchpad(
key,
cast(JsonValue, value),
max_keys=self._MAX_RUNTIME_KEYS,
)
except ValueError as exc:
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
self._audit("modify", f"scratchpad.{key} = {value!r}")
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
old = self._runtime_state._runtime_vars.get(key)
self._runtime_state._runtime_vars[key] = value
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
return f"Set scratchpad.{key} = {value!r}"
@classmethod
+17 -51
View File
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
import httpx
from loguru import logger
from nanobot.agent.skills import normalize_skill_document
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
@@ -28,7 +27,6 @@ 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"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = (
@@ -212,27 +210,11 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _skill_name(name: str, *, legacy: bool = False) -> str:
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
if not legacy:
clean = clean.replace("_", "-")
return f"cli-app-{clean or 'app'}"
def _plugin_skill_relative_path(name: str) -> str:
skill_name = _skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
"""Return a CLI App's skill path, including the legacy location."""
canonical = _plugin_skill_relative_path(name)
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
return legacy
return canonical
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
@@ -631,7 +613,7 @@ class CliAppManager:
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"tool": "run_cli_app",
}
)
@@ -657,6 +639,9 @@ class CliAppManager:
install_cmd = str(app.get("install_cmd") or "")
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload(
self,
app: dict[str, Any],
@@ -692,7 +677,7 @@ class CliAppManager:
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
"skill_installed": self._skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
@@ -728,8 +713,7 @@ class CliAppManager:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_skill_name(name)}"
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
capabilities = [
compact_dict({
"type": "cli",
@@ -742,13 +726,13 @@ class CliAppManager:
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [plugin_path],
"managed_paths": [skill_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [plugin_path],
"managed_paths": [skill_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
@@ -1048,10 +1032,11 @@ 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 = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
description = _catalog_description(app) or f"Use {display} from nanobot."
return f"""---
name: {_skill_name(name)}
description: {json.dumps(description, ensure_ascii=False)}
name: {_safe_skill_name(name)}
description: >-
{description}
---
# {display}
@@ -1088,43 +1073,24 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
return note + "\n" + content
def install_skill(self, app: dict[str, Any]) -> Path:
name = str(app["name"])
path = self.workspace / _plugin_skill_relative_path(name)
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2]
manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA,
"name": _skill_name(str(app["name"])),
"version": str(app.get("version") or ""),
"description": _catalog_description(app),
})
_write_json(plugin_root / "plugin.json", manifest)
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
return path
def remove_skill(self, name: str) -> None:
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
if plugin_root.is_dir():
shutil.rmtree(plugin_root)
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
from nanobot.agent.plugins import set_agent_plugin_enabled
installed = self._load_installed()
entry = self._installed_entry(app)
installed[str(app["name"])] = entry
self._save_installed(installed)
self.install_skill(app)
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
return entry
def install(self, name: str) -> dict[str, Any]:
+1 -3
View File
@@ -20,8 +20,6 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
from nanobot.apps.cli.service import cli_app_skill_relative_path
structured_items = cast(list[Any], structured)
mentions = [
cast(Mapping[str, Any], item) for item in structured_items
@@ -34,7 +32,7 @@ def runtime_lines_for_request(
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
@@ -15,7 +15,6 @@ import type {
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
import { FeishuConnectFlow } from "./FeishuConnectFlow";
@@ -34,6 +33,7 @@ export function FeishuAssistantsPanel({
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
@@ -92,7 +92,6 @@ function FeishuInstanceAction({
instance: NanobotChannelInstanceInfo;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { client } = useClient();
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const [busy, setBusy] = useState(false);
@@ -115,7 +114,7 @@ function FeishuInstanceAction({
setError(null);
try {
onFeaturesUpdate(
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
);
} catch (err) {
setError((err as Error).message);
-7
View File
@@ -101,14 +101,8 @@ class ChannelManager:
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
config_path: Path | None = None,
):
if config_path is None:
from nanobot.config.loader import get_config_path
config_path = get_config_path()
self.config = config
self._config_path = config_path.expanduser().resolve(strict=False)
self.bus = bus
self._session_manager = session_manager
self._cron_service = cron_service
@@ -176,7 +170,6 @@ class ChannelManager:
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
config_path=self._config_path,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
-162
View File
@@ -373,13 +373,6 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[ServerConnection] = set()
# Request/reply mutations aren't replayed across reconnects. Tasks may
# finish after a client-side deadline so an already-started mutation
# isn't ambiguously cancelled halfway through.
self._webui_request_tasks: dict[
tuple[ServerConnection, str],
asyncio.Task[None],
] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
@@ -765,9 +758,6 @@ class WebSocketChannel(BaseChannel):
) -> None:
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
t = envelope.get("type")
if t == "webui_request":
await self._start_webui_request(connection, envelope)
return
if t == "new_chat":
new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error(
@@ -1115,152 +1105,6 @@ class WebSocketChannel(BaseChannel):
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def _start_webui_request(
self,
connection: ServerConnection,
envelope: dict[str, Any],
) -> None:
request_id = envelope.get("request_id")
if not isinstance(request_id, str) or re.fullmatch(
r"[A-Za-z0-9._:-]{1,128}",
request_id,
) is None:
await self._send_event(
connection,
"error",
detail="invalid webui request_id",
)
return
if connection not in self._webui_connections:
await self._send_webui_response(
connection,
request_id,
status=403,
message="access_denied",
)
return
action = envelope.get("action")
payload = envelope.get("payload")
if not isinstance(action, str) or re.fullmatch(
r"[a-z][a-z0-9_.]{0,127}",
action,
) is None:
await self._send_webui_response(
connection,
request_id,
status=400,
message="invalid WebUI mutation action",
)
return
if not isinstance(payload, dict):
await self._send_webui_response(
connection,
request_id,
status=400,
message="WebUI mutation payload must be an object",
)
return
key = (connection, request_id)
if key in self._webui_request_tasks:
await self._send_webui_response(
connection,
request_id,
status=409,
message="duplicate WebUI request_id",
)
return
task = asyncio.create_task(
self._complete_webui_request(
connection,
request_id,
action,
cast(dict[str, Any], payload),
)
)
self._webui_request_tasks[key] = task
async def _complete_webui_request(
self,
connection: ServerConnection,
request_id: str,
action: str,
payload: dict[str, Any],
) -> None:
try:
response = await self._http_router.dispatch_webui_mutation(
connection,
action,
payload,
)
status = response.status_code
body = bytes(response.body).decode("utf-8", errors="replace").strip()
if 200 <= status < 300:
try:
result = json.loads(body)
except json.JSONDecodeError:
await self._send_webui_response(
connection,
request_id,
status=502,
message="WebUI mutation returned an invalid response",
)
return
await self._send_webui_response(
connection,
request_id,
result=result,
)
return
await self._send_webui_response(
connection,
request_id,
status=status,
message=body or response.reason_phrase,
)
except asyncio.CancelledError:
raise
except Exception:
self.logger.exception("WebUI mutation '{}' failed", action)
await self._send_webui_response(
connection,
request_id,
status=500,
message="WebUI mutation failed",
)
finally:
self._webui_request_tasks.pop((connection, request_id), None)
async def _send_webui_response(
self,
connection: ServerConnection,
request_id: str,
*,
result: Any = None,
status: int | None = None,
message: str | None = None,
) -> None:
if status is None:
await self._send_event(
connection,
"webui_response",
request_id=request_id,
ok=True,
result=result,
)
return
await self._send_event(
connection,
"webui_response",
request_id=request_id,
ok=False,
error={
"status": status,
"message": message or "WebUI mutation failed",
},
)
async def _workspace_scope_or_error(
self,
connection: ServerConnection,
@@ -1301,12 +1145,6 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
mutation_tasks = tuple(self._webui_request_tasks.values())
for task in mutation_tasks:
task.cancel()
if mutation_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True)
self._webui_request_tasks.clear()
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
@@ -3,16 +3,12 @@
import asyncio
import json
import time
import uuid
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import websockets
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
@@ -46,12 +42,6 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
from nanobot.webui.http_utils import (
http_error as _http_error,
)
from nanobot.webui.http_utils import (
http_json_response as _http_json_response,
)
from nanobot.webui.http_utils import (
issue_route_secret_matches as _issue_route_secret_matches,
)
@@ -129,38 +119,6 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
)
async def _webui_mutate(
client: Any,
action: str,
payload: dict[str, Any] | None = None,
) -> httpx.Response:
request_id = f"test-{uuid.uuid4().hex}"
await client.send(json.dumps({
"type": "webui_request",
"request_id": request_id,
"action": action,
"payload": payload or {},
}))
while True:
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
if envelope.get("event") != "webui_response":
continue
if envelope.get("request_id") != request_id:
continue
if envelope.get("ok") is True:
status = 200
body = envelope.get("result")
else:
error = envelope.get("error") or {}
status = int(error.get("status") or 500)
body = {"error": str(error.get("message") or "WebUI mutation failed")}
return httpx.Response(
status,
json=body,
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
)
@pytest.mark.asyncio
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
channel = _ch(MessageBus())
@@ -899,98 +857,6 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
assert client_connection not in channel._webui_connections
@pytest.mark.asyncio
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_json_response({"saved": True})
)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-1",
"action": "settings.provider.update",
"payload": {"provider": "openrouter", "apiKey": "secret"},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
conn,
"settings.provider.update",
{"provider": "openrouter", "apiKey": "secret"},
)
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-1",
"ok": True,
"result": {"saved": True},
}
@pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
return_value=_http_error(400, "invalid settings payload")
)
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-2",
"action": "settings.agent.update",
"payload": {},
},
)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-2",
"ok": False,
"error": {"status": 400, "message": "invalid settings payload"},
}
@pytest.mark.asyncio
async def test_webui_request_requires_bootstrap_authenticated_connection(
bus: MagicMock,
) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
await channel._dispatch_envelope(
conn,
"static-token-client",
{
"type": "webui_request",
"request_id": "request-3",
"action": "settings.agent.update",
"payload": {},
},
)
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": "request-3",
"ok": False,
"error": {"status": 403, "message": "access_denied"},
}
@pytest.mark.asyncio
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
bus: MagicMock,
@@ -1000,33 +866,23 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus)
conn = AsyncMock()
conn.request = SimpleNamespace(headers=Headers())
channel._webui_connections.add(conn)
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
request_id = "sidebar-large-state"
envelope = {
"type": "webui_request",
"request_id": request_id,
"action": "sidebar.update",
"payload": {"state": {
"type": "set_sidebar_state",
"state": {
"session_order": session_order,
"view": {"sort": "manual"},
}},
},
}
assert len(json.dumps(envelope).encode()) > 8_192
await channel._dispatch_envelope(conn, "webui-client", envelope)
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
assert saved["session_order"] == session_order
assert saved["view"]["sort"] == "manual"
assert json.loads(conn.send.await_args.args[0]) == {
"event": "webui_response",
"request_id": request_id,
"ok": True,
"result": saved,
}
conn.send.assert_not_awaited()
@pytest.mark.asyncio
@@ -3031,15 +2887,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
)
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
assert ready["event"] == "ready"
settings = await _http_get(
f"http://127.0.0.1:{port}/api/settings",
headers={"Authorization": "Bearer tok"},
@@ -3123,14 +2971,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert unknown_api.status_code == 404
assert "<!doctype html>" not in unknown_api.text.lower()
provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{
"provider": "openrouter",
"apiKey": "sk-or-test",
"apiBase": "https://openrouter.ai/api/v1",
},
provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert provider_updated.status_code == 200
provider_body = provider_updated.json()
@@ -3140,18 +2985,22 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert provider_body["image_generation"]["provider_configured"] is True
assert "sk-or-test" not in provider_updated.text
custom_provider_created = await _webui_mutate(
webui_client,
"settings.provider.create",
{
"name": "Company Gateway",
"apiBase": "https://gateway.example/v1",
"apiKey": "sk-company",
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"extraBody": json.dumps({"service_tier": "priority"}),
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
custom_provider_created = await _http_get(
f"http://127.0.0.1:{port}/api/settings/provider/create",
headers={
"Authorization": "Bearer tok",
"X-Nanobot-Provider-Values": json.dumps(
{
"name": "Company Gateway",
"apiBase": "https://gateway.example/v1",
"apiKey": "sk-company",
"extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"extraBody": json.dumps({"service_tier": "priority"}),
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
}
),
},
)
assert custom_provider_created.status_code == 200
@@ -3166,10 +3015,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert "sk-company" not in custom_provider_created.text
local_provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
local_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=atomic_chat"
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json()
@@ -3179,44 +3029,38 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text
updated = await _webui_mutate(
webui_client,
"settings.agent.update",
{
"model": "atomic_chat/test",
"provider": "atomic_chat",
"timezone": "Asia/Shanghai",
"tool_hint_max_length": 120,
},
updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model=atomic_chat/test"
"&provider=atomic_chat&timezone=Asia%2FShanghai"
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
headers={"Authorization": "Bearer tok"},
)
assert updated.status_code == 200
updated_body = updated.json()
assert updated_body["requires_restart"] is True
assert updated_body["restart_required_sections"] == ["runtime"]
preset_updated = await _webui_mutate(
webui_client,
"settings.agent.update",
{"model_preset": "deep"},
preset_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=deep",
headers={"Authorization": "Bearer tok"},
)
assert preset_updated.status_code == 200
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
bad_preset = await _webui_mutate(
webui_client,
"settings.agent.update",
{"model_preset": "missing"},
bad_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/update?model_preset=missing",
headers={"Authorization": "Bearer tok"},
)
assert bad_preset.status_code == 400
created_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
created_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
)
assert created_preset.status_code == 200
created_body = created_preset.json()
@@ -3230,15 +3074,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert created_presets["fast-writing"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai"
updated_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.update",
{
"name": "fast-writing",
"label": "Codex",
"provider": "openai",
"model": "openai/gpt-5.5",
},
updated_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/update"
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
headers={"Authorization": "Bearer tok"},
)
assert updated_preset.status_code == 200
updated_preset_body = updated_preset.json()
@@ -3249,10 +3089,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert updated_presets["fast-writing"]["label"] == "Codex"
call_order_updated = await _webui_mutate(
webui_client,
"settings.model_call_order.update",
{"order": ["fast-writing", "deep"]},
call_order_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-call-order/update"
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
headers={"Authorization": "Bearer tok"},
)
assert call_order_updated.status_code == 200
call_order_body = call_order_updated.json()
@@ -3260,27 +3101,20 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
duplicate_preset = await _webui_mutate(
webui_client,
"settings.model_configuration.create",
{
"label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
duplicate_preset = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/model-configurations/create"
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
headers={"Authorization": "Bearer tok"},
)
assert duplicate_preset.status_code == 409
search_updated = await _webui_mutate(
webui_client,
"settings.web_search.update",
{
"provider": "searxng",
"base_url": "https://search.example.com",
"max_results": 8,
"timeout": 45,
"use_jina_reader": False,
},
search_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=searxng"
"&base_url=https%3A%2F%2Fsearch.example.com"
"&max_results=8&timeout=45&use_jina_reader=false",
headers={"Authorization": "Bearer tok"},
)
assert search_updated.status_code == 200
search_body = search_updated.json()
@@ -3292,13 +3126,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert search_body["web_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False
network_safety_updated = await _webui_mutate(
webui_client,
"settings.network_safety.update",
{
"webui_allow_local_service_access": False,
"webui_default_access_mode": "full",
},
network_safety_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
headers={"Authorization": "Bearer tok"},
)
assert network_safety_updated.status_code == 200
network_safety_body = network_safety_updated.json()
@@ -3308,17 +3139,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
image_updated = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{
"enabled": True,
"provider": "openrouter",
"model": "openai/gpt-image-1",
"default_aspect_ratio": "16:9",
"default_image_size": "2K",
"max_images_per_turn": 3,
},
image_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?enabled=true"
"&provider=openrouter&model=openai%2Fgpt-image-1"
"&default_aspect_ratio=16%3A9&default_image_size=2K"
"&max_images_per_turn=3",
headers={"Authorization": "Bearer tok"},
)
assert image_updated.status_code == 200
image_body = image_updated.json()
@@ -3330,14 +3157,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert image_body["image_generation"]["default_image_size"] == "2K"
assert image_body["image_generation"]["max_images_per_turn"] == 3
image_provider_updated = await _webui_mutate(
webui_client,
"settings.provider.update",
{
"provider": "openrouter",
"apiKey": "sk-or-next",
"apiBase": "https://openrouter.ai/api/v1",
},
image_provider_updated = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/provider/update?provider=openrouter"
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
headers={"Authorization": "Bearer tok"},
)
assert image_provider_updated.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True
@@ -3345,17 +3169,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert "sk-or-next" not in image_provider_updated.text
assert image_reload.await_count == 2
bad_web = await _webui_mutate(
webui_client,
"settings.web_search.update",
{"provider": "duckduckgo", "max_results": 99},
bad_web = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
headers={"Authorization": "Bearer tok"},
)
assert bad_web.status_code == 400
bad_image = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"provider": "missing"},
bad_image = await _http_get(
"http://127.0.0.1:"
f"{port}/api/settings/image-generation/update?provider=missing",
headers={"Authorization": "Bearer tok"},
)
assert bad_image.status_code == 400
@@ -3392,8 +3216,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert saved.tools.image_generation.default_image_size == "2K"
assert saved.tools.image_generation.max_images_per_turn == 3
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
@@ -3426,17 +3248,11 @@ async def test_image_settings_hot_reload_without_restart(
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
)
assert json.loads(await webui_client.recv())["event"] == "ready"
response = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
response = await _http_get(
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
headers={"Authorization": "Bearer tok"},
)
assert response.status_code == 200
@@ -3444,8 +3260,6 @@ async def test_image_settings_hot_reload_without_restart(
assert response.json()["restart_required_sections"] == []
image_reload.assert_awaited_once_with(bus)
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
@@ -3477,25 +3291,17 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
webui_client = None
try:
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
webui_client = await websockets.connect(
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
)
assert json.loads(await webui_client.recv())["event"] == "ready"
response = await _webui_mutate(
webui_client,
"settings.image_generation.update",
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
response = await _http_get(
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
headers={"Authorization": "Bearer tok"},
)
assert response.status_code == 200
assert response.json()["requires_restart"] is True
assert response.json()["restart_required_sections"] == ["image"]
finally:
if webui_client is not None:
await webui_client.close()
await channel.stop()
await server_task
File diff suppressed because it is too large Load Diff
+3 -16
View File
@@ -22,7 +22,6 @@ class WeixinConnectSession:
channel: WeixinChannel
current_poll_base_url: str
refresh_count: int
force: bool
created_wall: float
deadline: float
last_error: str | None = None
@@ -73,7 +72,7 @@ class WeixinConnectStore:
channel.connect_open_client()
try:
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
except Exception as exc:
await self._close_channel(channel)
raise ChannelConnectError(
@@ -90,7 +89,6 @@ class WeixinConnectStore:
channel=channel,
current_poll_base_url=channel.connect_base_url,
refresh_count=0,
force=force,
created_wall=now_wall,
deadline=time.monotonic() + 600,
)
@@ -189,7 +187,7 @@ class WeixinConnectStore:
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code(force=session.force)
await session.channel.connect_fetch_qr_code()
)
except Exception as exc:
self._sessions.pop(session_id, None)
@@ -206,17 +204,6 @@ class WeixinConnectStore:
)
if status == "binded_redirect":
if session.force:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": (
"Unable to complete a new WeChat login. "
"Start again and scan with the account you want to connect."
),
}
if not session.channel.connect_load_state():
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
@@ -247,7 +234,7 @@ class WeixinConnectStore:
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code(force=session.force)
await session.channel.connect_fetch_qr_code()
)
except Exception as exc:
self._sessions.pop(session_id, None)
+12 -17
View File
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
break
return tokens
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
"""Fetch a QR code without existing credentials when forced."""
local_tokens = [] if force else self._local_token_list()
async def _fetch_qr_code(self) -> tuple[str, str]:
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
local_tokens = self._local_token_list()
data = await self._api_post(
"ilink/bot/get_bot_qrcode?bot_type=3",
{"local_token_list": local_tokens},
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
return qrcode_id, (qrcode_img_content or qrcode_id)
async def _qr_login(self, *, force: bool = False) -> bool:
"""Perform QR login; forced flows accept only newly confirmed credentials."""
async def _qr_login(self) -> bool:
"""Perform QR code login flow. Returns True on success."""
try:
refresh_count = 0
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
qrcode_id, scan_url = await self._fetch_qr_code()
self._print_qr_code(scan_url)
current_poll_base_url = self.config.base_url
verify_code = ""
@@ -825,16 +825,11 @@ class WeixinChannel(BaseChannel):
if refresh_count > MAX_QR_REFRESH_COUNT:
self.logger.warning("WeChat verification failed too many times")
return False
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
qrcode_id, scan_url = await self._fetch_qr_code()
current_poll_base_url = self.config.base_url
self._print_qr_code(scan_url)
continue
elif status == "binded_redirect":
if force:
self.logger.error(
"Forced WeChat login returned an existing binding without new credentials"
)
return False
if self._token or self._load_state():
self.logger.info("WeChat account is already connected")
return True
@@ -851,7 +846,7 @@ class WeixinChannel(BaseChannel):
MAX_QR_REFRESH_COUNT,
)
return False
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
qrcode_id, scan_url = await self._fetch_qr_code()
current_poll_base_url = self.config.base_url
verify_code = ""
self._print_qr_code(scan_url)
@@ -898,8 +893,8 @@ class WeixinChannel(BaseChannel):
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
self._running = True
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
return await self._fetch_qr_code(force=force)
async def connect_fetch_qr_code(self) -> tuple[str, str]:
return await self._fetch_qr_code()
async def connect_poll_qr_code(
self,
@@ -952,14 +947,14 @@ class WeixinChannel(BaseChannel):
if force:
self._token = ""
self._get_updates_buf = ""
if self._token or (not force and self._load_state()):
if self._token or self._load_state():
return True
# Initialize HTTP client for the login flow
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
self._running = True # Enable polling loop in _qr_login()
try:
return await self._qr_login(force=force)
return await self._qr_login()
finally:
self._running = False
if self._client:
+11 -41
View File
@@ -25,9 +25,7 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
@@ -88,31 +86,14 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
observed_force: list[bool] = []
async def fake_fetch_qr_code(
self: WeixinChannel,
*,
force: bool = False,
) -> tuple[str, str]:
observed_force.append(force)
return f"qr-reconnect-{len(observed_force)}", "https://qr.example/reconnect"
async def fake_api_get_with_base(
self: WeixinChannel,
**_kwargs: Any,
) -> dict[str, str]:
return {"status": "expired"}
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-reconnect", "https://qr.example/reconnect"
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start(force=True)
refreshed = await store.poll(started["session_id"])
assert refreshed["status"] == "pending"
assert observed_force == [True, True]
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
cancelled = await store.cancel(started["session_id"])
assert cancelled["status"] == "cancelled"
@@ -135,9 +116,7 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
poll_started = asyncio.Event()
release_poll = asyncio.Event()
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-cancel", "https://qr.example/cancel"
async def fake_api_get_with_base(
@@ -183,9 +162,7 @@ async def test_weixin_connect_store_handles_verification_code(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-verify", "https://qr.example/verify"
responses = [
@@ -227,7 +204,7 @@ async def test_weixin_connect_store_handles_verification_code(
@pytest.mark.asyncio
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
async def test_weixin_connect_store_treats_existing_binding_as_success(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -244,12 +221,7 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(
self: WeixinChannel,
*,
force: bool = False,
) -> tuple[str, str]:
assert force is True
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-existing", "https://qr.example/existing"
async def fake_api_get_with_base(
@@ -265,8 +237,8 @@ async def test_weixin_connect_store_rejects_existing_binding_during_forced_login
started = await store.start(force=True)
completed = await store.poll(started["session_id"])
assert completed["status"] == "failed"
assert "new WeChat login" in completed["message"]
assert completed["status"] == "succeeded"
assert "already connected" in completed["message"]
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
@@ -283,9 +255,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-missing", "https://qr.example/missing"
async def fake_api_get_with_base(
@@ -298,7 +268,7 @@ async def test_weixin_connect_store_rejects_existing_binding_without_local_crede
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start(force=False)
started = await store.start(force=True)
completed = await store.poll(started["session_id"])
assert completed["status"] == "failed"
@@ -196,86 +196,6 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
persisted = {
"token": "persisted-token",
"get_updates_buf": "persisted-cursor",
"context_tokens": {"wx-user": "ctx-persisted"},
"typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
"base_url": "https://persisted.example",
}
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
(tmp_path / "account.json").write_text(
json.dumps(persisted),
encoding="utf-8",
)
channel._print_qr_code = lambda _url: None
channel._api_post = AsyncMock(
side_effect=[
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
]
)
channel._api_get_with_base = AsyncMock(
side_effect=[
{"status": "expired"},
{"status": "binded_redirect"},
]
)
ok = await channel.login(force=True)
assert ok is False
assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
[],
[],
]
assert channel._token == ""
assert channel._get_updates_buf == ""
assert channel._context_tokens == {}
assert channel._typing_tickets == {}
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
(tmp_path / "account.json").write_text(
json.dumps(
{
"token": "persisted-token",
"get_updates_buf": "persisted-cursor",
"context_tokens": {"wx-user": "ctx-persisted"},
"base_url": "https://persisted.example",
}
),
encoding="utf-8",
)
channel._qr_login = AsyncMock(return_value=False)
ok = await channel.login(force=False)
assert ok is True
channel._qr_login.assert_not_awaited()
assert channel._token == "persisted-token"
assert channel._get_updates_buf == "persisted-cursor"
assert channel._context_tokens == {"wx-user": "ctx-persisted"}
assert channel.config.base_url == "https://persisted.example"
@pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel()
@@ -27,7 +27,6 @@ import type {
NanobotFeatureInfo,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import {
WEIXIN_AUTH_EXPIRED_MESSAGE,
@@ -65,7 +64,6 @@ export function WeixinPanel({
onAction,
onFeaturesUpdate,
}: ChannelPluginPanelProps) {
const { client } = useClient();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const channelTx = channelTranslator(t, "weixin");
@@ -152,7 +150,7 @@ export function WeixinPanel({
setSaveState("idle");
try {
const payload = await configureChannel(
client,
context.token,
"weixin",
channelValuesForSave(editableFieldsRef.current, values),
{ enable: context.enabled },
@@ -170,7 +168,7 @@ export function WeixinPanel({
} finally {
setSaving(false);
}
}, [client]);
}, []);
useEffect(() => {
if (
-1
View File
@@ -669,7 +669,6 @@ def _run_gateway(
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
webui_skill_state_action=_webui_skill_state_action,
config_path=Path(config_path),
)
def _pick_heartbeat_target() -> tuple[str, str]:
-1
View File
@@ -373,7 +373,6 @@ class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config
command: str = "" # Stdio: command to run (e.g. "npx")
args: list[str] = Field(default_factory=list) # Stdio: command arguments
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
+1 -4
View File
@@ -24,7 +24,6 @@ class ProviderSnapshot:
@dataclass(frozen=True)
class _ProviderSetup:
model: str
provider_name: str
provider_config: ProviderConfig | None
spec: ProviderSpec | None
backend: str
@@ -100,7 +99,6 @@ def _resolve_provider_setup(
return _ProviderSetup(
model=model,
provider_name=provider_name,
provider_config=p,
spec=spec,
backend=backend,
@@ -136,7 +134,6 @@ def _make_provider_core(
model=model,
)
model = setup.model
provider_name = setup.provider_name
p = setup.provider_config
spec = setup.spec
backend = setup.backend
@@ -201,7 +198,7 @@ def _make_provider_core(
extra_headers=_provider_extra_headers(spec, p),
spec=spec,
extra_body=p.extra_body if p else None,
api_type=p.api_type if p and provider_name == "openai" else "auto",
api_type=p.api_type if p else "auto",
extra_query=p.extra_query if p else None,
proxy=p.proxy if p else None,
)
+56 -40
View File
@@ -49,7 +49,7 @@ from nanobot.providers.openai_responses import (
if TYPE_CHECKING:
from openai import AsyncOpenAI as AsyncOpenAIType
from nanobot.providers.registry import ProviderSpec
from nanobot.providers.registry import ProviderSpec, ResponsesCapabilities
# Module-level placeholder — set lazily by _ensure_client on first real
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
@@ -496,7 +496,12 @@ class OpenAICompatProvider(LLMProvider):
self.extra_headers = extra_headers or {}
self._spec = spec
self._extra_body = dict(extra_body or {})
self._api_type = api_type if spec and spec.name == "openai" else "auto"
responses = spec.responses if spec is not None else None
self._api_type = (
api_type
if responses is not None and responses.allows_api_type_override
else "auto"
)
self._extra_query = extra_query or {}
self._proxy = proxy or None
self._native_compaction_available = True
@@ -987,35 +992,33 @@ class OpenAICompatProvider(LLMProvider):
"""Choose Responses for providers/models that explicitly support it."""
if self._api_type == "chat_completions":
return False
spec_name = self._spec.name if self._spec is not None else None
model_name = self._request_model_name(model or self.default_model).lower()
supported_models = {
supported.lower()
for supported in getattr(self._spec, "responses_models", ())
}
model_responses = any(
model_name == supported or model_name.endswith(f"/{supported}")
for supported in supported_models
)
provider_responses = spec_name in ("openai", "github_copilot")
if not provider_responses and not model_responses:
capabilities = self._responses_capabilities()
if capabilities is None:
return False
if self._responses_is_required():
# Explicit Responses-only request fields are mandatory; do not
model_name = self._request_model_name(model or self.default_model).lower()
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
if not _is_direct_openai_base(self._effective_base):
return False
wants = False
if model_responses:
wants = True
elif reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True
if not wants:
explicitly_supported = capabilities.matches_model(model_name)
if self._hosted_web_search_enabled() and (
capabilities.auto_route or explicitly_supported
):
# Provider-hosted tools require Responses on models that the
# capability profile declares eligible for that transport.
return True
if (
capabilities.requires_direct_openai_base
and not _is_direct_openai_base(self._effective_base)
):
return False
wants_auto_route = capabilities.auto_route and (
(reasoning_effort is not None and reasoning_effort.lower() != "none")
or any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
)
if not explicitly_supported and not wants_auto_route:
return False
return self._responses_circuit_allows_probe(model, reasoning_effort)
@@ -1039,6 +1042,9 @@ class OpenAICompatProvider(LLMProvider):
)
)
def _responses_capabilities(self) -> ResponsesCapabilities | None:
return self._spec.responses if self._spec is not None else None
def _responses_state_provider(self) -> str:
spec_name = self._spec.name if self._spec is not None else "custom"
effective_base = self._effective_base or "https://api.openai.com/v1"
@@ -1061,14 +1067,20 @@ class OpenAICompatProvider(LLMProvider):
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Enable server compaction only on direct OpenAI Responses endpoints."""
_ = model
capabilities = self._responses_capabilities()
if (
not self._native_compaction_available
or self._api_type == "chat_completions"
or capabilities is None
or not capabilities.supports_native_compaction
):
return False
if self._spec is not None and self._spec.name != "openai":
if (
capabilities.requires_direct_openai_base
and not _is_direct_openai_base(self._effective_base)
):
return False
return _is_direct_openai_base(self._effective_base)
return True
def _responses_circuit_allows_probe(
self,
@@ -1156,7 +1168,10 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages)
)
)
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
capabilities = self._responses_capabilities()
preserve_reasoning = (
capabilities is not None and capabilities.reasoning_replay == "plaintext"
)
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
@@ -1187,10 +1202,15 @@ class OpenAICompatProvider(LLMProvider):
"compact_threshold": compact_threshold,
}]
if self._supports_temperature(model_name, reasoning_effort):
supports_temperature = self._supports_temperature(model_name, reasoning_effort)
if supports_temperature:
body["temperature"] = temperature
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
if (
not supports_temperature
and capabilities is not None
and capabilities.reasoning_replay == "encrypted"
):
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
@@ -1840,10 +1860,8 @@ class OpenAICompatProvider(LLMProvider):
self._record_responses_success(model, reasoning_effort)
return result
except Exception as responses_error:
if self._spec and self._spec.name == "github_copilot":
# Copilot gateway exposes GPT-5/o-series only via /responses;
# falling back to /chat/completions cannot succeed and would
# hide the real error.
capabilities = self._responses_capabilities()
if capabilities is not None and not capabilities.allows_chat_fallback:
raise
if self._responses_is_required():
raise
@@ -1936,10 +1954,8 @@ class OpenAICompatProvider(LLMProvider):
)
return result
except Exception as responses_error:
if self._spec and self._spec.name == "github_copilot":
# Copilot gateway exposes GPT-5/o-series only via /responses;
# falling back to /chat/completions cannot succeed and would
# hide the real error.
capabilities = self._responses_capabilities()
if capabilities is not None and not capabilities.allows_chat_fallback:
raise
if self._responses_is_required():
raise
+45 -6
View File
@@ -13,7 +13,7 @@ Every entry writes out all fields so you can copy-paste as a template.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Literal
from pydantic.alias_generators import to_snake
@@ -28,6 +28,32 @@ class ProviderModelSpec:
context_window: int | None = None
@dataclass(frozen=True)
class ResponsesCapabilities:
"""Provider capabilities for the shared OpenAI Responses execution path.
``reasoning_replay`` selects whether multi-turn reasoning is retained as
encrypted server content, plaintext local history, or not requested.
"""
models: tuple[str, ...] = ()
auto_route: bool = False
requires_direct_openai_base: bool = False
allows_api_type_override: bool = False
reasoning_replay: Literal["none", "encrypted", "plaintext"] = "none"
supports_native_compaction: bool = False
allows_chat_fallback: bool = True
def matches_model(self, model: str) -> bool:
"""Return whether *model* is explicitly routed through Responses."""
model_name = model.lower()
return any(
model_name == supported.lower()
or model_name.endswith(f"/{supported.lower()}")
for supported in self.models
)
@dataclass(frozen=True)
class ProviderSpec:
"""One LLM provider's metadata. See PROVIDERS below for real examples.
@@ -111,10 +137,8 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = ()
# Models that expose the OpenAI Responses wire format. This is model-level
# because providers may add Responses support incrementally (DeepSeek V4
# Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = ()
# Capabilities for providers/models served through the shared Responses path.
responses: ResponsesCapabilities | None = None
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
# supplies the hosted-tool selection. Values are raw Responses tool types.
@@ -389,6 +413,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="OpenAI",
backend="openai_compat",
supports_max_completion_tokens=True,
responses=ResponsesCapabilities(
auto_route=True,
requires_direct_openai_base=True,
allows_api_type_override=True,
reasoning_replay="encrypted",
supports_native_compaction=True,
),
),
# OpenAI Codex: OAuth-based, dedicated provider
ProviderSpec(
@@ -472,6 +503,11 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=True,
is_oauth=True,
supports_max_completion_tokens=True,
responses=ResponsesCapabilities(
auto_route=True,
reasoning_replay="encrypted",
allows_chat_fallback=False,
),
),
# DeepSeek: OpenAI-compatible at api.deepseek.com
ProviderSpec(
@@ -482,7 +518,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
responses=ResponsesCapabilities(
models=("deepseek-v4-flash",),
reasoning_replay="plaintext",
),
responses_default_tools=("web_search",),
),
# Gemini: Google's OpenAI-compatible endpoint
+6 -16
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
import re
import time
from pathlib import Path
from typing import Any, cast
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
@@ -90,8 +89,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
return values[0] if values else None
def _manager(config_path: Path | None = None) -> CliAppManager:
config = load_config(config_path) if config_path is not None else load_config()
def _manager() -> CliAppManager:
config = load_config()
cli_cfg = config.tools.cli_apps
return CliAppManager(
workspace=config.workspace_path,
@@ -103,12 +102,8 @@ def _manager(config_path: Path | None = None) -> CliAppManager:
)
async def cli_apps_payload(
*,
installed_only: bool = False,
config_path: Path | None = None,
) -> dict[str, Any]:
manager = _manager(config_path) if config_path is not None else _manager()
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
manager = _manager()
if installed_only:
return manager.installed_payload()
payload = manager.payload(cache_only=True)
@@ -123,16 +118,11 @@ async def cli_apps_payload(
return payload
def cli_apps_action(
action: str,
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise CliAppError("missing CLI app name")
manager = _manager(config_path) if config_path is not None else _manager()
manager = _manager()
if action == "install":
return manager.install(name)
if action == "update":
-7
View File
@@ -8,11 +8,9 @@ from typing import TYPE_CHECKING, Any, Callable
from loguru import logger as default_logger
from nanobot.config.loader import get_config_path
from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.settings_services import WebUISettingsServices
from nanobot.webui.temporary_chats import WebUITemporaryChats
from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController
@@ -31,7 +29,6 @@ class GatewayServices:
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
http: GatewayHTTPHandler
settings: WebUISettingsServices
tokens: GatewayTokenStore
media: WebUIMediaGateway
ingress: WebUIIngressPolicy
@@ -53,7 +50,6 @@ def build_gateway_services(
static_dist_path: Path | None,
workspace_path: Path,
default_restrict_to_workspace: bool,
config_path: Path | None = None,
runtime_model_name: Callable[[], str | None] | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
@@ -67,7 +63,6 @@ def build_gateway_services(
skill_state_action: Callable[[set[str]], None] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
settings = WebUISettingsServices.create(config_path or get_config_path())
tokens = GatewayTokenStore()
ingress = DEFAULT_WEBUI_INGRESS_POLICY
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
@@ -107,7 +102,6 @@ def build_gateway_services(
media=media,
ingress=ingress,
workspaces=workspaces,
settings=settings,
skills_workspace_path=workspace_path,
disabled_skills=disabled_skills,
cron_service=cron_service,
@@ -121,7 +115,6 @@ def build_gateway_services(
)
return GatewayServices(
http=http,
settings=settings,
tokens=tokens,
media=media,
ingress=ingress,
-415
View File
@@ -1,415 +0,0 @@
"""Gateway-owned browser authorization flows for remote MCP servers."""
from __future__ import annotations
import asyncio
import re
import secrets
import time
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import SplitResult, parse_qs, urlsplit, urlunsplit
from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig
from nanobot.security.network import validate_url_target
from nanobot.webui.http_utils import is_loopback_host
McpReload = Callable[[], Awaitable[dict[str, Any]]]
_FLOW_TTL_S = 300
_START_WAIT_S = 20
_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$")
class McpOAuthError(Exception):
"""Safe WebUI error for an MCP OAuth request."""
def __init__(self, message: str, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
class _OAuthCallbackError(RuntimeError):
pass
@dataclass
class _McpOAuthFlow:
flow_id: str
name: str
cfg: MCPServerConfig
redirect_uri: str
manual_callback: bool
expires_at: float
authorization_ready: asyncio.Event = field(default_factory=asyncio.Event)
callback_result: asyncio.Future[tuple[str, str | None]] | None = None
task: asyncio.Task[bool] | None = None
authorization_url: str | None = None
state: str | None = None
callback_received: bool = False
error: str | None = None
reload_result: dict[str, Any] | None = None
def _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]:
cleaned = redirect_uri.strip()
parsed = urlsplit(cleaned)
try:
port = parsed.port
except ValueError as exc:
raise McpOAuthError("Invalid MCP OAuth callback URL") from exc
if (
not parsed.netloc
or not parsed.hostname
or parsed.path != MCP_OAUTH_CALLBACK_PATH
or parsed.query
or parsed.fragment
or parsed.username is not None
or parsed.password is not None
):
raise McpOAuthError("Invalid MCP OAuth callback URL")
return cleaned, parsed, port
def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str:
"""Allow HTTPS callbacks, plus loopback HTTP for a local gateway."""
cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri)
if parsed.scheme == "https":
return cleaned
if parsed.scheme == "http" and is_loopback_host(parsed.netloc):
return cleaned
raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost")
def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]:
"""Use a pasteable loopback callback when a remote WebUI is served over HTTP."""
cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri)
if parsed.scheme != "http" or is_loopback_host(parsed.netloc):
return validate_mcp_oauth_redirect_uri(cleaned), False
loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}"
manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", ""))
return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True
class McpOAuthManager:
"""Own short-lived browser flows while the gateway process is running."""
def __init__(self) -> None:
self._flows: dict[str, _McpOAuthFlow] = {}
self._states: dict[str, str] = {}
async def start(
self,
name: str,
cfg: MCPServerConfig,
redirect_uri: str,
*,
reload_mcp: McpReload,
reset_credentials: bool = False,
) -> dict[str, Any]:
self._prune()
redirect_uri, manual_callback = prepare_mcp_oauth_redirect_uri(redirect_uri)
await self._cancel_name(name)
loop = asyncio.get_running_loop()
now = time.monotonic()
flow = _McpOAuthFlow(
flow_id=secrets.token_urlsafe(24),
name=name,
cfg=cfg,
redirect_uri=redirect_uri,
manual_callback=manual_callback,
expires_at=now + _FLOW_TTL_S,
callback_result=loop.create_future(),
)
self._flows[flow.flow_id] = flow
handlers = MCPOAuthHandlers(
redirect_uri=redirect_uri,
redirect_handler=lambda url: self._receive_authorization_url(flow, url),
callback_handler=lambda: self._wait_for_callback(flow),
reset_credentials=reset_credentials,
)
flow.task = asyncio.create_task(
self._connect_and_reload(flow, handlers, reload_mcp),
name=f"mcp-oauth:{name}",
)
ready_waiter = asyncio.create_task(flow.authorization_ready.wait())
try:
await asyncio.wait(
{ready_waiter, flow.task},
timeout=_START_WAIT_S,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
ready_waiter.cancel()
with suppress(asyncio.CancelledError):
await ready_waiter
return self._payload(flow)
async def status(self, flow_id: str) -> dict[str, Any]:
self._prune()
flow = self._flow(flow_id)
return self._payload(flow)
def submit_callback(
self,
*,
state: str,
code: str | None,
error: str | None,
) -> str:
self._prune()
flow_id = self._states.pop(state, None)
if flow_id is None:
raise McpOAuthError("This MCP authorization request has expired", status=410)
flow = self._flow(flow_id)
callback_result = flow.callback_result
if callback_result is None or callback_result.done():
raise McpOAuthError("This MCP authorization callback was already used", status=409)
flow.callback_received = True
if error:
safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed"
flow.error = f"Authorization was not completed ({safe_error})."
callback_result.set_exception(_OAuthCallbackError(flow.error))
raise McpOAuthError(flow.error)
elif not code or len(code) > 8192:
flow.error = "The MCP server did not return an authorization code."
callback_result.set_exception(_OAuthCallbackError(flow.error))
raise McpOAuthError(flow.error)
else:
callback_result.set_result((code, state))
return flow.name
def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]:
"""Complete a flow from a full browser callback URL pasted into the WebUI."""
self._prune()
flow = self._flow(flow_id)
parsed = urlsplit(callback_url.strip())
expected = urlsplit(flow.redirect_uri)
if (
not parsed.query
or parsed.fragment
or parsed.username is not None
or parsed.password is not None
or parsed.scheme != expected.scheme
or parsed.netloc != expected.netloc
or parsed.path != expected.path
):
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
)
try:
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
except ValueError as exc:
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
) from exc
states = query.get("state", [])
state = states[0] if len(states) == 1 else ""
if not state or state != flow.state:
raise McpOAuthError(
"This callback belongs to a different or expired authorization request. "
"Start again.",
status=410,
)
codes = query.get("code", [])
errors = query.get("error", [])
if len(codes) > 1 or len(errors) > 1 or (codes and errors):
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
)
code = codes[0] if len(codes) == 1 else None
error = errors[0] if len(errors) == 1 else None
if (not code and not error) or (code is not None and len(code) > 8192):
raise McpOAuthError(
"Paste the complete callback URL from the browser address bar."
)
self.submit_callback(state=state, code=code, error=error)
return self._payload(flow)
async def cancel(self, flow_id: str) -> dict[str, Any]:
self._prune()
flow = self._flow(flow_id)
await self._cancel_flow(flow)
return self._payload(flow)
async def _receive_authorization_url(
self,
flow: _McpOAuthFlow,
authorization_url: str,
) -> None:
parsed = urlsplit(authorization_url)
if (
parsed.scheme != "https"
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
):
flow.error = "The MCP server returned an unsafe authorization URL."
raise McpOAuthError(flow.error)
ok, _error = validate_url_target(authorization_url)
if not ok:
flow.error = "The MCP server returned an unsafe authorization URL."
raise McpOAuthError(flow.error)
states = parse_qs(parsed.query).get("state", [])
state = states[0] if len(states) == 1 else ""
if not state or len(state) > 512:
flow.error = "The MCP server returned an invalid authorization URL."
raise McpOAuthError(flow.error)
if state in self._states:
flow.error = "The MCP server reused an OAuth state value."
raise McpOAuthError(flow.error)
flow.authorization_url = authorization_url
flow.state = state
self._states[state] = flow.flow_id
flow.authorization_ready.set()
async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]:
callback_result = flow.callback_result
if callback_result is None:
raise _OAuthCallbackError("MCP OAuth callback is unavailable")
remaining = max(0.1, flow.expires_at - time.monotonic())
try:
return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining)
except asyncio.TimeoutError as exc:
flow.error = "MCP authorization timed out."
raise _OAuthCallbackError(flow.error) from exc
async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool:
connections: dict[str, MCPConnection] = {}
try:
connections = await connect_mcp_servers(
{flow.name: flow.cfg},
ToolRegistry(),
oauth_handlers={flow.name: handlers},
)
succeeded = flow.name in connections
if not succeeded and flow.error is None:
flow.error = "Could not complete the MCP OAuth connection."
return succeeded
except asyncio.CancelledError:
raise
except Exception:
if flow.error is None:
flow.error = "Could not complete the MCP OAuth connection."
return False
finally:
for connection in connections.values():
with suppress(Exception):
await connection.aclose()
async def _connect_and_reload(
self,
flow: _McpOAuthFlow,
handlers: MCPOAuthHandlers,
reload_mcp: McpReload,
) -> bool:
succeeded = await self._connect(flow, handlers)
if not succeeded:
return False
try:
flow.reload_result = await reload_mcp()
failed = flow.reload_result.get("failed")
if (
not flow.reload_result.get("ok")
and not flow.reload_result.get("requires_restart")
and isinstance(failed, list)
and flow.name in failed
):
flow.reload_result = await reload_mcp()
except Exception:
flow.reload_result = {
"ok": False,
"message": "Signed in, but nanobot could not activate the MCP tools.",
"requires_restart": True,
}
return True
def _flow(self, flow_id: str) -> _McpOAuthFlow:
flow = self._flows.get(flow_id)
if flow is None:
raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404)
return flow
def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]:
task = flow.task
connected = flow.reload_result.get("connected") if flow.reload_result is not None else None
if task is not None and task.cancelled():
status = "cancelled"
elif task is not None and task.done():
try:
succeeded = task.result()
except Exception:
succeeded = False
if not succeeded:
status = "failed"
elif flow.reload_result is None:
status = "authorized"
elif flow.reload_result.get("ok") or (
isinstance(connected, list) and flow.name in connected
):
status = "connected"
else:
status = "authorized"
elif flow.callback_received:
status = "connecting"
elif flow.authorization_url:
status = "authorization_required"
else:
status = "starting"
payload: dict[str, Any] = {
"flow_id": flow.flow_id,
"name": flow.name,
"status": status,
"expires_in": max(0, int(flow.expires_at - time.monotonic())),
}
if flow.manual_callback:
payload["completion_input"] = "callback_url"
if flow.authorization_url and status == "authorization_required":
payload["authorization_url"] = flow.authorization_url
if flow.error:
payload["error"] = flow.error
if flow.reload_result is not None:
payload["hot_reload"] = flow.reload_result
return payload
async def _cancel_name(self, name: str) -> None:
for flow in list(self._flows.values()):
if flow.name == name and flow.task is not None and not flow.task.done():
await self._cancel_flow(flow)
async def _cancel_flow(self, flow: _McpOAuthFlow) -> None:
if flow.state:
self._states.pop(flow.state, None)
task = flow.task
if task is not None and not task.done():
task.cancel()
with suppress(BaseException):
await task
def _prune(self) -> None:
now = time.monotonic()
for flow_id, flow in list(self._flows.items()):
if flow.expires_at > now:
continue
if flow.state:
self._states.pop(flow.state, None)
if flow.task is not None and not flow.task.done():
flow.task.cancel()
callback_result = flow.callback_result
if callback_result is not None and not callback_result.done():
callback_result.cancel()
self._flows.pop(flow_id, None)
+44 -337
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import base64
import json
import os
import re
@@ -15,17 +14,8 @@ from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
from typing import Any, Literal, Mapping, cast
from nanobot.agent.plugins import (
AgentPluginState,
discover_agent_plugin_states,
set_agent_plugin_enabled,
)
from nanobot.agent.tools.mcp_oauth import (
delete_mcp_oauth_credentials,
mcp_oauth_has_credentials,
)
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
@@ -35,9 +25,6 @@ from nanobot.utils.helpers import ensure_dir
QueryParams = dict[str, list[str]]
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUISettingsConfig
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
_SECRET_QUERY_RE = re.compile(
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
@@ -61,6 +48,7 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
McpReload = Callable[[], Awaitable[dict[str, Any]]]
@@ -346,63 +334,6 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
),
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
),
McpPreset(
name="xmind",
display_name="Xmind",
category="productivity",
description="Create, read, and edit cloud mind maps through Xmind.",
docs_url="https://xmind.com/user-guide/xmind-mcp",
transport="streamableHttp",
install_supported=True,
brand_domain="xmind.com",
brand_color="#F4B41A",
requires="Xmind account",
server=MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://app.xmind.com/api/mcp",
tool_timeout=60,
),
note="Connects securely in your browser with Xmind OAuth.",
),
McpPreset(
name="notion",
display_name="Notion",
category="productivity",
description="Read and update your Notion workspace through Notion MCP.",
docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp",
transport="streamableHttp",
install_supported=True,
brand_domain="notion.so",
brand_color="#111111",
requires="Notion account",
server=MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.notion.com/mcp",
tool_timeout=60,
),
note="Connects securely in your browser with Notion OAuth.",
),
McpPreset(
name="linear",
display_name="Linear",
category="productivity",
description="Find and manage Linear issues, projects, and comments.",
docs_url="https://linear.app/docs/mcp",
transport="streamableHttp",
install_supported=True,
brand_domain="linear.app",
brand_color="#5E6AD2",
requires="Linear account",
server=MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.linear.app/mcp",
tool_timeout=60,
),
note="Connects securely in your browser with Linear OAuth.",
),
McpPreset(
name="github",
display_name="GitHub",
@@ -723,8 +654,6 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
return "not_installed" if preset.install_supported else "coming_soon"
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
return "missing_credentials"
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url):
return "authorization_required"
if cfg.command and not _command_available(cfg.command):
return "missing_dependency"
return "configured"
@@ -770,7 +699,6 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
compact_dict({
"type": "mcp",
"transport": preset.transport,
"auth": server.auth if server and server.auth else None,
"command": server.command if server and server.command else None,
"args": list(server.args) if server and server.command else None,
"url": _connection_summary(server) if server and server.url else None,
@@ -821,7 +749,6 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
compact_dict({
"type": "mcp",
"transport": transport,
"auth": cfg.auth,
"command": cfg.command or None,
"url": _connection_summary(cfg) if cfg.url else None,
})
@@ -849,7 +776,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
cfg = configured_servers.get(preset.name)
status = _status_for(preset, cfg)
configured = cfg is not None and status not in {"missing_credentials", "authorization_required"}
configured = cfg is not None and status not in {"missing_credentials"}
logo_url = _favicon_url(preset.brand_domain)
return {
"name": preset.name,
@@ -858,7 +785,6 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
"description": preset.description,
"docs_url": preset.docs_url,
"transport": preset.transport,
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
"requires": preset.requires,
"note": preset.note,
"install_supported": preset.install_supported,
@@ -885,11 +811,7 @@ def _custom_payload(
transport = cfg.type
if not transport:
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url):
status = "authorization_required"
else:
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
configured = status != "authorization_required"
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
return {
"name": name,
"display_name": name,
@@ -897,13 +819,12 @@ def _custom_payload(
"description": "Custom MCP server from nanobot config.",
"docs_url": "",
"transport": transport,
"auth": cfg.auth,
"requires": "",
"note": "",
"install_supported": True,
"installed": True,
"configured": configured,
"available": configured and _config_available(cfg),
"configured": True,
"available": _config_available(cfg),
"status": status,
"logo_url": None,
"brand_color": "#64748B",
@@ -916,50 +837,12 @@ def _custom_payload(
}
def _plugin_logo_data_url(path: Path | None) -> str | None:
if path is None:
return None
try:
data = path.read_bytes()
except OSError:
return None
encoded = base64.b64encode(data).decode("ascii")
image_format = path.suffix.lower().lstrip(".").replace("jpg", "jpeg")
return f"data:image/{image_format};base64,{encoded}"
def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
plugin = state.plugin
return {
"name": f"plugin-{plugin.name}",
"display_name": plugin.display_name,
"category": plugin.category,
"description": plugin.description or "Agent Plugin",
"docs_url": plugin.repository,
"transport": "stdio",
"requires": ", ".join(plugin.permissions),
"note": "",
"install_supported": False,
"installed": True,
"configured": not state.setup_required,
"enabled": state.enabled,
"available": state.enabled,
"status": "enabled" if state.enabled else "disabled",
"logo_url": _plugin_logo_data_url(plugin.logo),
"brand_color": plugin.accent_color,
"required_fields": [],
"connection_summary": ", ".join(state.mcp_servers),
"source": "agent-plugin",
}
def mcp_presets_payload(
*,
last_action: dict[str, Any] | None = None,
tool_preview: Mapping[str, list[str]] | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
config = load_config(config_path) if config_path is not None else load_config()
config = load_config()
known = _known_preset_names()
preset_rows = [
_preset_payload(preset, config.tools.mcp_servers)
@@ -971,17 +854,9 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known
]
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
plugin_rows = [
_agent_plugin_payload(state)
for state in discover_agent_plugin_states(config.workspace_path)
if (state.mcp_servers or state.plugin.install_command)
and f"plugin-{state.plugin.name}" not in existing_names
]
payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows, *plugin_rows],
"installed_count": len(config.tools.mcp_servers)
+ sum(int(row["enabled"]) for row in plugin_rows),
"presets": [*preset_rows, *custom_rows],
"installed_count": len(config.tools.mcp_servers),
}
if last_action is not None:
payload["last_action"] = last_action
@@ -1053,11 +928,7 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
await stack.aclose()
async def mcp_presets_test_action(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
"""Connect to an enabled MCP preset and report its tool surface."""
from nanobot.agent.tools.mcp import connect_mcp_servers
@@ -1070,22 +941,16 @@ async def mcp_presets_test_action(
display_name = _display_name_for(name, preset)
try:
config = resolve_config_env_vars(
load_config(config_path) if config_path is not None else load_config(),
config_path=config_path,
)
config = resolve_config_env_vars(load_config())
except ValueError as exc:
return mcp_presets_payload(
last_action={
"ok": False,
"message": _scrub_test_error(str(exc)),
"error": _scrub_test_error(str(exc)),
"tool_count": 0,
"tool_names": [],
"checked_at": _checked_at(),
},
config_path=config_path,
)
return mcp_presets_payload(last_action={
"ok": False,
"message": _scrub_test_error(str(exc)),
"error": _scrub_test_error(str(exc)),
"tool_count": 0,
"tool_names": [],
"checked_at": _checked_at(),
})
cfg = config.tools.mcp_servers.get(name)
if cfg is None:
@@ -1103,7 +968,7 @@ async def mcp_presets_test_action(
"tool_names": [],
"checked_at": _checked_at(),
}
return mcp_presets_payload(last_action=last_action, config_path=config_path)
return mcp_presets_payload(last_action=last_action)
if cfg.command and not _command_available(cfg.command):
last_action = {
@@ -1114,7 +979,7 @@ async def mcp_presets_test_action(
"tool_names": [],
"checked_at": _checked_at(),
}
return mcp_presets_payload(last_action=last_action, config_path=config_path)
return mcp_presets_payload(last_action=last_action)
registry = ToolRegistry()
stacks: dict[str, Any] = {}
@@ -1175,11 +1040,7 @@ async def mcp_presets_test_action(
tool_names = last_action.get("tool_names", [])
preview = {name: tool_names} if tool_names else None
return mcp_presets_payload(
last_action=last_action,
tool_preview=preview,
config_path=config_path,
)
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
@@ -1248,32 +1109,6 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
return normalized # type: ignore[return-value]
def _normalize_auth(
value: object,
*,
transport: Literal["stdio", "sse", "streamableHttp"],
url: str,
headers: Mapping[str, str],
) -> Literal["oauth"] | None:
raw = str(value or "").strip().lower()
if not raw and url and not headers:
normalized_url = url.rstrip("/")
if any(
preset.server is not None
and preset.server.auth == "oauth"
and preset.server.url.rstrip("/") == normalized_url
for preset in MCP_PRESETS
):
raw = "oauth"
if not raw:
return None
if raw != "oauth":
raise McpPresetError("unsupported MCP auth type")
if transport == "stdio":
raise McpPresetError("MCP OAuth requires a remote HTTP transport")
return "oauth"
def _validated_server_name(name: str) -> str:
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
raise McpPresetError("invalid MCP server name")
@@ -1289,13 +1124,6 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
raise McpPresetError("stdio MCP servers require a command")
if transport in {"sse", "streamableHttp"} and not url:
raise McpPresetError("remote MCP servers require a URL")
headers = _parse_string_map(_query_first(query, "headers"))
auth = _normalize_auth(
_query_first(query, "auth"),
transport=transport,
url=url,
headers=headers,
)
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
if raw_timeout:
@@ -1305,13 +1133,12 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
raise McpPresetError("tool_timeout must be an integer") from exc
cfg = MCPServerConfig(
type=transport,
auth=auth,
command=command if transport == "stdio" else "",
args=_parse_string_list(_query_first(query, "args")),
env=_parse_string_map(_query_first(query, "env")),
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
url=url if transport in {"sse", "streamableHttp"} else "",
headers=headers,
headers=_parse_string_map(_query_first(query, "headers")),
tool_timeout=tool_timeout,
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
)
@@ -1356,13 +1183,6 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
headers = cast(dict[object, object], headers_value)
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
typed_headers = cast(dict[str, str], headers)
auth = _normalize_auth(
server.get("auth"),
transport=transport,
url=url,
headers=typed_headers,
)
if not isinstance(enabled_tools_value, list):
enabled_tools_value = ["*"]
else:
@@ -1371,13 +1191,12 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
enabled_tools_value = ["*"]
return server_name, MCPServerConfig(
type=transport,
auth=auth,
command=command if transport == "stdio" else "",
args=cast(list[str], args),
env=cast(dict[str, str], env),
cwd=cwd if transport == "stdio" else "",
url=url if transport in {"sse", "streamableHttp"} else "",
headers=typed_headers,
headers=cast(dict[str, str], headers),
tool_timeout=timeout_int,
enabled_tools=cast(list[str], enabled_tools_value),
)
@@ -1402,54 +1221,24 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
return out
def _oauth_credentials_replaced(
previous: MCPServerConfig | None,
replacement: MCPServerConfig,
) -> bool:
if previous is None or previous.auth != "oauth":
return False
return replacement.auth != "oauth" or replacement.url != previous.url
def custom_mcp_action(
action: str,
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
config = load_config(config_path) if config_path is not None else load_config()
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
config = load_config()
if action == "custom":
name, cfg = _custom_server_from_query(query)
delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
config.tools.mcp_servers[name] = cfg
save_config(config, config_path)
if delete_credentials:
delete_mcp_oauth_credentials(name)
payload = mcp_presets_payload(
last_action=_server_action_message(action, name),
config_path=config_path,
)
save_config(config)
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
payload["requires_restart"] = True
return payload
if action in {"import", "import-cursor"}:
servers = _import_mcp_servers(_query_first(query, "config"))
delete_credentials = [
name
for name, cfg in servers.items()
if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
]
config.tools.mcp_servers.update(servers)
save_config(config, config_path)
for name in delete_credentials:
delete_mcp_oauth_credentials(name)
payload = mcp_presets_payload(
last_action={
"ok": True,
"message": f"Imported {len(servers)} MCP server(s).",
},
config_path=config_path,
)
save_config(config)
payload = mcp_presets_payload(last_action={
"ok": True,
"message": f"Imported {len(servers)} MCP server(s).",
})
payload["requires_restart"] = True
return payload
@@ -1460,61 +1249,29 @@ def custom_mcp_action(
raise McpPresetError("unknown MCP server", status=404)
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
config.tools.mcp_servers[name] = cfg
save_config(config, config_path)
payload = mcp_presets_payload(
last_action=_server_action_message(action, name),
config_path=config_path,
)
save_config(config)
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
payload["requires_restart"] = True
return payload
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
def ensure_mcp_oauth_server(
query: QueryParams,
*,
config_path: Path | None = None,
) -> tuple[str, MCPServerConfig]:
"""Materialize an OAuth preset on first click and return its saved config."""
name = _validated_server_name((_query_first(query, "name") or "").strip())
config = load_config(config_path) if config_path is not None else load_config()
cfg = config.tools.mcp_servers.get(name)
if cfg is None:
preset = _preset_by_name(name)
if preset.server is None or preset.server.auth != "oauth":
raise McpPresetError("MCP server does not support browser authorization", status=409)
cfg = _materialize_server(preset, query, None)
config.tools.mcp_servers[name] = cfg
save_config(config, config_path)
if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url:
raise McpPresetError("MCP server is not configured for OAuth", status=409)
return name, cfg
def mcp_presets_action(
action: str,
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise McpPresetError("missing MCP preset name")
preset = _preset_by_name_optional(name)
config = load_config(config_path) if config_path is not None else load_config()
config = load_config()
existing = config.tools.mcp_servers.get(name)
if action == "enable":
if preset is None:
raise McpPresetError("unknown MCP preset", status=404)
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
save_config(config, config_path)
payload = mcp_presets_payload(
last_action=_action_message(action, preset),
config_path=config_path,
)
save_config(config)
payload = mcp_presets_payload(last_action=_action_message(action, preset))
payload["requires_restart"] = True
return payload
@@ -1530,8 +1287,7 @@ def mcp_presets_action(
except OSError as exc:
cleanup_error = str(exc)
del config.tools.mcp_servers[name]
save_config(config, config_path)
delete_mcp_oauth_credentials(name)
save_config(config)
last_action = (
_action_message(action, preset)
if preset is not None
@@ -1547,10 +1303,7 @@ def mcp_presets_action(
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
)
last_action["verification_failed"] = ["managed_paths_absent"]
payload = mcp_presets_payload(
last_action=last_action,
config_path=config_path,
)
payload = mcp_presets_payload(last_action=last_action)
payload["requires_restart"] = True
return payload
@@ -1586,59 +1339,13 @@ async def mcp_presets_settings_action(
query: QueryParams,
*,
reload_mcp: McpReload | None = None,
config: WebUISettingsConfig | None = None,
remote: bool = False,
) -> dict[str, Any]:
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
config_path = config.path if config is not None else None
if action is None:
return mcp_presets_payload(config_path=config_path)
name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"):
plugin_config = load_config(config_path) if config_path is not None else load_config()
plugin_name = name.removeprefix("plugin-")
plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
if (
name not in plugin_config.tools.mcp_servers
and plugin_state is not None
and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
):
if action not in {"enable", "disable"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
if (
action == "enable"
and plugin_state.setup_required
and remote
and not plugin_config.tools.webui_allow_remote_package_install
):
raise McpPresetError(
"Agent Plugin setup is restricted to the local WebUI",
status=403,
)
plugin = await asyncio.to_thread(
set_agent_plugin_enabled,
plugin_config.workspace_path,
plugin_name,
action == "enable",
)
verb = "enabled" if action == "enable" else "disabled"
payload = mcp_presets_payload(
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
config_path=config_path,
)
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
return mcp_presets_payload()
if action == "test":
return await mcp_presets_test_action(query, config_path=config_path)
if config is not None:
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
payload = await asyncio.to_thread(
config.run_serialized,
lambda path: operation(action, query, config_path=path),
)
elif action in _CUSTOM_ACTIONS:
return await mcp_presets_test_action(query)
if action in _CUSTOM_ACTIONS:
payload = await asyncio.to_thread(custom_mcp_action, action, query)
else:
payload = await asyncio.to_thread(mcp_presets_action, action, query)
+4 -20
View File
@@ -1,7 +1,6 @@
"""Nanobot optional feature helpers for WebUI Settings."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from nanobot.channels.registry import load_channel_plugin
@@ -16,13 +15,8 @@ from nanobot.webui.http_utils import query_first
QueryParams = dict[str, list[str]]
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
if config_path is None:
return optional_features_payload()
from nanobot.config.loader import load_config
return optional_features_payload(config=load_config(config_path))
def nanobot_features_payload() -> dict[str, Any]:
return optional_features_payload()
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
@@ -38,19 +32,13 @@ def nanobot_features_action(
query: QueryParams,
*,
allow_install: bool = True,
config_path: Path | None = None,
) -> dict[str, Any]:
name = (query_first(query, "name") or "").strip()
instance_id = nanobot_feature_instance_target(query)
if not name:
raise OptionalFeatureError("missing feature name")
if action == "enable":
return enable_optional_feature(
name,
config_path=config_path,
allow_install=allow_install,
instance_id=instance_id,
)
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
if action == "disable":
try:
plugin = load_channel_plugin(name)
@@ -62,9 +50,5 @@ def nanobot_features_action(
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
status=400,
)
return disable_optional_feature(
name,
config_path=config_path,
instance_id=instance_id,
)
return disable_optional_feature(name, instance_id=instance_id)
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
+135 -168
View File
@@ -14,11 +14,11 @@ import math
import os
import re
import secrets
import threading
import time
from collections.abc import Iterable
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import Any, Literal, cast
from zoneinfo import ZoneInfo
import httpx
@@ -49,9 +49,6 @@ from nanobot.webui.workspaces import (
QueryParams = dict[str, list[str]]
RuntimeSurface = Literal["browser", "native"]
if TYPE_CHECKING:
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
def _version_payload() -> dict[str, Any]:
"""Return version info for the settings payload."""
@@ -136,6 +133,9 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
_WEBUI_OAUTH_TIMEOUT_S = 600
_WEBUI_OAUTH_MAX_FLOWS = 8
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
_webui_oauth_flows_lock = threading.Lock()
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
@@ -148,21 +148,6 @@ class WebUISettingsError(ValueError):
self.status = status
def _load_settings_config(config_path: Path | None) -> Config:
return load_config(config_path) if config_path is not None else load_config()
def _save_settings_config(config: Config, config_path: Path | None) -> None:
if config_path is None:
save_config(config)
else:
save_config(config, config_path)
def _settings_config_path(config_path: Path | None) -> Path:
return config_path if config_path is not None else get_config_path()
def _normalize_surface(surface: str | None) -> RuntimeSurface:
return "native" if surface in {"native", "desktop"} else "browser"
@@ -779,11 +764,7 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
return rows
def provider_models_payload(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
"""Fetch an OpenAI-compatible provider's model list for Settings.
The result is advisory only: users can always type a custom model id. This
@@ -794,7 +775,7 @@ def provider_models_payload(
if not provider_name:
raise WebUISettingsError("provider is required")
config = _load_settings_config(config_path)
config = load_config()
resolved_provider = _resolve_settings_provider(config, provider_name)
if resolved_provider is None:
raise WebUISettingsError("unknown provider")
@@ -1136,9 +1117,8 @@ def settings_payload(
runtime_capability_overrides: dict[str, Any] | None = None,
restart_required_sections: list[str] | None = None,
apply_state: dict[str, Any] | None = None,
config_path: Path | None = None,
) -> dict[str, Any]:
config = _load_settings_config(config_path)
config = load_config()
defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default"
effective_preset = config.resolve_preset()
@@ -1319,7 +1299,7 @@ def settings_payload(
"providers": _transcription_provider_rows(config),
},
"runtime": {
"config_path": str(_settings_config_path(config_path).expanduser()),
"config_path": str(get_config_path().expanduser()),
"workspace_path": str(config.workspace_path),
"gateway_host": config.gateway.host,
"gateway_port": config.gateway.port,
@@ -1361,18 +1341,14 @@ def settings_payload(
)
def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]:
def settings_usage_payload() -> dict[str, Any]:
"""Return the lightweight token usage slice for Overview refreshes."""
config = _load_settings_config(config_path)
config = load_config()
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
def update_agent_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
config = _load_settings_config(config_path)
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
changed = False
restart_required = False
@@ -1449,15 +1425,11 @@ def update_agent_settings(
restart_required = True
if changed:
_save_settings_config(config, config_path)
return settings_payload(requires_restart=restart_required, config_path=config_path)
save_config(config)
return settings_payload(requires_restart=restart_required)
def create_model_configuration(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
label = (_query_first_alias(query, "label", "displayName") or "").strip()
raw_name = (_query_first(query, "name") or label).strip()
model = (_query_first(query, "model") or "").strip()
@@ -1471,7 +1443,7 @@ def create_model_configuration(
raise WebUISettingsError("provider is required")
name = _model_configuration_slug(raw_name or label)
config = _load_settings_config(config_path)
config = load_config()
if name in config.model_presets:
raise WebUISettingsError("configuration already exists", status=409)
_validate_configured_provider(config, provider)
@@ -1504,22 +1476,18 @@ def create_model_configuration(
temperature=temperature if temperature is not None else base.temperature,
reasoning_effort=reasoning_effort,
)
_save_settings_config(config, config_path)
payload = settings_payload(config_path=config_path)
save_config(config)
payload = settings_payload()
payload["created_model_preset"] = name
return payload
def update_model_configuration(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
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_settings_config(config_path)
config = load_config()
preset = config.model_presets.get(name)
if preset is None:
raise WebUISettingsError("unknown model configuration")
@@ -1586,15 +1554,11 @@ def update_model_configuration(
changed = True
if changed:
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
save_config(config)
return settings_payload()
def update_model_call_order(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
raw_order = _query_first_alias(query, "order", "presetNames")
if raw_order is None:
raise WebUISettingsError("model call order is required")
@@ -1616,7 +1580,7 @@ def update_model_call_order(
cast(str, name).strip()
for name in cast(list[object], order)
]
config = _load_settings_config(config_path)
config = load_config()
_, editable = _model_call_order_state(config)
if not editable:
raise WebUISettingsError(
@@ -1635,17 +1599,13 @@ def update_model_call_order(
):
defaults.model_preset = normalized_order[0]
defaults.fallback_models = fallback_models
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
save_config(config)
return settings_payload()
def migrate_model_configurations(
_query: QueryParams | None = None,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
"""Materialize legacy primary/inline model settings as named presets."""
config = _load_settings_config(config_path)
config = load_config()
defaults = config.agents.defaults
primary = config.resolve_preset()
created: list[str] = []
@@ -1698,20 +1658,16 @@ def migrate_model_configurations(
if created:
defaults.fallback_models = fallback_models
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
save_config(config)
return settings_payload()
def delete_model_configuration(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def delete_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_settings_config(config_path)
config = load_config()
if name not in config.model_presets:
raise WebUISettingsError("unknown model configuration")
defaults = config.agents.defaults
@@ -1725,15 +1681,11 @@ def delete_model_configuration(
)
del config.model_presets[name]
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
save_config(config)
return settings_payload()
def create_provider_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
if not display_name:
raise WebUISettingsError("provider name is required")
@@ -1758,7 +1710,7 @@ def create_provider_settings(
if not api_base:
raise WebUISettingsError("API base is required")
config = _load_settings_config(config_path)
config = load_config()
if _provider_display_name_exists(config, display_name):
raise WebUISettingsError("provider already exists", status=409)
@@ -1767,22 +1719,18 @@ def create_provider_settings(
updates["api_type"] = "auto"
provider_config = _validated_provider_config(None, updates)
setattr(config.providers, provider_key, provider_config)
_save_settings_config(config, config_path)
payload = settings_payload(config_path=config_path)
save_config(config)
payload = settings_payload()
payload["created_provider"] = provider_key
return payload
def update_provider_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
if not provider_name:
raise WebUISettingsError("provider is required")
config = _load_settings_config(config_path)
config = load_config()
resolved_provider = _resolve_settings_provider(config, provider_name)
if resolved_provider is None:
raise WebUISettingsError("unknown provider")
@@ -1824,7 +1772,7 @@ def update_provider_settings(
changed = updated_provider_config != provider_config
if changed:
setattr(config.providers, provider_key, updated_provider_config)
_save_settings_config(config, config_path)
save_config(config)
image_config = config.tools.image_generation
restart_required = (
changed
@@ -1832,15 +1780,10 @@ def update_provider_settings(
and image_config.provider == provider_key
and get_image_gen_provider(provider_key) is not None
)
return settings_payload(requires_restart=restart_required, config_path=config_path)
return settings_payload(requires_restart=restart_required)
def login_oauth_provider(
query: QueryParams,
*,
oauth_flows: WebUIOAuthFlowRegistry,
config_path: Path | None = None,
) -> dict[str, Any]:
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")
@@ -1855,10 +1798,7 @@ def login_oauth_provider(
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
try:
proxy = resolve_config_env_vars(
_load_settings_config(config_path),
config_path=config_path,
).providers.openai_codex.proxy or None
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
except ValueError as e:
raise WebUISettingsError(str(e), status=400) from e
remote_browser_value = _query_first(query, "remote_browser")
@@ -1876,7 +1816,7 @@ def login_oauth_provider(
except Exception as e:
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
flow_id = secrets.token_urlsafe(24)
oauth_flows.register(spec.name, flow_id, flow)
_register_webui_oauth_flow(spec.name, flow_id, flow)
return {
"status": "authorization_required",
"provider": spec.name,
@@ -1900,16 +1840,13 @@ def login_oauth_provider(
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(config_path=config_path)
return settings_payload()
if spec.name == "xai_grok":
from nanobot.providers.xai_oauth import start_xai_oauth_login
try:
proxy = resolve_config_env_vars(
_load_settings_config(config_path),
config_path=config_path,
).providers.xai_grok.proxy or None
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
except ValueError as e:
raise WebUISettingsError(str(e), status=400) from e
try:
@@ -1920,7 +1857,7 @@ def login_oauth_provider(
except Exception as e:
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
flow_id = secrets.token_urlsafe(24)
oauth_flows.register(spec.name, flow_id, flow)
_register_webui_oauth_flow(spec.name, flow_id, flow)
return {
"status": "authorization_required",
"provider": spec.name,
@@ -1936,9 +1873,6 @@ def login_oauth_provider(
def complete_oauth_provider(
query: QueryParams,
authorization_response: str | None = None,
*,
oauth_flows: WebUIOAuthFlowRegistry,
config_path: Path | None = None,
) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip()
flow_id = (_query_first(query, "flow_id") or "").strip()
@@ -1948,7 +1882,7 @@ def complete_oauth_provider(
if not flow_id:
raise WebUISettingsError("flow_id is required")
flow = oauth_flows.get(spec.name, flow_id)
flow = _get_webui_oauth_flow(spec.name, flow_id)
if flow is None:
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
@@ -1970,7 +1904,7 @@ def complete_oauth_provider(
except WebUISettingsError:
raise
except Exception as e:
oauth_flows.remove(spec.name, flow_id, flow)
_remove_webui_oauth_flow(spec.name, flow_id, flow)
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
if token is None:
return {
@@ -1978,18 +1912,13 @@ def complete_oauth_provider(
"provider": spec.name,
"flow_id": flow_id,
}
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
if not token.access:
raise WebUISettingsError("OAuth login failed", status=401)
return settings_payload(config_path=config_path)
return settings_payload()
def logout_oauth_provider(
query: QueryParams,
*,
oauth_flows: WebUIOAuthFlowRegistry,
config_path: Path | None = None,
) -> dict[str, Any]:
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")
@@ -2003,7 +1932,7 @@ def logout_oauth_provider(
from oauth_cli_kit.storage import FileTokenStorage
except ImportError:
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
oauth_flows.clear(spec.name)
_clear_webui_oauth_flows(spec.name)
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
elif spec.name == "github_copilot":
try:
@@ -2014,23 +1943,77 @@ def logout_oauth_provider(
elif spec.name == "xai_grok":
from nanobot.providers.xai_oauth import logout_xai_oauth
oauth_flows.clear(spec.name)
_clear_webui_oauth_flows(spec.name)
logout_xai_oauth()
return settings_payload(config_path=config_path)
return settings_payload()
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(config_path=config_path)
return settings_payload()
def update_network_safety_settings(
query: QueryParams,
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
discarded: list[Any] = []
with _webui_oauth_flows_lock:
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
if existing.expired:
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
oldest_id = next(iter(_webui_oauth_flows))
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
_webui_oauth_flows[flow_id] = (provider_name, flow)
for existing in discarded:
existing.cancel()
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
with _webui_oauth_flows_lock:
registered = _webui_oauth_flows.get(flow_id)
if registered is None or registered[0] != provider_name:
return None
flow = registered[1]
if not flow.expired:
return flow
_webui_oauth_flows.pop(flow_id, None)
flow.cancel()
return None
def _remove_webui_oauth_flow(
provider_name: str,
flow_id: str,
flow: Any,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
cancel: bool = True,
) -> None:
with _webui_oauth_flows_lock:
registered = _webui_oauth_flows.get(flow_id)
if (
registered is not None
and registered[0] == provider_name
and registered[1] is flow
):
_webui_oauth_flows.pop(flow_id)
if cancel:
flow.cancel()
def _clear_webui_oauth_flows(provider_name: str) -> None:
with _webui_oauth_flows_lock:
flow_ids = [
flow_id
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
if registered_provider == provider_name
]
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
for flow in flows:
flow.cancel()
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")
@@ -2039,7 +2022,7 @@ def update_network_safety_settings(
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_settings_config(config_path)
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")
@@ -2048,7 +2031,7 @@ def update_network_safety_settings(
changed = True
if changed:
_save_settings_config(config, config_path)
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":
@@ -2059,20 +2042,16 @@ def update_network_safety_settings(
write_webui_default_access_mode(default_access_mode)
except ValueError as exc:
raise WebUISettingsError(str(exc)) from exc
return settings_payload(requires_restart=changed, config_path=config_path)
return settings_payload(requires_restart=changed)
def update_web_search_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
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)
if provider_option is None:
raise WebUISettingsError("unknown web search provider")
config = _load_settings_config(config_path)
config = load_config()
search_config = config.tools.web.search
web_config = config.tools.web
previous_provider = search_config.provider
@@ -2151,17 +2130,13 @@ def update_web_search_settings(
restart_required = True
if changed:
_save_settings_config(config, config_path)
return settings_payload(requires_restart=restart_required, config_path=config_path)
save_config(config)
return settings_payload(requires_restart=restart_required)
def update_api_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
def update_api_settings(query: QueryParams) -> dict[str, Any]:
"""Update the managed OpenAI-compatible API configuration."""
config = _load_settings_config(config_path)
config = load_config()
api = config.api
host = _query_first(query, "host")
@@ -2198,16 +2173,12 @@ def update_api_settings(
if not is_loopback_host(api.host) and not api.api_key.strip():
raise WebUISettingsError("an API key is required when the API is available on the network")
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
save_config(config)
return settings_payload()
def update_image_generation_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
config = _load_settings_config(config_path)
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
image_config = config.tools.image_generation
changed = False
@@ -2300,16 +2271,12 @@ def update_image_generation_settings(
raise WebUISettingsError("image generation provider is not configured")
if changed:
_save_settings_config(config, config_path)
return settings_payload(requires_restart=changed, config_path=config_path)
save_config(config)
return settings_payload(requires_restart=changed)
def update_transcription_settings(
query: QueryParams,
*,
config_path: Path | None = None,
) -> dict[str, Any]:
config = _load_settings_config(config_path)
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
transcription = config.transcription
changed = False
@@ -2374,5 +2341,5 @@ def update_transcription_settings(
changed = True
if changed:
_save_settings_config(config, config_path)
return settings_payload(config_path=config_path)
save_config(config)
return settings_payload()
+193 -424
View File
@@ -8,19 +8,18 @@ request mapping and response shaping.
from __future__ import annotations
import asyncio
import html
import inspect
import json
import time
from collections.abc import Callable
from typing import Any, cast
from urllib.parse import unquote
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
from nanobot.bus.queue import MessageBus
from nanobot.channels._setup import channel_setup_spec
@@ -32,7 +31,7 @@ from nanobot.channels.contracts import (
)
from nanobot.channels.registry import load_channel_plugin
from nanobot.channels.validation import validate_channel_config
from nanobot.config.schema import Config
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.optional_features import (
OptionalFeatureError,
extra_installed,
@@ -41,11 +40,10 @@ from nanobot.optional_features import (
)
from nanobot.pairing import approve_code, deny_code, list_pending
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.http_utils import http_response as _http_response
from nanobot.webui.http_utils import case_insensitive_header
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first
from nanobot.webui.mcp_oauth_api import McpOAuthManager
from nanobot.webui.mcp_presets_api import ensure_mcp_oauth_server, mcp_presets_settings_action
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
from nanobot.webui.nanobot_features_api import (
nanobot_feature_instance_target,
nanobot_features_action,
@@ -74,17 +72,24 @@ from nanobot.webui.settings_api import (
update_transcription_settings,
update_web_search_settings,
)
from nanobot.webui.settings_services import WebUISettingsServices
from nanobot.webui.version_check import check_for_update
QueryParams = dict[str, list[str]]
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
_SKIP_FIELD = object()
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
def _channel_connect_route(path: str) -> tuple[str, str] | None:
@@ -99,7 +104,6 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/disable": "disable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
@@ -108,66 +112,6 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/tools": "tools",
}
_SETTINGS_MUTATION_PATHS = frozenset({
"/api/settings/update",
"/api/settings/model-configurations/create",
"/api/settings/model-configurations/update",
"/api/settings/model-configurations/delete",
"/api/settings/model-configurations/migrate",
"/api/settings/model-call-order/update",
"/api/settings/provider/update",
"/api/settings/provider/create",
"/api/settings/provider/oauth-login",
"/api/settings/provider/oauth-login/complete",
"/api/settings/provider/oauth-logout",
"/api/settings/web-search/update",
"/api/settings/api-service/start",
"/api/settings/api-service/stop",
"/api/settings/image-generation/update",
"/api/settings/transcription/update",
"/api/settings/network-safety/update",
"/api/settings/cli-apps/install",
"/api/settings/cli-apps/update",
"/api/settings/cli-apps/uninstall",
"/api/settings/cli-apps/test",
"/api/settings/nanobot-features/enable",
"/api/settings/nanobot-features/disable",
"/api/settings/channels/validate",
"/api/settings/channels/configure",
"/api/settings/pairing/approve",
"/api/settings/pairing/deny",
"/api/settings/mcp-oauth/start",
"/api/settings/mcp-oauth/complete",
"/api/settings/mcp-oauth/cancel",
*_MCP_PRESET_ACTIONS_BY_PATH,
})
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
if not isinstance(payload, dict):
return None
return cast(dict[str, Any], payload)
def _query_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if value is None:
return ""
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
return str(value)
def _payload_query(payload: dict[str, Any]) -> QueryParams:
return {
key: [_query_value(value)]
for key, value in payload.items()
if key
and key not in {"authorization_response", "channel", "values"}
}
class WebUISettingsRouter:
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
@@ -175,7 +119,6 @@ class WebUISettingsRouter:
def __init__(
self,
*,
settings: WebUISettingsServices,
bus: MessageBus,
logger: Any,
check_api_token: Callable[[WsRequest], bool],
@@ -186,9 +129,7 @@ class WebUISettingsRouter:
runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
) -> None:
self.settings = settings
self.bus = bus
self.logger = logger
self._check_api_token = check_api_token
@@ -199,23 +140,10 @@ class WebUISettingsRouter:
self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action
self._channel_runtime_status = channel_runtime_status
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
self._mcp_oauth = McpOAuthManager()
self._restart_sections: set[str] = set()
self._channel_connectors: dict[str, Any] = {}
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
if self.is_mutation_path(path) and not getattr(
request,
_WEBUI_MUTATION_REQUEST_ATTR,
False,
):
return self._error_response(
405,
"WebUI mutations require an authenticated WebSocket",
)
if path == MCP_OAUTH_CALLBACK_PATH:
return self._handle_mcp_oauth_callback(request)
if path == "/api/settings":
return self._handle_settings(request)
if path == "/api/settings/usage":
@@ -294,33 +222,15 @@ class WebUISettingsRouter:
if path == "/api/settings/pairing/deny":
return self._handle_settings_pairing_action(request, "deny")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(connection, request)
if path == "/api/settings/mcp-oauth/start":
return await self._handle_mcp_oauth_start(request)
if path == "/api/settings/mcp-oauth/status":
return await self._handle_mcp_oauth_status(request)
if path == "/api/settings/mcp-oauth/complete":
return self._handle_mcp_oauth_complete(request)
if path == "/api/settings/mcp-oauth/cancel":
return await self._handle_mcp_oauth_cancel(request)
return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check":
return await self._handle_settings_version_check(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(connection, request, mcp_action)
return await self._handle_settings_mcp_presets(request, mcp_action)
return None
@staticmethod
def is_mutation_path(path: str) -> bool:
return (
path in _SETTINGS_MUTATION_PATHS
or _channel_connect_route(path) is not None
)
def _query(self, request: WsRequest) -> QueryParams:
payload = _mutation_payload(request)
if payload is not None:
return _payload_query(payload)
return self._parse_query(request.path)
def _authorized(self, request: WsRequest) -> bool:
@@ -350,18 +260,70 @@ class WebUISettingsRouter:
)
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request)
query = self._query(request)
raw = request.headers.get(_MCP_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("MCP settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid MCP settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("MCP settings payload must be a JSON object")
payload = cast(dict[object, Any], payload)
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("MCP settings payload contains an invalid key")
if value is None:
continue
if isinstance(value, str):
text = value.strip()
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if text:
merged[key] = [text]
return merged
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request)
query = self._query(request)
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("provider settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
try:
payload = json.loads(unquote(raw))
except json.JSONDecodeError:
raise WebUISettingsError("invalid provider settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("provider settings payload must be a JSON object")
payload = cast(dict[object, Any], payload)
merged = {key: list(values) for key, values in query.items()}
for key, value in payload.items():
if not isinstance(key, str) or not key:
raise WebUISettingsError("provider settings payload contains an invalid key")
if isinstance(value, str):
text = value
elif value is None:
text = ""
else:
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
merged[key] = [text]
return merged
def _handle_settings(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(
self._with_restart_state(
self.settings.read(
settings_payload,
settings_payload(
surface=self._runtime_surface,
runtime_capability_overrides=self._runtime_capabilities,
)
@@ -371,7 +333,7 @@ class WebUISettingsRouter:
def _handle_settings_usage(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(self.settings.read(settings_usage_payload))
return self._json_response(settings_usage_payload())
def _handle_settings_pairing(self, request: WsRequest) -> Response:
if not self._authorized(request):
@@ -417,7 +379,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(update_agent_settings, self._query(request))
payload = update_agent_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
@@ -426,10 +388,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
create_model_configuration,
self._query(request),
)
payload = create_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -438,10 +397,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_model_configuration,
self._query(request),
)
payload = update_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -450,10 +406,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
delete_model_configuration,
self._query(request),
)
payload = delete_model_configuration(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -462,10 +415,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
migrate_model_configurations,
self._query(request),
)
payload = migrate_model_configurations(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -474,10 +424,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_model_call_order,
self._query(request),
)
payload = update_model_call_order(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -486,10 +433,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_provider_settings,
self._parse_provider_settings_query(request)
)
payload = update_provider_settings(self._parse_provider_settings_query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
payload = await self._apply_image_generation_runtime_change(payload)
@@ -499,10 +443,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
create_provider_settings,
self._parse_provider_settings_query(request)
)
payload = create_provider_settings(self._parse_provider_settings_query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -511,11 +452,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(
self.settings.read,
provider_models_payload,
self._query(request),
)
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
@@ -533,33 +470,27 @@ class WebUISettingsRouter:
query = self._query(request)
try:
if action == "login":
payload = await asyncio.to_thread(
self.settings.read,
login_oauth_provider,
query,
oauth_flows=self.settings.oauth_flows,
)
payload = await asyncio.to_thread(login_oauth_provider, query)
elif action == "complete":
raw_response = (_mutation_payload(request) or {}).get(
"authorization_response"
authorization_response = case_insensitive_header(
request.headers,
_OAUTH_CALLBACK_HEADER,
) or case_insensitive_header(
request.headers,
_OAUTH_CODE_HEADER,
)
if raw_response is not None and not isinstance(raw_response, str):
raise WebUISettingsError("OAuth authorization response must be a string")
authorization_response = raw_response
if (
len(authorization_response.encode("utf-8"))
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
):
raise WebUISettingsError("OAuth authorization response is too large")
payload = await asyncio.to_thread(
self.settings.read,
complete_oauth_provider,
query,
authorization_response or None,
oauth_flows=self.settings.oauth_flows,
)
else:
payload = await asyncio.to_thread(
self.settings.read,
logout_oauth_provider,
query,
oauth_flows=self.settings.oauth_flows,
)
payload = await asyncio.to_thread(logout_oauth_provider, query)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
if payload.get("status") in {"authorization_required", "pending"}:
@@ -570,10 +501,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_web_search_settings,
self._query(request),
)
payload = update_web_search_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
@@ -592,22 +520,19 @@ class WebUISettingsRouter:
return self._unauthorized()
try:
await asyncio.to_thread(
self._nanobot_features_action,
nanobot_features_action,
"enable",
{"name": ["api"]},
allow_install=self._allow_feature_package_install(connection, request),
)
self.settings.mutate(
update_api_settings,
self._parse_api_service_settings_query(request),
)
config = self.settings.config.load()
update_api_settings(self._parse_api_service_settings_query(request))
config = load_config()
runtime = self._api_runtime()
options = ApiStartOptions(
host=config.api.host,
port=config.api.port,
workspace=str(config.workspace_path),
config_path=str(self.settings.config.path),
config_path=str(get_config_path().expanduser().resolve(strict=False)),
)
current = runtime.status()
result = await asyncio.to_thread(
@@ -624,12 +549,33 @@ class WebUISettingsRouter:
return self._json_response(self._api_service_payload(last_action="started"))
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
payload = _mutation_payload(request)
if payload is not None:
api_key = payload.get("api_key")
if api_key is not None and not isinstance(api_key, str):
raise WebUISettingsError("API service API key must be a string")
return self._query(request)
query = self._query(request)
if "api_key" in query or "apiKey" in query:
raise WebUISettingsError("API service API key must be provided in the private header")
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("API service settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid API service settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("API service settings payload must be a JSON object")
payload = cast(dict[str, Any], payload)
unknown = set(payload) - {"api_key"}
if unknown:
raise WebUISettingsError("API service settings payload contains an invalid key")
api_key = payload.get("api_key")
if api_key is not None and not isinstance(api_key, str):
raise WebUISettingsError("API service API key must be a string")
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
if api_key is not None:
merged["api_key"] = [api_key]
return merged
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
if not self._authorized(request):
@@ -643,11 +589,13 @@ class WebUISettingsRouter:
return self._error_response(500, self._api_runtime_message(result.message))
return self._json_response(self._api_service_payload(last_action="stopped"))
def _api_runtime(self) -> ApiRuntime:
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
@staticmethod
def _api_runtime() -> ApiRuntime:
config_path = get_config_path().expanduser().resolve(strict=False)
return ApiRuntime(paths=api_runtime_paths(config_path))
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
config = self.settings.config.load()
config = load_config()
status = self._api_runtime().status()
extras = optional_dependency_groups()
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
@@ -691,10 +639,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_image_generation_settings,
self._query(request),
)
payload = update_image_generation_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
payload = await self._apply_image_generation_runtime_change(payload)
@@ -729,10 +674,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_transcription_settings,
self._query(request),
)
payload = update_transcription_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload))
@@ -741,10 +683,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = self.settings.mutate(
update_network_safety_settings,
self._query(request),
)
payload = update_network_safety_settings(self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="runtime"))
@@ -758,10 +697,7 @@ class WebUISettingsRouter:
"yes",
}
try:
payload = await cli_apps_payload(
installed_only=installed_only,
config_path=self.settings.config.path,
)
payload = await cli_apps_payload(installed_only=installed_only)
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return self._error_response(500, "failed to load CLI Apps")
@@ -775,12 +711,7 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(
cli_apps_action,
action,
self._query(request),
config_path=self.settings.config.path,
)
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception as e:
@@ -795,29 +726,12 @@ class WebUISettingsRouter:
if not self._authorized(request):
return self._unauthorized()
try:
payload = await asyncio.to_thread(self._nanobot_features_payload)
payload = await asyncio.to_thread(nanobot_features_payload)
except Exception:
self.logger.exception("failed to load nanobot features")
return self._error_response(500, "failed to load nanobot features")
return self._json_response(self._with_channel_runtime_status(payload))
def _nanobot_features_payload(self) -> dict[str, Any]:
return nanobot_features_payload(config_path=self.settings.config.path)
def _nanobot_features_action(
self,
action: str,
query: QueryParams,
*,
allow_install: bool = True,
) -> dict[str, Any]:
return self.settings.mutate(
nanobot_features_action,
action,
query,
allow_install=allow_install,
)
async def _handle_settings_nanobot_features_action(
self,
connection: Any,
@@ -828,7 +742,7 @@ class WebUISettingsRouter:
return self._unauthorized()
try:
payload = await asyncio.to_thread(
self._nanobot_features_action,
nanobot_features_action,
action,
self._query(request),
allow_install=action != "enable"
@@ -936,7 +850,7 @@ class WebUISettingsRouter:
saved = await asyncio.to_thread(
self._save_channel_config_values,
name,
self._parse_channel_values(request),
self._parse_channel_values_header(request),
instance_id,
)
except WebUISettingsError as e:
@@ -951,7 +865,7 @@ class WebUISettingsRouter:
"saved_keys": saved,
}
if not enable:
features = await asyncio.to_thread(self._nanobot_features_payload)
features = await asyncio.to_thread(nanobot_features_payload)
features = self._with_channel_runtime_status(features)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
@@ -962,7 +876,7 @@ class WebUISettingsRouter:
try:
features = await asyncio.to_thread(
self._nanobot_features_action,
nanobot_features_action,
"enable",
feature_query,
allow_install=self._allow_feature_package_install(connection, request),
@@ -992,7 +906,7 @@ class WebUISettingsRouter:
payload = await asyncio.to_thread(
validate_channel_config,
name,
self._parse_channel_values(request),
self._parse_channel_values_header(request),
instance_id=instance_id,
)
except WebUISettingsError as e:
@@ -1002,14 +916,19 @@ class WebUISettingsRouter:
return self._error_response(500, "failed to validate channel settings")
return self._json_response(payload)
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
payload = _mutation_payload(request)
if payload is None or "values" not in payload:
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
if not raw:
return {}
values = payload.get("values")
if not isinstance(values, dict):
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("channel settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid channel settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("channel settings payload must be a JSON object")
return cast(dict[str, Any], values)
return cast(dict[str, Any], payload)
def _save_channel_config_values(
self,
@@ -1030,47 +949,44 @@ class WebUISettingsRouter:
if not raw_values:
return []
def update(config: Config) -> list[str]:
section = getattr(config.channels, name, None)
channel_config = channel_instance_config(
config = load_config()
section = getattr(config.channels, name, None)
channel_config = channel_instance_config(
plugin,
section,
instance_id=instance_id,
)
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not raw_key:
raise WebUISettingsError("channel settings payload contains an invalid key")
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
try:
updated_section = channel_update_instance_config(
plugin,
section,
channel_config,
instance_id=instance_id,
)
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not raw_key:
raise WebUISettingsError(
"channel settings payload contains an invalid key"
)
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
try:
updated_section = channel_update_instance_config(
plugin,
section,
channel_config,
instance_id=instance_id,
)
except ValueError as exc:
raise WebUISettingsError(
f"Invalid {name} configuration: {exc}",
status=400,
) from exc
setattr(config.channels, name, updated_section)
return saved
return self.settings.config.update(update)
except ValueError as exc:
raise WebUISettingsError(
f"Invalid {name} configuration: {exc}",
status=400,
) from exc
setattr(config.channels, name, updated_section)
save_config(config)
return saved
@staticmethod
def _coerce_channel_value(
@@ -1193,14 +1109,14 @@ class WebUISettingsRouter:
target["instance_id"] = [str(payload["instance_id"])]
try:
features = await asyncio.to_thread(
self._nanobot_features_action,
nanobot_features_action,
"enable",
target,
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
self._nanobot_features_payload(),
nanobot_features_payload(),
message=(
f"{channel_name} connected, but enabling channel support failed: "
f"{exc.message}"
@@ -1221,29 +1137,23 @@ class WebUISettingsRouter:
if _is_local_browser_request(connection, request.headers):
return True
try:
return bool(
self.settings.config.load().tools.webui_allow_remote_package_install
)
return bool(load_config().tools.webui_allow_remote_package_install)
except Exception:
self.logger.exception("failed to load remote package install policy")
return False
async def _handle_settings_mcp_presets(
self,
connection: Any,
request: WsRequest,
action: str | None = None,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
query = self._parse_mcp_settings_query(request)
payload = await mcp_presets_settings_action(
action,
query,
self._parse_mcp_settings_query(request),
reload_mcp=lambda: request_mcp_reload(self.bus),
config=self.settings.config,
remote=not _is_local_browser_request(connection, request.headers),
)
except Exception as e:
status = getattr(e, "status", 500)
@@ -1255,147 +1165,6 @@ class WebUISettingsRouter:
return self._json_response(payload)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _handle_mcp_oauth_start(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
if self._mcp_oauth_redirect_uri is None:
return self._error_response(500, "MCP OAuth callback is not configured")
query = self._parse_mcp_settings_query(request)
try:
name, cfg = await asyncio.to_thread(
self.settings.mutate,
ensure_mcp_oauth_server,
query,
)
redirect_uri = self._mcp_oauth_redirect_uri(request)
reset = (_query_first(query, "reset") or "").lower() in {"1", "true", "yes"}
payload = await self._mcp_oauth.start(
name,
cfg,
redirect_uri,
reload_mcp=lambda: request_mcp_reload(self.bus),
reset_credentials=reset,
)
except Exception as exc:
return self._mcp_oauth_error_response(exc, action="start")
return self._json_response(payload)
async def _handle_mcp_oauth_status(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
flow_id = (_query_first(self._query(request), "flow_id") or "").strip()
if not flow_id:
return self._error_response(400, "missing MCP OAuth flow ID")
try:
payload = await self._mcp_oauth.status(flow_id)
except Exception as exc:
return self._mcp_oauth_error_response(exc, action="status")
return self._json_response(payload)
def _handle_mcp_oauth_complete(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
flow_id = (_query_first(query, "flow_id") or "").strip()
if not flow_id:
return self._error_response(400, "missing MCP OAuth flow ID")
callback_url = (_query_first(query, "callback_url") or "").strip()
if not callback_url:
return self._error_response(400, "Paste the complete callback URL to continue")
if len(callback_url.encode("utf-8")) > _MCP_OAUTH_CALLBACK_URL_MAX_BYTES:
return self._error_response(400, "The MCP OAuth callback URL is too long")
try:
payload = self._mcp_oauth.submit_callback_url(
flow_id=flow_id,
callback_url=callback_url,
)
except Exception as exc:
return self._mcp_oauth_error_response(exc, action="complete")
return self._json_response(payload)
async def _handle_mcp_oauth_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
flow_id = (_query_first(self._query(request), "flow_id") or "").strip()
if not flow_id:
return self._error_response(400, "missing MCP OAuth flow ID")
try:
payload = await self._mcp_oauth.cancel(flow_id)
except Exception as exc:
return self._mcp_oauth_error_response(exc, action="cancel")
return self._json_response(payload)
def _handle_mcp_oauth_callback(self, request: WsRequest) -> Response:
query = self._query(request)
state = (_query_first(query, "state") or "").strip()
if not state:
return self._mcp_oauth_callback_page(
ok=False,
message="This authorization request is missing its security state.",
status=400,
)
try:
name = self._mcp_oauth.submit_callback(
state=state,
code=_query_first(query, "code"),
error=_query_first(query, "error"),
)
except Exception as exc:
status = int(getattr(exc, "status", 400))
message = str(getattr(exc, "message", "Could not complete MCP authorization"))
return self._mcp_oauth_callback_page(ok=False, message=message, status=status)
return self._mcp_oauth_callback_page(
ok=True,
message=f"Authorization received for {name}. Return to nanobot to finish connecting.",
)
def _mcp_oauth_error_response(self, exc: Exception, *, action: str) -> Response:
raw_status = getattr(exc, "status", 500)
status = raw_status if isinstance(raw_status, int) and 400 <= raw_status <= 599 else 500
if status >= 500:
self.logger.exception("MCP OAuth '{}' failed", action)
message = f"MCP OAuth {action} failed"
else:
raw_message = getattr(exc, "message", None)
message = raw_message if isinstance(raw_message, str) else "MCP OAuth request failed"
return self._error_response(status, message)
@staticmethod
def _mcp_oauth_callback_page(
*,
ok: bool,
message: str,
status: int = 200,
) -> Response:
title = "Authorization received" if ok else "Connection failed"
safe_title = html.escape(title)
safe_message = html.escape(message)
close_script = "<script>setTimeout(() => window.close(), 700)</script>" if ok else ""
body = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>{safe_title}</title><style>"
"body{font:16px system-ui;margin:0;min-height:100vh;display:grid;place-items:center;"
"background:#f7f7f6;color:#171717}.card{max-width:34rem;margin:2rem;padding:2rem;"
"border:1px solid #ddd;border-radius:16px;background:white}h1{font-size:1.35rem}"
"p{line-height:1.55;color:#555}</style></head><body><main class='card'>"
f"<h1>{safe_title}</h1><p>{safe_message}</p></main>{close_script}</body></html>"
).encode("utf-8")
return _http_response(
body,
status=status,
content_type="text/html; charset=utf-8",
extra_headers=[
("Cache-Control", "no-store"),
("Referrer-Policy", "no-referrer"),
(
"Content-Security-Policy",
"default-src 'none'; base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
),
],
)
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
-148
View File
@@ -1,148 +0,0 @@
"""Gateway-owned state for the WebUI settings surface."""
from __future__ import annotations
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
_T = TypeVar("_T")
_WEBUI_OAUTH_MAX_FLOWS = 8
class WebUISettingsConfig:
"""Instance-scoped config access with serialized read-modify-write operations."""
def __init__(self, config_path: Path) -> None:
self.path = config_path.expanduser().resolve(strict=False)
self._lock = threading.RLock()
def load(self) -> Config:
"""Load this gateway's config without consulting the process-global path."""
with self._lock:
return load_config(self.path)
def update(self, mutation: Callable[[Config], _T]) -> _T:
"""Apply and atomically persist one in-process read-modify-write operation."""
with self._lock:
config = load_config(self.path)
result = mutation(config)
save_config(config, self.path)
return result
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
"""Run a path-aware read-modify-write operation under the instance lock."""
with self._lock:
return operation(self.path)
class WebUIOAuthFlowRegistry:
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
if max_flows < 1:
raise ValueError("max_flows must be at least one")
self._max_flows = max_flows
self._flows: dict[str, tuple[str, Any]] = {}
self._lock = threading.Lock()
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
discarded: list[Any] = []
with self._lock:
for existing_id, (_provider_name, existing) in list(self._flows.items()):
if existing.expired:
discarded.append(self._flows.pop(existing_id)[1])
while len(self._flows) >= self._max_flows:
oldest_id = next(iter(self._flows))
discarded.append(self._flows.pop(oldest_id)[1])
self._flows[flow_id] = (provider_name, flow)
for existing in discarded:
existing.cancel()
def get(self, provider_name: str, flow_id: str) -> Any | None:
with self._lock:
registered = self._flows.get(flow_id)
if registered is None or registered[0] != provider_name:
return None
flow = registered[1]
if not flow.expired:
return flow
self._flows.pop(flow_id, None)
flow.cancel()
return None
def remove(
self,
provider_name: str,
flow_id: str,
flow: Any,
*,
cancel: bool = True,
) -> None:
with self._lock:
registered = self._flows.get(flow_id)
if (
registered is not None
and registered[0] == provider_name
and registered[1] is flow
):
self._flows.pop(flow_id)
if cancel:
flow.cancel()
def clear(self, provider_name: str) -> None:
with self._lock:
flow_ids = [
flow_id
for flow_id, (registered_provider, _flow) in self._flows.items()
if registered_provider == provider_name
]
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
for flow in flows:
flow.cancel()
@dataclass(frozen=True)
class WebUISettingsServices:
"""Settings dependencies composed once for a gateway instance."""
config: WebUISettingsConfig
oauth_flows: WebUIOAuthFlowRegistry
@classmethod
def create(cls, config_path: Path) -> WebUISettingsServices:
return cls(
config=WebUISettingsConfig(config_path),
oauth_flows=WebUIOAuthFlowRegistry(),
)
def read(
self,
operation: Callable[..., _T],
/,
*args: Any,
**kwargs: Any,
) -> _T:
"""Run a settings read against this gateway's explicit config path."""
return operation(*args, config_path=self.config.path, **kwargs)
def mutate(
self,
operation: Callable[..., _T],
/,
*args: Any,
**kwargs: Any,
) -> _T:
"""Serialize a path-aware settings read-modify-write operation."""
return self.config.run_serialized(
lambda config_path: operation(
*args,
config_path=config_path,
**kwargs,
)
)
+25 -188
View File
@@ -17,10 +17,9 @@ import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote, unquote, urlsplit, urlunsplit
from urllib.parse import unquote
from loguru import logger
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
@@ -119,64 +118,7 @@ from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_WEBUI_MUTATION_PATHS = {
"automation.enable": "/api/webui/automations/enable",
"automation.disable": "/api/webui/automations/disable",
"automation.delete": "/api/webui/automations/delete",
"automation.run": "/api/webui/automations/run",
"automation.update": "/api/webui/automations/update",
"skill.install": "/api/webui/skills/install",
"skill.update": "/api/webui/skills/update",
"skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update",
"settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update",
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
"settings.model_call_order.update": "/api/settings/model-call-order/update",
"settings.provider.update": "/api/settings/provider/update",
"settings.provider.create": "/api/settings/provider/create",
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
"settings.web_search.update": "/api/settings/web-search/update",
"settings.api_service.start": "/api/settings/api-service/start",
"settings.api_service.stop": "/api/settings/api-service/stop",
"settings.image_generation.update": "/api/settings/image-generation/update",
"settings.transcription.update": "/api/settings/transcription/update",
"settings.network_safety.update": "/api/settings/network-safety/update",
"settings.cli_app.install": "/api/settings/cli-apps/install",
"settings.cli_app.update": "/api/settings/cli-apps/update",
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
"settings.cli_app.test": "/api/settings/cli-apps/test",
"settings.feature.enable": "/api/settings/nanobot-features/enable",
"settings.feature.disable": "/api/settings/nanobot-features/disable",
"settings.channel.validate": "/api/settings/channels/validate",
"settings.channel.configure": "/api/settings/channels/configure",
"settings.pairing.approve": "/api/settings/pairing/approve",
"settings.pairing.deny": "/api/settings/pairing/deny",
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
"settings.mcp.import": "/api/settings/mcp-presets/import",
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
"settings.mcp.oauth_start": "/api/settings/mcp-oauth/start",
"settings.mcp.oauth_complete": "/api/settings/mcp-oauth/complete",
"settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel",
}
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
"settings.channel.connect.start": "start",
"settings.channel.connect.poll": "poll",
"settings.channel.connect.cancel": "cancel",
}
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
@@ -208,7 +150,6 @@ if TYPE_CHECKING:
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.settings_services import WebUISettingsServices
def _decode_api_key(raw_key: str) -> str | None:
key = unquote(raw_key)
@@ -218,33 +159,6 @@ def _decode_api_key(raw_key: str) -> str | None:
return key
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
if not isinstance(payload, dict):
return None
return cast(dict[str, Any], payload)
def _request_query(request: WsRequest) -> dict[str, list[str]]:
payload = _mutation_payload(request)
if payload is None:
return _parse_query(request.path)
query: dict[str, list[str]] = {}
for key, value in payload.items():
if not key:
continue
if isinstance(value, bool):
text = "true" if value else "false"
elif value is None:
text = ""
elif isinstance(value, (dict, list)):
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
else:
text = str(value)
query[key] = [text]
return query
def _default_model_name_from_config() -> str | None:
try:
from nanobot.config.loader import load_config
@@ -297,7 +211,6 @@ class GatewayHTTPHandler:
media: WebUIMediaGateway,
ingress: WebUIIngressPolicy,
workspaces: WebUIWorkspaceController,
settings: WebUISettingsServices,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
@@ -318,7 +231,6 @@ class GatewayHTTPHandler:
self.media = media
self.ingress = ingress
self.workspaces = workspaces
self.settings = settings
self.skills_workspace_path = skills_workspace_path
self.disabled_skills: set[str] = (
disabled_skills if disabled_skills is not None else set()
@@ -337,7 +249,6 @@ class GatewayHTTPHandler:
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
self.settings_routes = WebUISettingsRouter(
settings=settings,
bus=bus,
logger=self._log,
check_api_token=self.check_api_token,
@@ -348,7 +259,6 @@ class GatewayHTTPHandler:
runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
)
def workspace_controls_available(self, connection: Any) -> bool:
@@ -375,86 +285,11 @@ class GatewayHTTPHandler:
)
try:
if self._is_webui_mutation_path(got):
return _http_error(
405,
"WebUI mutations require an authenticated WebSocket",
)
response = await self._dispatch_resolved(connection, request, got)
return response
finally:
self._log_slow_http(got, response, started)
async def dispatch_webui_mutation(
self,
connection: Any,
action: str,
payload: dict[str, Any],
) -> Response:
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
path = self._webui_mutation_path(action, payload)
if isinstance(path, Response):
return path
source_request = getattr(connection, "request", None)
source_headers = getattr(source_request, "headers", None)
if source_headers is None:
headers = Headers()
else:
try:
headers = Headers(source_headers.raw_items())
except (AttributeError, TypeError):
try:
headers = Headers(source_headers)
except TypeError:
headers = Headers()
request = WsRequest(path, headers)
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
response = await self._dispatch_resolved(connection, request, path)
if isinstance(response, Response):
return response
return _http_error(404, "WebUI mutation action not found")
def _is_webui_mutation_path(self, path: str) -> bool:
if self.settings_routes.is_mutation_path(path):
return True
if re.match(r"^/api/sessions/[^/]+/delete$", path):
return True
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
return True
return path in {
"/api/webui/skills/install",
"/api/webui/skills/update",
"/api/webui/skills/delete",
"/api/webui/sidebar-state/update",
}
@staticmethod
def _webui_mutation_path(
action: str,
payload: dict[str, Any],
) -> str | Response:
path = _WEBUI_MUTATION_PATHS.get(action)
if path is not None:
return path
if action == "session.delete":
key = payload.get("key")
if not isinstance(key, str) or not key.strip():
return _http_error(400, "missing session key")
return f"/api/sessions/{quote(key, safe='')}/delete"
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
if connect_action is not None:
channel = payload.get("channel")
if not isinstance(channel, str) or re.fullmatch(
r"[A-Za-z0-9_-]{1,64}",
channel,
) is None:
return _http_error(400, "invalid channel name")
return f"/api/settings/channels/{channel}/connect/{connect_action}"
return _http_error(404, "unknown WebUI mutation action")
async def _dispatch_resolved(
self,
connection: Any,
@@ -622,14 +457,6 @@ class GatewayHTTPHandler:
expected_path = _normalize_config_path(self.config.path)
return f"{scheme}://{host}{expected_path}"
def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str:
"""Derive the browser callback from the same public origin as WebSocket bootstrap."""
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
public_ws_url = urlsplit(self._bootstrap_ws_url(request))
scheme = "https" if public_ws_url.scheme == "wss" else "http"
return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", ""))
# -- Session routes -----------------------------------------------------
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
@@ -819,7 +646,7 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
query = _request_query(request)
query = _parse_query(request.path)
delete_automations = (_query_first(query, "delete_automations") or "").lower()
automation_jobs = session_automation_jobs(
self.cron_service,
@@ -915,7 +742,7 @@ class GatewayHTTPHandler:
if self.cron_service is None and self.local_trigger_store is None:
return _http_error(503, "automation service unavailable")
query = _request_query(request)
query = _parse_query(request.path)
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id:
return _http_error(400, "missing automation id")
@@ -1147,7 +974,7 @@ class GatewayHTTPHandler:
if self._skill_install_lock.locked():
return _http_error(409, "another skill installation is already in progress")
query = _request_query(request)
query = _parse_query(request.path)
provider = _query_first(query, "provider") or "skills_sh"
source = _query_first(query, "source") or ""
skill_id = _query_first(query, "skill") or ""
@@ -1188,7 +1015,7 @@ class GatewayHTTPHandler:
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
query = _request_query(request)
query = _parse_query(request.path)
name = _query_first(query, "name") or ""
raw_enabled = (_query_first(query, "enabled") or "").lower()
if raw_enabled not in {"true", "false"}:
@@ -1220,7 +1047,7 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized")
if not _is_local_browser_request(connection, request.headers):
return _http_error(403, "remote skill deletion is disabled")
name = _query_first(_request_query(request), "name") or ""
name = _query_first(_parse_query(request.path), "name") or ""
try:
action = delete_webui_skill(
self.skills_workspace_path,
@@ -1267,14 +1094,18 @@ class GatewayHTTPHandler:
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
payload = _mutation_payload(request)
state_value = payload.get("state") if payload is not None else None
if state_value is None:
query = _parse_query(request.path)
raw_state = _query_first(query, "state")
if raw_state is None:
return _http_error(400, "missing state")
if not isinstance(state_value, dict):
try:
decoded = json.loads(raw_state)
except json.JSONDecodeError:
return _http_error(400, "state must be JSON")
if not isinstance(decoded, dict):
return _http_error(400, "state must be an object")
try:
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
except ValueError as e:
return _http_error(400, str(e))
except OSError:
@@ -1343,10 +1174,16 @@ class GatewayHTTPHandler:
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
payload = _mutation_payload(request)
if payload is None or "values" not in payload:
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
if not raw:
return {}
values = payload.get("values")
try:
values = json.loads(raw)
except Exception:
try:
values = json.loads(unquote(raw))
except Exception:
return None
return cast(dict[str, Any], values) if isinstance(values, dict) else None
-1
View File
@@ -37,7 +37,6 @@ dependencies = [
"readability-lxml>=0.8.4,<1.0.0",
"lxml-html-clean>=0.4.0,<1.0.0",
"rich>=14.0.0,<15.0.0",
"qrcode[pil]>=8.0",
"croniter>=6.0.0,<7.0.0",
"prompt-toolkit>=3.0.50,<4.0.0",
"questionary>=2.0.0,<3.0.0",
-417
View File
@@ -1,417 +0,0 @@
import json
import shutil
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier
from typing import Any, cast
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.plugins import (
AGENT_PLUGIN_MCP_SCHEMA,
AGENT_PLUGIN_SCHEMA,
agent_plugin_mcp_servers,
discover_agent_plugin_states,
enabled_agent_plugin_skills,
set_agent_plugin_enabled,
)
from nanobot.agent.skills import SkillsLoader
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
skill = root / "skills" / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
encoding="utf-8",
)
return skill
def _manifest(name: str, **fields: object) -> dict[str, object]:
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
def _write_plugin(
workspace: Path,
directory: str,
*,
name: str | None = None,
manifest: dict[str, object] | None = None,
) -> Path:
root = workspace / "plugins" / directory
root.mkdir(parents=True)
payload = manifest or _manifest(name or directory)
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
return root
def _write_mcp(root: Path, servers: dict[str, object], **fields: object) -> None:
payload = {"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers, **fields}
(root / "mcp.json").write_text(json.dumps(payload), encoding="utf-8")
def _write_setup_plugin(workspace: Path) -> tuple[Path, Path]:
plugin = _write_plugin(
workspace,
"desktop",
manifest=_manifest(
"desktop",
version="1.2.3",
extensions={"dev.nanobot": {"installCommand": ["./bin/install"]}},
),
)
executable = plugin / "bin" / "install"
executable.parent.mkdir()
executable.write_text("setup", encoding="utf-8")
return plugin, executable
def _loaded_plugin_skills(workspace: Path) -> list[str]:
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == [
{
"name": "release-notes",
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
"source": "plugin",
}
]
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
assert "### Agent Plugin skills" in loader.build_skills_summary()
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == []
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes")
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
shutil.rmtree(plugin)
assert loader.list_skills() == []
assert loader.build_skills_summary() == ""
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "direct")
nested = plugin / "skills" / "group" / "nested"
nested.mkdir(parents=True)
(nested / "SKILL.md").write_text(
"---\nname: nested\ndescription: Nested skill.\n---\n",
encoding="utf-8",
)
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
assert _loaded_plugin_skills(tmp_path) == ["direct"]
@pytest.mark.parametrize(
"manifest",
[
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
],
)
def test_invalid_agent_plugin_manifest_is_skipped(
tmp_path: Path,
manifest: dict[str, object],
) -> None:
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
_write_skill(plugin, "example")
assert discover_agent_plugin_states(tmp_path) == []
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest=_manifest(
"demo",
futureField=True,
author=None,
keywords=None,
extensions="invalid but non-fatal",
),
)
_write_skill(plugin, "example")
set_agent_plugin_enabled(tmp_path, "demo", True)
assert _loaded_plugin_skills(tmp_path) == ["example"]
def test_agent_plugin_discovers_contained_raster_logo(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest=_manifest(
"demo",
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
),
)
assets = plugin / "assets"
assets.mkdir()
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
assert discover_agent_plugin_states(tmp_path)[0].plugin.logo == assets / "icon.png"
def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None:
outside = tmp_path / "outside.png"
outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
plugin = _write_plugin(
tmp_path,
"demo",
manifest=_manifest(
"demo",
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
),
)
assets = plugin / "assets"
assets.mkdir()
try:
(assets / "icon.png").symlink_to(outside)
except OSError as exc:
pytest.skip(f"file symlink unavailable: {exc}")
assert discover_agent_plugin_states(tmp_path)[0].plugin.logo is None
@pytest.mark.parametrize(
("skill_name", "frontmatter"),
[
("wrong-directory", "name: another\ndescription: Mismatch."),
("missing-description", "name: missing-description"),
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
],
)
def test_invalid_agent_skill_is_skipped(
tmp_path: Path,
skill_name: str,
frontmatter: str,
) -> None:
plugin = _write_plugin(tmp_path, "demo")
skill = plugin / "skills" / skill_name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
set_agent_plugin_enabled(tmp_path, "demo", True)
assert _loaded_plugin_skills(tmp_path) == []
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
_write_skill(plugin, "shared", description="Plugin version.")
set_agent_plugin_enabled(tmp_path, "demo", True)
workspace_skill = tmp_path / "skills" / "shared"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Workspace version.\n---\n",
encoding="utf-8",
)
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
assert "Workspace version" in (loader.load_skill("shared") or "")
def test_disabled_plugin_skill_cannot_shadow_or_inject_builtin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
skill = _write_skill(plugin, "shared", description="Plugin version.")
(skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Plugin version.\nalways: true\n---\n\nPlugin body.\n",
encoding="utf-8",
)
builtin = tmp_path / "builtin"
builtin_skill = builtin / "shared"
builtin_skill.mkdir(parents=True)
(builtin_skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Built-in version.\n---\n\nBuilt-in body.\n",
encoding="utf-8",
)
loader = SkillsLoader(tmp_path, builtin_skills_dir=builtin)
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
assert "Built-in version" in (loader.load_skill("shared") or "")
assert loader.get_always_skills() == []
set_agent_plugin_enabled(tmp_path, "demo", True)
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
assert "Plugin body" in (loader.load_skill("shared") or "")
assert loader.get_always_skills() == ["shared"]
set_agent_plugin_enabled(tmp_path, "demo", False)
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
assert "Built-in version" in (loader.load_skill("shared") or "")
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
outside = tmp_path / "outside"
_write_skill(outside, "escaped")
skills_root = plugin / "skills"
skills_root.mkdir()
try:
(skills_root / "escaped").symlink_to(
outside / "skills" / "escaped",
target_is_directory=True,
)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
set_agent_plugin_enabled(tmp_path, "demo", True)
assert _loaded_plugin_skills(tmp_path) == []
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "desktop")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
_write_mcp(
plugin,
{
"desktop": {
"type": "stdio",
"command": "./bin/server",
"args": ["--data", "${PLUGIN_DATA}/state"],
"cwd": "${PLUGIN_ROOT}",
}
},
futureField=True,
)
assert agent_plugin_mcp_servers(tmp_path) == {}
set_agent_plugin_enabled(tmp_path, "desktop", True)
servers = agent_plugin_mcp_servers(tmp_path)
server = servers["desktop"]
assert server.command == str(executable)
assert server.cwd == str(plugin)
assert server.env["PLUGIN_ROOT"] == str(plugin)
assert server.args[0] == "--data"
assert server.args[1].endswith("/state")
set_agent_plugin_enabled(tmp_path, "desktop", False)
assert agent_plugin_mcp_servers(tmp_path) == {}
def test_plugin_setup_command_runs_once_per_version(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit")
plugin, executable = _write_setup_plugin(tmp_path)
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
calls.append((command, cast(dict[str, str], kwargs["env"])))
return subprocess.CompletedProcess(command, 0, "ok", "")
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
set_agent_plugin_enabled(tmp_path, "desktop", True)
set_agent_plugin_enabled(tmp_path, "desktop", False)
set_agent_plugin_enabled(tmp_path, "desktop", True)
assert len(calls) == 1
assert calls[0][0] == (str(executable),)
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
assert discover_agent_plugin_states(tmp_path)[0].setup_required is False
def test_concurrent_plugin_enable_runs_setup_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_, executable = _write_setup_plugin(tmp_path)
calls: list[tuple[str, ...]] = []
def run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
calls.append(command)
time.sleep(0.1)
return subprocess.CompletedProcess(command, 0, "ok", "")
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
ready = Barrier(2)
def enable() -> None:
ready.wait()
set_agent_plugin_enabled(tmp_path, "desktop", True)
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [executor.submit(enable) for _ in range(2)]
for future in futures:
future.result()
assert calls == [(str(executable),)]
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "network")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
_write_mcp(
plugin,
{
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
"local": {"type": "stdio", "command": "./bin/server"},
"escape": {"type": "stdio", "command": "../outside"},
},
)
set_agent_plugin_enabled(tmp_path, "network", True)
assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"]
def test_plugin_state_symlink_cannot_escape_config_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = tmp_path / "config"
outside = tmp_path / "outside"
config.mkdir()
outside.mkdir()
try:
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: config / "config.json",
)
_write_plugin(tmp_path, "desktop")
with pytest.raises(RuntimeError, match="escapes its parent"):
set_agent_plugin_enabled(tmp_path, "desktop", True)
-46
View File
@@ -406,52 +406,6 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
await loop.close_mcp()
@pytest.mark.asyncio
async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
):
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config()
notion = MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.notion.test/mcp",
)
linear = MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.linear.test/mcp",
)
config.tools.mcp_servers.update({"notion": notion, "linear": linear})
save_config(config)
attempted: list[str] = []
async def _fake_connect(servers, _registry):
attempted.extend(servers)
stack = AsyncExitStack()
await stack.__aenter__()
return {"linear": stack}
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
monkeypatch.setattr(
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
lambda name, _url: name == "linear",
)
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
result = await mcp_runtime.reload_servers(loop, loop.tools)
assert attempted == ["linear"]
assert result["ok"] is True
assert result["failed"] == []
assert result["retried"] == []
assert result["connected"] == ["linear"]
await loop.close_mcp()
@pytest.mark.asyncio
async def test_mcp_tool_reconnects_after_session_terminated(
tmp_path,
+8 -16
View File
@@ -5,7 +5,6 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ModelPresetConfig
@@ -35,13 +34,6 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
)
def _my_tool(loop: AgentLoop) -> MyTool:
return MyTool(
runtime_control=AgentRuntimeControl(loop),
modify_allowed=True,
)
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
loop = _make_loop(tmp_path)
assert loop.model_preset is None
@@ -248,7 +240,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
output = tool._inspect_all()
assert "model_preset: 'fast'" in output
@@ -258,7 +250,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
result = tool._modify("model_preset", "fast")
assert "Error" not in result
assert loop.model_preset == "fast"
@@ -271,7 +263,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
result = tool._modify("model_preset", "default")
@@ -288,7 +280,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
result = tool._modify("model_preset", "missing")
@@ -303,7 +295,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
with request_context(RequestContext(
channel="cli",
@@ -326,7 +318,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
loop.set_session_model_preset = MagicMock(
side_effect=ValueError("No API key configured for provider 'openai'.")
)
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
with request_context(RequestContext(
channel="cli",
@@ -351,7 +343,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
value: object,
) -> None:
loop = _make_loop(tmp_path)
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
session = loop.sessions.get_or_create("cli:one")
with request_context(RequestContext(
@@ -374,7 +366,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = _my_tool(loop)
tool = MyTool(runtime_state=loop, modify_allowed=True)
result = tool._modify("model", "anthropic/claude-opus-4-5")
assert "Error" not in result
assert loop.model_preset is None
-1
View File
@@ -266,7 +266,6 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
assert len(entries) == 1
assert entries[0]["name"] == "beta"
assert entries[0]["path"] == str(beta_path)
assert loader.load_skill("alpha") is None
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
-225
View File
@@ -1,225 +0,0 @@
"""Contract and security regressions for the MyTool runtime boundary."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.runtime_control import (
RUNTIME_COMMAND_KEYS,
RUNTIME_SNAPSHOT_KEYS,
AgentRuntimeControl,
RuntimeControl,
)
from nanobot.agent.tools.self import MyTool, MyToolConfig
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
tools_config=tools_config,
)
def _my_tool(loop: AgentLoop) -> MyTool:
tool = loop.tools.get("my")
assert isinstance(tool, MyTool)
return tool
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
tool = _my_tool(loop)
assert isinstance(tool._runtime_control, RuntimeControl)
assert isinstance(tool._runtime_control, AgentRuntimeControl)
assert tool._runtime_control is not loop
assert not hasattr(tool, "_runtime_state")
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
loop.web_config.search.api_key = "search-secret"
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
loop.unlisted_secret = "loop-secret"
snapshot = _my_tool(loop)._runtime_control.snapshot()
values = snapshot.as_mapping()
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
assert RUNTIME_COMMAND_KEYS == frozenset({
"model",
"model_preset",
"max_iterations",
"context_window_tokens",
"provider_retry_mode",
"max_tool_result_chars",
"workspace",
})
assert "provider" not in values
assert "sessions" not in values
assert "restrict_to_workspace" not in values
assert "unlisted_secret" not in values
rendered = repr(values)
assert "search-secret" not in rendered
assert "proxy-secret" not in rendered
assert "loop-secret" not in rendered
assert snapshot.web_config["proxy"] == "<configured>"
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
control = _my_tool(loop)._runtime_control
snapshot = control.snapshot()
search = snapshot.web_config["search"]
assert isinstance(search, dict)
search["provider"] = "mutated"
snapshot.exec_config["allow_patterns"] = ["mutated"]
snapshot.tool_names.append("mutated")
refreshed = control.snapshot()
refreshed_search = refreshed.web_config["search"]
assert isinstance(refreshed_search, dict)
assert refreshed_search["provider"] == loop.web_config.search.provider
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
assert "mutated" not in refreshed.tool_names
@pytest.mark.asyncio
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
loop.unlisted_control_plane = "internal-secret"
original_workspace_root = loop.workspace_scopes.default_workspace
tool = _my_tool(loop)
inspected = await tool.execute(action="check", key="unlisted_control_plane")
modified = await tool.execute(
action="set",
key="unlisted_control_plane",
value="scratch-value",
)
nested = await tool.execute(
action="set",
key="workspace_scopes.default_workspace",
value="elsewhere",
)
assert "internal-secret" not in inspected
assert "not found" in inspected
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
assert loop.unlisted_control_plane == "internal-secret"
assert "Error" in nested
assert loop.workspace_scopes.default_workspace == original_workspace_root
@pytest.mark.asyncio
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
tmp_path: Path,
) -> None:
loop = _make_loop(tmp_path)
tool = _my_tool(loop)
assert ToolsConfig().my.allow_set is False
assert tool.parameters == {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["check", "set"],
"description": "Action to perform",
},
"key": {
"type": "string",
"description": (
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
"'request.sender_id' for current routing metadata. Use 'model_preset' "
"to switch named model presets. For check without key, shows all "
"config values."
),
},
"value": {
"description": (
"New value (for set). Type must match target (int for "
"max_iterations/context_window_tokens, str for model/model_preset)."
),
},
},
"required": ["action"],
}
assert "READ-ONLY MODE" in tool.description
result = await tool.execute(action="set", key="max_iterations", value=80)
assert result == "Error: set is disabled (tools.my.allow_set is false)"
assert loop.max_iterations != 80
@pytest.mark.asyncio
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
tool = _my_tool(loop)
max_iterations = await tool.execute(
action="set",
key="max_iterations",
value=80,
)
retry_mode = await tool.execute(
action="set",
key="provider_retry_mode",
value="persistent",
)
scratchpad = await tool.execute(
action="set",
key="preference",
value={"concise": True},
)
assert max_iterations == "Set max_iterations = 80 (was 200)"
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
assert loop.max_iterations == 80
assert loop.subagents.max_iterations == 80
assert loop.provider_retry_mode == "persistent"
assert tool._runtime_control.snapshot().scratchpad == {
"preference": {"concise": True},
}
@pytest.mark.asyncio
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
changed = await loop.tools.execute(
"my",
{"action": "set", "key": "max_iterations", "value": 80},
)
assert checked == "model: 'test-model'"
assert changed == "Set max_iterations = 80 (was 200)"
assert loop.max_iterations == 80
@pytest.mark.asyncio
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
loop = _make_loop(tmp_path, allow_set=True)
tool = _my_tool(loop)
result = await tool.execute(action="set", key="workspace", value="elsewhere")
assert "Set workspace" in result
assert tool._runtime_control.snapshot().workspace == "elsewhere"
assert loop.workspace == tmp_path
assert loop.workspace_scopes.default_workspace == tmp_path
+69 -74
View File
@@ -8,12 +8,10 @@ from types import MappingProxyType
from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
from nanobot.config.schema import ModelPresetConfig
# ---------------------------------------------------------------------------
@@ -29,16 +27,13 @@ def _make_mock_loop(**overrides):
loop.workspace = Path("/tmp/workspace")
loop.restrict_to_workspace = False
loop._start_time = 1000.0
loop.exec_config = ExecToolConfig()
loop.exec_config = MagicMock()
loop.channels_config = MagicMock()
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
loop.last_usage = loop._last_usage
loop._runtime_vars = {}
loop._current_iteration = 0
loop.current_iteration = loop._current_iteration
loop.provider_retry_mode = "standard"
loop.max_tool_result_chars = 16000
loop.model_preset = None
loop.model_presets = {}
loop._concurrency_gate = None
loop._unified_session = False
loop._extra_hooks = []
@@ -50,7 +45,9 @@ def _make_mock_loop(**overrides):
)
# web_config mock — needed for check tests
loop.web_config = WebToolsConfig()
loop.web_config = MagicMock()
loop.web_config.enable = True
loop.web_config.search = MagicMock()
loop.web_config.search.api_key = "sk-secret-key-12345"
# Tools registry mock
@@ -58,13 +55,10 @@ def _make_mock_loop(**overrides):
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
loop.tools.get.return_value = None
loop.tool_names = loop.tools.tool_names
# SubagentManager mock
loop.subagents = MagicMock()
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
loop.subagents._task_statuses = {}
loop.subagents.runtime_statuses.side_effect = lambda: loop.subagents._task_statuses
loop.subagents.get_running_count = MagicMock(return_value=1)
for k, v in overrides.items():
@@ -73,10 +67,10 @@ def _make_mock_loop(**overrides):
return loop
def _make_tool(loop=None):
if loop is None:
loop = _make_mock_loop()
return MyTool(runtime_control=AgentRuntimeControl(loop))
def _make_tool(runtime_state=None):
if runtime_state is None:
runtime_state = _make_mock_loop()
return MyTool(runtime_state=runtime_state)
# ---------------------------------------------------------------------------
@@ -93,10 +87,10 @@ class TestInspectSummary:
assert "context_window_tokens: 65536" in result
@pytest.mark.asyncio
async def test_inspect_includes_scratchpad(self):
async def test_inspect_includes_runtime_vars(self):
loop = _make_mock_loop()
tool = _make_tool(loop=loop)
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
loop._runtime_vars = {"task": "review"}
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check")
assert "task" in result
@@ -156,7 +150,9 @@ class TestInspectPathNavigation:
@pytest.mark.asyncio
async def test_inspect_config_subfield(self):
loop = _make_mock_loop()
tool = _make_tool(loop=loop)
loop.web_config = MagicMock()
loop.web_config.enable = True
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="web_config.enable")
assert "True" in result
@@ -164,7 +160,7 @@ class TestInspectPathNavigation:
async def test_inspect_dict_key_via_dotpath(self):
loop = _make_mock_loop()
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
assert "100" in result
@@ -183,16 +179,20 @@ class TestInspectPathNavigation:
@pytest.mark.asyncio
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
class SearchConfig(BaseModel):
provider: str = "tavily"
api_key: str = "sk-test-secret"
base_url: str = ""
max_results: int = 5
loop = _make_mock_loop()
loop.web_config.search = WebSearchConfig(
provider="tavily",
api_key="sk-test-secret",
)
loop.web_config = MagicMock()
loop.web_config.search = SearchConfig()
tool = _make_tool(loop)
result = await tool.execute(action="check", key="web_config.search")
assert "tavily" in result
assert "provider='tavily'" in result
assert "sk-test-secret" not in result
assert "api_key" not in result.lower()
@@ -209,14 +209,14 @@ class TestModifyRestricted:
tool = _make_tool()
result = await tool.execute(action="set", key="max_iterations", value=80)
assert "Set max_iterations = 80" in result
assert tool._runtime_control.snapshot().max_iterations == 80
assert tool._runtime_state.max_iterations == 80
@pytest.mark.asyncio
async def test_modify_restricted_out_of_range(self):
tool = _make_tool()
result = await tool.execute(action="set", key="max_iterations", value=0)
assert "Error" in result
assert tool._runtime_control.snapshot().max_iterations == 40
assert tool._runtime_state.max_iterations == 40
@pytest.mark.asyncio
async def test_modify_restricted_max_exceeded(self):
@@ -241,12 +241,12 @@ class TestModifyRestricted:
tool = _make_tool()
result = await tool.execute(action="set", key="max_iterations", value="80")
assert "Set max_iterations" in result
assert tool._runtime_control.snapshot().max_iterations == 80
assert tool._runtime_state.max_iterations == 80
@pytest.mark.asyncio
async def test_modify_context_window_valid(self):
loop = _make_mock_loop()
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
assert "Set context_window_tokens" in result
assert loop.context_window_tokens == 131072
@@ -324,15 +324,15 @@ class TestModifyFree:
tool = _make_tool()
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
assert "Set provider_retry_mode" in result
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
assert tool._runtime_state.provider_retry_mode == "persistent"
@pytest.mark.asyncio
async def test_modify_new_key_stores_in_scratchpad(self):
"""Modifying an unknown key should store it in the scratchpad."""
async def test_modify_new_key_stores_in_runtime_vars(self):
"""Modifying a non-existing attribute should store in _runtime_vars."""
tool = _make_tool()
result = await tool.execute(action="set", key="my_custom_var", value="hello")
assert "my_custom_var" in result
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
@pytest.mark.asyncio
async def test_modify_rejects_callable(self):
@@ -351,14 +351,14 @@ class TestModifyFree:
tool = _make_tool()
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
assert result == "Set scratchpad.items = [1, 2, 3]"
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
@pytest.mark.asyncio
async def test_modify_allows_dict(self):
tool = _make_tool()
result = await tool.execute(action="set", key="data", value={"a": 1})
assert result == "Set scratchpad.data = {'a': 1}"
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
@pytest.mark.asyncio
async def test_modify_whitespace_key_rejected(self):
@@ -396,7 +396,7 @@ class TestModifyFree:
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
assert "Error" in result
assert "str" in result
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
assert tool._runtime_state.provider_retry_mode == "standard"
@pytest.mark.asyncio
async def test_modify_existing_int_attr_wrong_type_rejected(self):
@@ -404,7 +404,7 @@ class TestModifyFree:
tool = _make_tool()
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
assert "Error" in result
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
assert tool._runtime_state.max_tool_result_chars == 16000
# ---------------------------------------------------------------------------
@@ -486,12 +486,11 @@ class TestModifyOpen:
assert "protected" in result
@pytest.mark.asyncio
async def test_modify_workspace_preserves_display_compatibility(self):
"""The compatibility value is isolated from filesystem security boundaries."""
async def test_modify_workspace_allowed(self):
"""workspace was READONLY in v1, now freely modifiable."""
tool = _make_tool()
result = await tool.execute(action="set", key="workspace", value="/new/path")
assert "Set workspace" in result
assert tool._runtime_control.snapshot().workspace == "/new/path"
@pytest.mark.asyncio
async def test_modify_mcp_servers_blocked(self):
@@ -585,28 +584,28 @@ class TestUnknownAction:
# ---------------------------------------------------------------------------
# scratchpad limits
# runtime_vars limits (from code review)
# ---------------------------------------------------------------------------
class TestScratchpadLimits:
class TestRuntimeVarsLimits:
@pytest.mark.asyncio
async def test_scratchpad_rejects_at_max_keys(self):
tool = _make_tool()
for i in range(64):
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
async def test_runtime_vars_rejects_at_max_keys(self):
loop = _make_mock_loop()
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="set", key="overflow", value="data")
assert "full" in result
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
assert "overflow" not in loop._runtime_vars
@pytest.mark.asyncio
async def test_scratchpad_allows_update_existing_key_at_max(self):
tool = _make_tool()
for i in range(64):
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
async def test_runtime_vars_allows_update_existing_key_at_max(self):
loop = _make_mock_loop()
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="set", key="key_0", value="updated")
assert "Error" not in result
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
assert loop._runtime_vars["key_0"] == "updated"
# ---------------------------------------------------------------------------
@@ -845,7 +844,7 @@ class TestInspectTaskStatuses:
usage={"prompt_tokens": 500, "completion_tokens": 100},
),
}
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="subagents._task_statuses")
assert "abc12345" in result
assert "read logs" in result
@@ -866,7 +865,7 @@ class TestInspectTaskStatuses:
stop_reason="completed",
)
loop.subagents._task_statuses = {"xyz": status}
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
assert "search code" in result
assert "completed" in result
@@ -880,10 +879,7 @@ class TestReadOnlyMode:
def _make_readonly_tool(self):
loop = _make_mock_loop()
return MyTool(
runtime_control=AgentRuntimeControl(loop),
modify_allowed=False,
)
return MyTool(runtime_state=loop, modify_allowed=False)
@pytest.mark.asyncio
async def test_inspect_allowed_in_readonly(self):
@@ -908,13 +904,13 @@ class TestReadOnlyMode:
# ---------------------------------------------------------------------------
# scratchpad inspection
# runtime vars check fallback (Fix #1: cross-turn memory)
# ---------------------------------------------------------------------------
class TestScratchpadInspection:
class TestRuntimeVarsInspectFallback:
@pytest.mark.asyncio
async def test_inspect_scratchpad_value_after_modify(self):
async def test_inspect_runtime_var_after_modify(self):
"""Design doc scenario: set then check should return the value."""
tool = _make_tool()
await tool.execute(action="set", key="user_prefers_concise", value=True)
@@ -922,14 +918,14 @@ class TestScratchpadInspection:
assert "True" in result
@pytest.mark.asyncio
async def test_inspect_scratchpad_string(self):
async def test_inspect_runtime_var_string(self):
tool = _make_tool()
await tool.execute(action="set", key="current_project", value="nanobot")
result = await tool.execute(action="check", key="current_project")
assert "nanobot" in result
@pytest.mark.asyncio
async def test_inspect_scratchpad_dict(self):
async def test_inspect_runtime_var_dict(self):
tool = _make_tool()
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
result = await tool.execute(action="check", key="task_meta")
@@ -962,7 +958,7 @@ class TestSensitiveSubFieldBlocking:
loop = _make_mock_loop()
loop.some_config = MagicMock()
loop.some_config.password = "hunter2"
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="some_config.password")
assert "not accessible" in result
@@ -971,7 +967,7 @@ class TestSensitiveSubFieldBlocking:
loop = _make_mock_loop()
loop.vault = MagicMock()
loop.vault.secret = "classified"
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="vault.secret")
assert "not accessible" in result
@@ -980,7 +976,7 @@ class TestSensitiveSubFieldBlocking:
loop = _make_mock_loop()
loop.auth_data = MagicMock()
loop.auth_data.token = "jwt-payload"
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check", key="auth_data.token")
assert "not accessible" in result
@@ -996,7 +992,7 @@ class TestSensitiveSubFieldBlocking:
async def test_modify_password_blocked(self):
loop = _make_mock_loop()
loop.some_config = MagicMock()
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="set", key="some_config.password", value="evil")
assert "not accessible" in result
@@ -1087,8 +1083,8 @@ class TestSecurityAttributeProtection:
@pytest.mark.asyncio
async def test_modify_model_presets_dotpath_blocked(self):
"""The config-derived model preset catalog is inspectable but not mutable."""
presets = {"fast": ModelPresetConfig(model="fast-model")}
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
presets = {"fast": {"model": "fast-model"}}
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
result = await tool.execute(
action="set",
@@ -1097,14 +1093,14 @@ class TestSecurityAttributeProtection:
)
assert "read-only" in result
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
assert presets == {"fast": {"model": "fast-model"}}
@pytest.mark.asyncio
async def test_inspect_read_only_model_preset_dotpath(self):
presets = MappingProxyType({
"fast": ModelPresetConfig(model="fast-model"),
})
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
result = await tool.execute(action="check", key="model_presets.fast.model")
@@ -1154,8 +1150,7 @@ class TestLastUsageInSummary:
async def test_last_usage_not_shown_when_empty(self):
loop = _make_mock_loop()
loop._last_usage = {}
loop.last_usage = loop._last_usage
tool = _make_tool(loop=loop)
tool = _make_tool(runtime_state=loop)
result = await tool.execute(action="check")
assert "_last_usage" not in result
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
from nanobot.agent.tools.self import MyTool
from nanobot.bus.queue import MessageBus
@pytest.mark.asyncio
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
max_iterations=40,
)
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
loop = MagicMock()
loop.max_iterations = 40
loop._runtime_vars = {}
loop.subagents = MagicMock()
loop.subagents.max_iterations = loop.max_iterations
def _sync_subagent_runtime_limits() -> None:
loop.subagents.max_iterations = loop.max_iterations
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
tool = MyTool(runtime_state=loop)
result = await tool.execute(action="set", key="max_iterations", value=80)
-4
View File
@@ -651,7 +651,6 @@ def test_plugin_setup_contract_drives_save_and_validation(
from nanobot.channels.validation import validate_channel_config
from nanobot.config import loader
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -661,7 +660,6 @@ def test_plugin_setup_contract_drives_save_and_validation(
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
)
router = object.__new__(WebUISettingsRouter)
router.settings = WebUISettingsServices.create(config_path)
saved = router._save_channel_config_values(
"setupplugin",
@@ -740,7 +738,6 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
from nanobot.config import loader
from nanobot.webui.settings_api import WebUISettingsError
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices
config_path = tmp_path / "config.json"
config_path.write_text(
@@ -759,7 +756,6 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
before = config_path.read_text(encoding="utf-8")
monkeypatch.setattr(loader, "_current_config_path", config_path)
router = object.__new__(WebUISettingsRouter)
router.settings = WebUISettingsServices.create(config_path)
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
router._save_channel_config_values(
+6 -63
View File
@@ -9,20 +9,9 @@ from types import SimpleNamespace
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.skills import SkillsLoader
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
agent_plugins,
"get_config_path",
lambda: tmp_path / "config" / "config.json",
)
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
@@ -402,9 +391,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy", encoding="utf-8")
payload = manager.install("gimp")
@@ -414,23 +400,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "cli-app-gimp",
"version": "1.0.0",
"description": "Public duplicate entry",
}
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
assert [
item["name"]
for item in SkillsLoader(manager.workspace).list_skills()
if item["source"] == "plugin"
] == ["cli-app-gimp"]
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output(
@@ -515,7 +487,7 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved)
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
@@ -732,8 +704,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -746,7 +717,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not plugin_dir.exists()
assert not skill_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -874,47 +845,19 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"skill": "skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"skill": "skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_legacy_underscored_skill_remains_visible_and_removable(tmp_path: Path) -> None:
manager = _manager(tmp_path)
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text(
"---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n",
encoding="utf-8",
)
manager._save_installed(
{"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}}
)
app = {
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
"install_cmd": "pip install cli-anything-unimol-tools",
}
assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True
assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == (
"skills/cli-app-unimol_tools/SKILL.md"
)
manager.remove_skill("unimol_tools")
assert not legacy.exists()
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
+2 -21
View File
@@ -38,7 +38,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
@@ -58,23 +58,4 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
lines = runtime_lines_for_request(
"please use @unimol_tools",
{
"cli_apps": [{
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
}],
},
tmp_path,
)
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
@@ -48,6 +48,7 @@ def test_build_responses_body_strips_github_copilot_prefix():
provider_context=ProviderCallContext(context_window_tokens=128_000),
)
assert body["model"] == "gpt-5.4-mini"
assert body["include"] == ["reasoning.encrypted_content"]
assert "context_management" not in body
@@ -11,6 +11,11 @@ from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider,
)
from nanobot.providers.openai_responses.state import build_responses_state
from nanobot.providers.registry import (
ProviderSpec,
ResponsesCapabilities,
find_by_name,
)
@pytest.fixture()
@@ -18,7 +23,7 @@ def provider():
"""A direct-OpenAI provider with Responses API support."""
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})()
p._spec = find_by_name("openai")
p._effective_base = "https://api.openai.com/v1"
p._api_type = "auto"
p._responses_failures = {}
@@ -31,12 +36,7 @@ def test_responses_api_available_by_default(provider):
def test_deepseek_v4_flash_uses_responses_by_model(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._spec = find_by_name("deepseek")
provider._effective_base = "https://api.deepseek.com"
provider.default_model = "deepseek-v4-flash"
@@ -45,17 +45,51 @@ def test_deepseek_v4_flash_uses_responses_by_model(provider):
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._spec = find_by_name("deepseek")
provider._effective_base = "https://api.deepseek.com"
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
def test_responses_behavior_is_declared_by_capabilities(provider):
provider._spec = ProviderSpec(
name="example",
keywords=("example",),
env_key="EXAMPLE_API_KEY",
responses=ResponsesCapabilities(
models=("example-o3",),
reasoning_replay="plaintext",
),
)
provider._effective_base = "https://example.test"
assert provider._should_use_responses_api("example-o3", None) is True
body = provider._build_responses_body(
messages=[
{"role": "user", "content": "question"},
{
"role": "assistant",
"reasoning_content": "think first",
"content": "answer",
},
{"role": "user", "content": "follow-up"},
],
tools=None,
model="example-o3",
max_tokens=100,
temperature=0.1,
reasoning_effort="high",
tool_choice=None,
)
assert {
"type": "reasoning",
"content": [{"type": "output_text", "text": "think first"}],
} in body["input"]
assert "include" not in body
def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {}
@@ -74,6 +108,7 @@ def test_direct_openai_enables_server_compaction(provider):
"type": "compaction",
"compact_threshold": 70_000,
}]
assert body["include"] == ["reasoning.encrypted_content"]
def test_api_type_chat_completions_disables_responses(provider):
@@ -97,7 +132,7 @@ def test_api_type_responses_ignores_circuit_breaker(provider):
def test_api_type_responses_does_not_force_non_openai(provider):
provider._spec = type("Spec", (), {"name": "custom"})()
provider._spec = find_by_name("custom")
provider._api_type = "responses"
assert provider._should_use_responses_api("gpt-4o", None) is False
@@ -192,12 +227,15 @@ def test_legacy_compatibility_markers_still_trigger_fallback():
def _deepseek_provider(provider):
provider._spec = type("Spec", (), {
"name": "deepseek",
"responses_models": ("deepseek-v4-flash",),
"strip_model_prefix": False,
"strip_model_prefixes": (),
})()
provider._spec = ProviderSpec(
name="deepseek",
keywords=("deepseek",),
env_key="DEEPSEEK_API_KEY",
responses=ResponsesCapabilities(
models=("deepseek-v4-flash",),
reasoning_replay="plaintext",
),
)
provider._effective_base = "https://api.deepseek.com"
provider.default_model = "deepseek-v4-flash"
provider._extra_body = {}
-10
View File
@@ -133,16 +133,6 @@ class TestEditFileTool:
assert "Successfully" in result
assert f.read_text() == "hello earth"
@pytest.mark.asyncio
async def test_identical_replacement_returns_clear_error(self, tool, tmp_path):
f = tmp_path / "a.py"
f.write_text("hello world", encoding="utf-8")
result = await tool.execute(path=str(f), old_text="world", new_text="world")
assert result == "Error: new_text must be different from old_text."
assert f.read_text(encoding="utf-8") == "hello world"
@pytest.mark.asyncio
async def test_crlf_normalisation(self, tool, tmp_path):
f = tmp_path / "crlf.py"
-325
View File
@@ -1,325 +0,0 @@
from __future__ import annotations
import json
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from nanobot.agent.tools.mcp_oauth import (
MCPAuthorizationRequiredError,
MCPOAuthHandlers,
MCPOAuthStorage,
create_mcp_oauth_auth,
delete_mcp_oauth_credentials,
mcp_oauth_has_credentials,
)
from nanobot.config.schema import MCPServerConfig
def _use_data_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.agent.tools.mcp_oauth.get_data_dir", lambda: tmp_path)
def test_mcp_server_config_accepts_explicit_oauth() -> None:
config = MCPServerConfig.model_validate({
"type": "streamableHttp",
"url": "https://mcp.example.com/mcp",
"auth": "oauth",
})
assert config.auth == "oauth"
assert config.model_dump(by_alias=True)["auth"] == "oauth"
@pytest.mark.asyncio
async def test_mcp_oauth_storage_isolates_name_and_server_url(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
storage = MCPOAuthStorage("notion-work", "https://mcp.example.com/mcp")
tokens = OAuthToken(access_token="access-secret", refresh_token="refresh-secret")
client_info = OAuthClientInformationFull(
redirect_uris=["https://agent.example/auth/mcp/callback"],
client_id="client-id",
client_secret="client-secret",
)
await storage.prepare_redirect_uri("https://agent.example/auth/mcp/callback")
await storage.set_tokens(tokens)
await storage.set_client_info(client_info)
assert await storage.get_tokens() == tokens
assert await storage.get_client_info() == client_info
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
assert mcp_oauth_has_credentials("notion-work", "https://mcp.example.com/mcp")
assert not mcp_oauth_has_credentials("notion-home", "https://mcp.example.com/mcp")
assert not mcp_oauth_has_credentials("notion-work", "https://other.example.com/mcp")
payload = json.loads((tmp_path / "auth" / "mcp.json").read_text(encoding="utf-8"))
assert "https://mcp.example.com/mcp" not in str(payload)
assert "access-secret" in str(payload)
@pytest.mark.asyncio
async def test_changed_redirect_uri_discards_dynamic_registration_but_keeps_tokens(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
storage = MCPOAuthStorage("linear", "https://mcp.linear.example/mcp")
await storage.prepare_redirect_uri("https://old.example/auth/mcp/callback")
await storage.set_tokens(OAuthToken(access_token="access-secret"))
await storage.set_client_info(OAuthClientInformationFull(
redirect_uris=["https://old.example/auth/mcp/callback"],
client_id="old-client",
))
await storage.prepare_redirect_uri("https://new.example/auth/mcp/callback")
assert await storage.get_tokens() is not None
assert await storage.get_client_info() is None
@pytest.mark.asyncio
async def test_reset_and_delete_credentials_are_scoped_to_one_server(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
first = MCPOAuthStorage("first", "https://mcp.example.com/mcp")
second = MCPOAuthStorage("second", "https://mcp.example.com/mcp")
await first.set_tokens(OAuthToken(access_token="first-token"))
await second.set_tokens(OAuthToken(access_token="second-token"))
await first.prepare_redirect_uri(
"https://agent.example/auth/mcp/callback",
reset=True,
)
assert await first.get_tokens() is None
assert await second.get_tokens() is not None
assert delete_mcp_oauth_credentials("first")
assert not delete_mcp_oauth_credentials("first")
assert await second.get_tokens() is not None
@pytest.mark.asyncio
async def test_deleted_credentials_reject_late_writes_from_stale_oauth_flow(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.linear.example/mcp"
stale = MCPOAuthStorage("linear", server_url)
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
assert delete_mcp_oauth_credentials("linear")
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
assert not mcp_oauth_has_credentials("linear", server_url)
replacement = MCPOAuthStorage("linear", server_url)
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
await stale.set_tokens(OAuthToken(access_token="late-after-replacement"))
assert not mcp_oauth_has_credentials("linear", server_url)
assert await replacement.get_tokens() is None
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
stored = await replacement.get_tokens()
assert stored is not None
assert stored.access_token == "fresh-token"
@pytest.mark.asyncio
async def test_delete_before_oauth_claim_rejects_late_credential_writes(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.linear.example/mcp"
stale = MCPOAuthStorage("linear", server_url)
assert not delete_mcp_oauth_credentials("linear")
with pytest.raises(MCPAuthorizationRequiredError, match="cancelled"):
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
assert not mcp_oauth_has_credentials("linear", server_url)
replacement = MCPOAuthStorage("linear", server_url)
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
assert mcp_oauth_has_credentials("linear", server_url)
@pytest.mark.asyncio
async def test_create_mcp_oauth_auth_uses_browser_handlers_and_persists_redirect(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
async def redirect(_url: str) -> None:
return None
async def callback() -> tuple[str, str | None]:
return "code", "state"
handlers = MCPOAuthHandlers(
redirect_uri="https://agent.example/auth/mcp/callback",
redirect_handler=redirect,
callback_handler=callback,
)
auth = await create_mcp_oauth_auth(
"xmind",
"https://app.xmind.example/api/mcp",
handlers,
)
assert str(auth.context.client_metadata.redirect_uris[0]) == (
"https://agent.example/auth/mcp/callback"
)
assert str(auth.context.client_metadata.client_uri) == "https://github.com/HKUDS/nanobot"
assert str(auth.context.client_metadata.logo_uri) == (
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
"webui/public/brand/nanobot_apple_touch.png"
)
assert auth.context.redirect_handler is redirect
assert auth.context.callback_handler is callback
storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp")
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
@pytest.mark.asyncio
async def test_background_authorization_without_tokens_stops_locally(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
with pytest.raises(MCPAuthorizationRequiredError):
await create_mcp_oauth_auth("notion", "https://mcp.notion.example/mcp")
assert not (tmp_path / "auth" / "mcp.json").exists()
@pytest.mark.asyncio
async def test_background_authorization_request_clears_rejected_token(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.example.com/mcp"
storage = MCPOAuthStorage("notion", server_url)
client_info = OAuthClientInformationFull(
redirect_uris=["https://agent.example/auth/mcp/callback"],
client_id="registered-client",
)
await storage.set_tokens(OAuthToken(access_token="rejected-token"))
await storage.set_client_info(client_info)
auth = await create_mcp_oauth_auth("notion", server_url)
redirect_handler = auth.context.redirect_handler
assert redirect_handler is not None
with pytest.raises(MCPAuthorizationRequiredError):
await redirect_handler("https://accounts.example.com/authorize?state=state")
assert await storage.get_tokens() is None
assert await storage.get_client_info() == client_info
@pytest.mark.asyncio
async def test_official_mcp_sdk_completes_discovery_registration_and_token_exchange(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_data_dir(tmp_path, monkeypatch)
server_url = "https://mcp.example.com/mcp"
authorization_url = ""
requests: list[tuple[str, str]] = []
async def redirect(url: str) -> None:
nonlocal authorization_url
authorization_url = url
async def callback() -> tuple[str, str | None]:
state = parse_qs(urlsplit(authorization_url).query)["state"][0]
return "authorization-code", state
auth = await create_mcp_oauth_auth(
"company-mcp",
server_url,
MCPOAuthHandlers(
redirect_uri="https://agent.example/auth/mcp/callback",
redirect_handler=redirect,
callback_handler=callback,
),
)
async def respond(request: httpx.Request) -> httpx.Response:
requests.append((request.method, str(request.url)))
if str(request.url) == server_url:
if request.headers.get("Authorization") == "Bearer access-token":
return httpx.Response(200, json={"ok": True})
return httpx.Response(
401,
headers={
"WWW-Authenticate": (
'Bearer resource_metadata="https://mcp.example.com/'
'.well-known/oauth-protected-resource"'
)
},
)
if request.url.path == "/.well-known/oauth-protected-resource":
return httpx.Response(200, json={
"resource": server_url,
"authorization_servers": ["https://auth.example.com"],
})
if request.url.path == "/.well-known/oauth-authorization-server":
return httpx.Response(200, json={
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"registration_endpoint": "https://auth.example.com/register",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
})
if request.url.path == "/register":
registration = json.loads(request.content)
assert registration["client_uri"] == "https://github.com/HKUDS/nanobot"
assert registration["logo_uri"].endswith(
"/webui/public/brand/nanobot_apple_touch.png"
)
return httpx.Response(201, json={
"client_id": "nanobot-client",
"redirect_uris": ["https://agent.example/auth/mcp/callback"],
"token_endpoint_auth_method": "none",
})
if request.url.path == "/token":
return httpx.Response(200, json={
"access_token": "access-token",
"refresh_token": "refresh-token",
"token_type": "Bearer",
"expires_in": 3600,
})
return httpx.Response(404)
async with httpx.AsyncClient(
transport=httpx.MockTransport(respond),
auth=auth,
) as client:
response = await client.get(server_url)
assert response.status_code == 200
assert urlsplit(authorization_url)._replace(query="").geturl() == (
"https://auth.example.com/authorize"
)
assert ("POST", "https://auth.example.com/register") in requests
assert ("POST", "https://auth.example.com/token") in requests
stored = await MCPOAuthStorage("company-mcp", server_url).get_tokens()
assert stored is not None
assert stored.access_token == "access-token"
assert stored.refresh_token == "refresh-token"
-55
View File
@@ -5,13 +5,10 @@ import asyncio
import socket
from unittest.mock import MagicMock, patch
import httpx
import pytest
from nanobot.agent.tools import mcp as mcp_mod
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import MCPServerConfig
from nanobot.security.network import configure_ssrf_whitelist
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
@@ -174,58 +171,6 @@ async def test_connect_skips_unreachable_sse():
assert len(registry._tools) == 0
@pytest.mark.asyncio
async def test_connect_isolates_streamable_http_status_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A reachable endpoint returning HTTP 530 must not poison the event loop."""
async def _reachable(_url: str) -> bool:
return True
def _return_http_530(request: httpx.Request) -> httpx.Response:
return httpx.Response(530, text="cloudflare error 1033", request=request)
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(_return_http_530),
)
loop = asyncio.get_running_loop()
previous_exception_handler = loop.get_exception_handler()
unhandled: list[BaseException] = []
def _capture_unhandled(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
if isinstance(context.get("exception"), BaseException):
unhandled.append(context["exception"])
loop.set_exception_handler(_capture_unhandled)
try:
registry = ToolRegistry()
stacks = await asyncio.wait_for(
connect_mcp_servers(
{
"cloudflare": MCPServerConfig(
type="streamableHttp",
url="https://mcp.example.com/mcp",
)
},
registry,
),
timeout=5.0,
)
await asyncio.sleep(0)
assert stacks == {}
assert registry.tool_names == []
assert unhandled == []
assert not any(task.get_name() == "mcp:cloudflare" for task in asyncio.all_tasks())
finally:
loop.set_exception_handler(previous_exception_handler)
@pytest.mark.asyncio
async def test_probe_not_called_for_stdio():
"""stdio transport should not be probed — it spawns a local process."""
+8 -207
View File
@@ -826,23 +826,19 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
) -> None:
messages: list[str] = []
def _error(message: str, *args: object) -> None:
messages.append(message.format(*args))
@asynccontextmanager
async def _broken_stdio_client(_params: object):
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
yield # pragma: no cover
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
sink = mcp_mod.logger.add(
lambda message: messages.append(message.record["message"]), level="ERROR"
)
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
registry = ToolRegistry()
try:
stacks = await connect_mcp_servers(
{"gh": MCPServerConfig(command="github-mcp")}, registry
)
finally:
mcp_mod.logger.remove(sink)
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
assert stacks == {}
assert messages
@@ -851,36 +847,6 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
assert "stderr" in messages[-1]
def test_transient_connection_group_logs_brief_warning_and_debug_trace() -> None:
records: list[dict] = []
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
error = ExceptionGroup("transport failed", [httpx.ConnectError("")])
try:
mcp_mod._log_mcp_connection_failure("notion", error)
finally:
mcp_mod.logger.remove(sink)
warning = next(record for record in records if record["level"].name == "WARNING")
debug = next(record for record in records if record["level"].name == "DEBUG")
assert warning["exception"] is None
assert "transient connection failure" in warning["message"]
assert debug["exception"] is not None
assert not any(record["level"].name == "ERROR" for record in records)
def test_unexpected_connection_failure_keeps_error_trace() -> None:
records: list[dict] = []
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
try:
mcp_mod._log_mcp_connection_failure("notion", RuntimeError("boom"))
finally:
mcp_mod.logger.remove(sink)
error = next(record for record in records if record["level"].name == "ERROR")
assert error["exception"] is not None
assert not any(record["level"].name == "WARNING" for record in records)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config",
@@ -1116,22 +1082,10 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
@pytest.mark.asyncio
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
async def test_connect_mcp_servers_one_failure_does_not_block_others(
monkeypatch: pytest.MonkeyPatch,
failure_mode: str,
) -> None:
bad_session = _make_fake_session([])
async def _cancel_initialize() -> None:
raise asyncio.CancelledError("cancelled by SDK")
if failure_mode == "cancellation":
bad_session.initialize = _cancel_initialize
sessions = {
"bad": bad_session,
"good": _make_fake_session(["demo"]),
}
sessions = {"good": _make_fake_session(["demo"])}
class _SelectiveClientSession:
def __init__(self, read: object, _write: object) -> None:
@@ -1145,7 +1099,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
@asynccontextmanager
async def _selective_stdio_client(params: object):
if params.command == "bad" and failure_mode == "exception":
if params.command == "bad":
raise RuntimeError("boom")
yield params.command, object()
@@ -1155,8 +1109,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{
"bad": MCPServerConfig(command="bad"),
"good": MCPServerConfig(command="good"),
"bad": MCPServerConfig(command="bad"),
},
registry,
)
@@ -1167,36 +1121,6 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
assert set(stacks) == {"good"}
@pytest.mark.asyncio
async def test_connect_mcp_servers_propagates_external_cancellation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
started = asyncio.Event()
closed = asyncio.Event()
@asynccontextmanager
async def _blocking_stdio_client(_params: object):
try:
started.set()
await asyncio.Event().wait()
yield object(), object()
finally:
closed.set()
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _blocking_stdio_client)
task = asyncio.create_task(
connect_mcp_servers({"slow": MCPServerConfig(command="slow")}, ToolRegistry())
)
await asyncio.wait_for(started.wait(), timeout=1.0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.wait_for(closed.wait(), timeout=1.0)
@pytest.mark.asyncio
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
fake_mcp_runtime: dict[str, object | None],
@@ -1244,129 +1168,6 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
assert timeout.pool == 30.0
@pytest.mark.parametrize("transport", ["sse", "streamableHttp"])
@pytest.mark.asyncio
async def test_connect_mcp_servers_attaches_oauth_to_remote_http_client(
transport: str,
fake_mcp_runtime: dict[str, object | None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
oauth_auth = object()
oauth_handlers = object()
captured: dict[str, object] = {}
async def _reachable(_url: str) -> bool:
return True
def _validate(_url: str) -> tuple[bool, str]:
return True, ""
async def _create_auth(name: str, url: str, handlers: object) -> object:
captured.update(name=name, url=url, handlers=handlers)
return oauth_auth
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
oauth_mod.MCPAuthorizationRequiredError = RuntimeError # type: ignore[attr-defined]
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
class FakeAsyncClient:
def __init__(self, *args: object, **kwargs: object) -> None:
captured["client_kwargs"] = kwargs
async def __aenter__(self) -> object:
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
return False
@asynccontextmanager
async def _capturing_sse_client(
_url: str,
httpx_client_factory=None,
auth=None,
):
captured["transport_auth"] = auth
yield object(), object()
@asynccontextmanager
async def _capturing_streamable_http_client(_url: str, http_client=None):
assert http_client is not None
yield object(), object(), object()
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
monkeypatch.setattr(
sys.modules["mcp.client.streamable_http"],
"streamable_http_client",
_capturing_streamable_http_client,
)
url = "https://mcp.example.com/sse" if transport == "sse" else "https://mcp.example.com/mcp"
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{"remote": MCPServerConfig(type=transport, url=url, auth="oauth")},
registry,
oauth_handlers={"remote": oauth_handlers}, # type: ignore[arg-type]
)
for stack in stacks.values():
await stack.aclose()
assert captured["name"] == "remote"
assert captured["url"] == url
assert captured["handlers"] is oauth_handlers
if transport == "sse":
assert captured["transport_auth"] is oauth_auth
else:
client_kwargs = captured["client_kwargs"]
assert isinstance(client_kwargs, dict)
assert client_kwargs["auth"] is oauth_auth
assert client_kwargs["event_hooks"] == {"request": [mcp_mod._validate_mcp_request_url]}
@pytest.mark.asyncio
async def test_connect_mcp_servers_skips_background_oauth_without_credentials(
fake_mcp_runtime: dict[str, object | None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
class AuthorizationRequiredError(RuntimeError):
pass
async def _create_auth(*_args: object) -> object:
raise AuthorizationRequiredError
probe_called = False
async def _probe(_url: str) -> bool:
nonlocal probe_called
probe_called = True
return True
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined]
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
monkeypatch.setattr(mcp_mod, "_probe_http_url", _probe)
stacks = await connect_mcp_servers(
{
"remote": MCPServerConfig(
type="streamableHttp",
url="https://mcp.example.com/mcp",
auth="oauth",
)
},
ToolRegistry(),
)
assert stacks == {}
assert not probe_called
@pytest.mark.asyncio
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
fake_mcp_runtime: dict[str, object | None],
@@ -1,65 +0,0 @@
"""Shared characterization cases for live and persisted WebUI projection."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from nanobot.webui.transcript import replay_transcript_to_ui_messages
_FIXTURE_PATH = (
Path(__file__).parents[2]
/ "webui"
/ "src"
/ "tests"
/ "fixtures"
/ "live-replay-event-projection.json"
)
_SEMANTIC_MESSAGE_FIELDS = (
"role",
"content",
"kind",
"traces",
"toolEvents",
"fileEdits",
"images",
"media",
"cliApps",
"mcpPresets",
"sessionMentions",
"reasoning",
"latencyMs",
"source",
"turnId",
"turnPhase",
"turnSeq",
)
def _normalize_projection(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
segment_aliases: dict[str, str] = {}
normalized: list[dict[str, Any]] = []
for message in messages:
row = {
field: message[field]
for field in _SEMANTIC_MESSAGE_FIELDS
if field in message and message[field] is not None
}
segment_id = message.get("activitySegmentId")
if isinstance(segment_id, str) and segment_id:
row["activitySegmentId"] = segment_aliases.setdefault(
segment_id,
f"segment-{len(segment_aliases) + 1}",
)
normalized.append(row)
return normalized
def test_replay_matches_shared_live_projection_before_canonical_revision_migration() -> None:
"""Lock the known-equivalent subset without defining the future snapshot protocol."""
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
for case in fixture["cases"]:
actual = replay_transcript_to_ui_messages(case["transcript"])
assert _normalize_projection(actual) == case["expected"], case["name"]
-283
View File
@@ -1,283 +0,0 @@
from __future__ import annotations
import asyncio
import pytest
from nanobot.config.schema import MCPServerConfig
from nanobot.webui.mcp_oauth_api import (
McpOAuthError,
McpOAuthManager,
prepare_mcp_oauth_redirect_uri,
validate_mcp_oauth_redirect_uri,
)
class _Connection:
def __init__(self) -> None:
self.closed = False
async def aclose(self) -> None:
self.closed = True
def _config() -> MCPServerConfig:
return MCPServerConfig(
type="streamableHttp",
auth="oauth",
url="https://mcp.example.com/mcp",
)
@pytest.mark.asyncio
async def test_browser_flow_retries_current_server_and_ignores_unrelated_reload_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = McpOAuthManager()
connection = _Connection()
received: dict[str, object] = {}
reload_calls = 0
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (True, ""),
)
async def connect(servers, _registry, *, oauth_handlers):
assert set(servers) == {"xmind"}
handlers = oauth_handlers["xmind"]
await handlers.redirect_handler(
"https://accounts.example.com/authorize?client_id=test&state=state-123"
)
received["callback"] = await handlers.callback_handler()
return {"xmind": connection}
async def reload_mcp() -> dict[str, object]:
nonlocal reload_calls
reload_calls += 1
if reload_calls == 1:
return {
"ok": False,
"requires_restart": False,
"failed": ["xmind"],
}
return {
"ok": False,
"requires_restart": False,
"connected": ["xmind"],
"failed": ["notion"],
"message": "MCP config reloaded, but some servers did not connect: notion",
}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
started = await manager.start(
"xmind",
_config(),
"https://agent.example.com/auth/mcp/callback",
reload_mcp=reload_mcp,
)
assert started["status"] == "authorization_required"
assert started["authorization_url"].startswith("https://accounts.example.com/authorize?")
manager.submit_callback(state="state-123", code="oauth-code", error=None)
with pytest.raises(McpOAuthError, match="expired"):
manager.submit_callback(state="state-123", code="replayed-code", error=None)
for _ in range(10):
await asyncio.sleep(0)
if reload_calls == 2:
break
assert reload_calls == 2
for _ in range(10):
first, second = await asyncio.gather(
manager.status(started["flow_id"]),
manager.status(started["flow_id"]),
)
if first["status"] == "connected":
break
await asyncio.sleep(0)
assert first["status"] == "connected"
assert second["status"] == "connected"
assert first["hot_reload"]["failed"] == ["notion"]
assert received["callback"] == ("oauth-code", "state-123")
assert reload_calls == 2
assert connection.closed is True
@pytest.mark.asyncio
async def test_remote_http_flow_accepts_a_pasted_loopback_callback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = McpOAuthManager()
connection = _Connection()
received: dict[str, object] = {}
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (True, ""),
)
async def connect(_servers, _registry, *, oauth_handlers):
handlers = oauth_handlers["linear"]
received["redirect_uri"] = handlers.redirect_uri
await handlers.redirect_handler(
"https://accounts.example.com/authorize?client_id=test&state=manual-state"
)
received["callback"] = await handlers.callback_handler()
return {"linear": connection}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
started = await manager.start(
"linear",
_config(),
"http://192.0.2.10:8765/auth/mcp/callback",
reload_mcp=lambda: asyncio.sleep(
0,
result={"ok": True, "requires_restart": False},
),
)
assert started["status"] == "authorization_required"
assert started["completion_input"] == "callback_url"
assert received["redirect_uri"] == "http://127.0.0.1:8765/auth/mcp/callback"
with pytest.raises(McpOAuthError, match="complete callback URL"):
manager.submit_callback_url(
flow_id=started["flow_id"],
callback_url=(
"http://127.0.0.1:8765/wrong?code=oauth-code&state=manual-state"
),
)
with pytest.raises(McpOAuthError, match="different or expired"):
manager.submit_callback_url(
flow_id=started["flow_id"],
callback_url=(
"http://127.0.0.1:8765/auth/mcp/callback"
"?code=oauth-code&state=other-state"
),
)
submitted = manager.submit_callback_url(
flow_id=started["flow_id"],
callback_url=(
"http://127.0.0.1:8765/auth/mcp/callback"
"?code=oauth-code&state=manual-state"
),
)
assert submitted["status"] == "connecting"
for _ in range(20):
await asyncio.sleep(0)
result = await manager.status(started["flow_id"])
if result["status"] == "connected":
break
assert result["status"] == "connected"
assert result["completion_input"] == "callback_url"
assert received["callback"] == ("oauth-code", "manual-state")
assert connection.closed is True
@pytest.mark.asyncio
async def test_browser_flow_surfaces_provider_denial_without_callback_description(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = McpOAuthManager()
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (True, ""),
)
async def connect(_servers, _registry, *, oauth_handlers):
handlers = oauth_handlers["notion"]
await handlers.redirect_handler("https://accounts.example.com/auth?state=deny-state")
await handlers.callback_handler()
return {}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
started = await manager.start(
"notion",
_config(),
"https://agent.example.com/auth/mcp/callback",
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
)
with pytest.raises(McpOAuthError, match="access_denied"):
manager.submit_callback(state="deny-state", code=None, error="access_denied")
for _ in range(10):
await asyncio.sleep(0)
result = await manager.status(started["flow_id"])
if result["status"] == "failed":
break
assert result["status"] == "failed"
assert result["error"] == "Authorization was not completed (access_denied)."
@pytest.mark.parametrize(
("authorization_url", "url_is_safe", "state"),
[
("https://127.0.0.1/authorize?state=private-state", False, "private-state"),
("http://accounts.example.com/authorize?state=http-state", True, "http-state"),
],
)
@pytest.mark.asyncio
async def test_browser_flow_blocks_unsafe_authorization_url(
monkeypatch: pytest.MonkeyPatch,
authorization_url: str,
url_is_safe: bool,
state: str,
) -> None:
manager = McpOAuthManager()
monkeypatch.setattr(
"nanobot.webui.mcp_oauth_api.validate_url_target",
lambda _url: (url_is_safe, "private address"),
)
async def connect(_servers, _registry, *, oauth_handlers):
await oauth_handlers["linear"].redirect_handler(authorization_url)
return {}
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
result = await manager.start(
"linear",
_config(),
"https://agent.example.com/auth/mcp/callback",
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
)
assert result["status"] == "failed"
assert result["error"] == "The MCP server returned an unsafe authorization URL."
with pytest.raises(McpOAuthError, match="expired"):
manager.submit_callback(state=state, code="code", error=None)
def test_redirect_uri_requires_https_except_for_loopback() -> None:
assert validate_mcp_oauth_redirect_uri(
"https://agent.example.com/auth/mcp/callback"
) == "https://agent.example.com/auth/mcp/callback"
assert validate_mcp_oauth_redirect_uri(
"http://127.0.0.1:8765/auth/mcp/callback"
) == "http://127.0.0.1:8765/auth/mcp/callback"
with pytest.raises(McpOAuthError, match="HTTPS or localhost"):
validate_mcp_oauth_redirect_uri("http://192.0.2.10/auth/mcp/callback")
with pytest.raises(McpOAuthError, match="Invalid"):
validate_mcp_oauth_redirect_uri("https://agent.example.com/wrong")
def test_remote_http_redirect_prepares_a_manual_loopback_callback() -> None:
assert prepare_mcp_oauth_redirect_uri(
"https://agent.example.com/auth/mcp/callback"
) == ("https://agent.example.com/auth/mcp/callback", False)
assert prepare_mcp_oauth_redirect_uri(
"http://127.0.0.1:8765/auth/mcp/callback"
) == ("http://127.0.0.1:8765/auth/mcp/callback", False)
assert prepare_mcp_oauth_redirect_uri(
"http://agent.example.com:9443/auth/mcp/callback"
) == ("http://127.0.0.1:9443/auth/mcp/callback", True)
+3 -221
View File
@@ -1,78 +1,22 @@
from __future__ import annotations
import asyncio
import json
import subprocess
from functools import partial
from pathlib import Path
import pytest
from mcp.shared.auth import OAuthToken
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import (
McpPresetError,
custom_mcp_action,
mcp_presets_action,
mcp_presets_payload,
mcp_presets_settings_action,
mcp_presets_test_action,
normalize_mcp_preset_mentions,
)
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def _write_agent_plugin(workspace: Path) -> None:
root = workspace / "plugins" / "desktop"
command = root / "bin" / "server"
command.parent.mkdir(parents=True, exist_ok=True)
command.write_text("#!/bin/sh\n", encoding="utf-8")
setup = root / "bin" / "install"
setup.write_text("#!/bin/sh\n", encoding="utf-8")
setup.chmod(0o755)
assets = root / "assets"
assets.mkdir()
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
(root / "plugin.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "desktop",
"description": "Control the local desktop.",
"extensions": {
"dev.nanobot": {
"displayName": "Desktop Control",
"accentColor": "#ff7a1a",
"logo": "./assets/icon.png",
"permissions": ["screen-recording"],
"installCommand": ["./bin/install"],
}
},
}
),
encoding="utf-8",
)
(root / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"desktop": {"type": "stdio", "command": "./bin/server"},
},
}
),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -94,9 +38,6 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
"aws-docs",
"brave-search",
"postman",
"xmind",
"notion",
"linear",
}.issubset(names)
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
assert browserbase["installed"] is False
@@ -114,99 +55,6 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
assert manifest["trust"]["review_status"] == "builtin_preset"
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
_write_agent_plugin(load_config().workspace_path)
monkeypatch.setattr(
"nanobot.agent.plugins.subprocess.run",
lambda command, **_: subprocess.CompletedProcess(command, 0, "", ""),
)
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
assert row["name"] == "plugin-desktop"
assert row["display_name"] == "Desktop Control"
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
assert row["install_supported"] is False
assert row["installed"] is True
assert row["configured"] is False
assert row["enabled"] is False
assert row["status"] == "disabled"
async def reload() -> dict[str, object]:
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
plugin_action = partial(
mcp_presets_settings_action,
query={"name": ["plugin-desktop"]},
)
with pytest.raises(McpPresetError, match="restricted") as restricted:
asyncio.run(plugin_action("enable", remote=True))
assert restricted.value.status == 403
enabled = asyncio.run(plugin_action("enable", reload_mcp=reload))
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
assert enabled_row["configured"] is True
assert enabled_row["enabled"] is True
assert enabled_row["status"] == "enabled"
assert enabled["requires_restart"] is False
disabled = asyncio.run(plugin_action("disable", reload_mcp=reload))
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
assert disabled_row["installed"] is True
assert disabled_row["configured"] is True
assert disabled_row["enabled"] is False
assert disabled_row["status"] == "disabled"
with pytest.raises(McpPresetError, match="enable and disable"):
asyncio.run(plugin_action("remove"))
config_path = tmp_path / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["tools"] = {
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
}
config_path.write_text(json.dumps(config), encoding="utf-8")
rows = [
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
]
assert len(rows) == 1
assert rows[0]["source"] == "custom"
@pytest.mark.asyncio
async def test_oauth_preset_is_one_click_configured_after_token_storage(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = mcp_presets_action("enable", {"name": ["xmind"]})
row = next(item for item in payload["presets"] if item["name"] == "xmind")
assert row["installed"] is True
assert row["configured"] is False
assert row["status"] == "authorization_required"
assert row["transport"] == "streamableHttp"
assert row["auth"] == "oauth"
config = load_config()
cfg = config.tools.mcp_servers["xmind"]
assert cfg.type == "streamableHttp"
assert cfg.auth == "oauth"
assert cfg.url == "https://app.xmind.com/api/mcp"
await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret"))
connected = mcp_presets_payload()
row = next(item for item in connected["presets"] if item["name"] == "xmind")
assert row["configured"] is True
assert row["status"] == "configured"
mcp_presets_action("remove", {"name": ["xmind"]})
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
def test_enable_browserbase_writes_scrubbed_config_payload(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -448,11 +296,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
assert "<redacted>" in payload["last_action"]["error"]
def test_unknown_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_config(tmp_path, monkeypatch)
with pytest.raises(McpPresetError) as exc:
mcp_presets_action("enable", {"name": ["asana"]})
mcp_presets_action("enable", {"name": ["linear"]})
assert exc.value.status == 404
@@ -566,72 +414,6 @@ def test_import_mcp_config_and_tool_allowlist(
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
def test_import_recognizes_known_and_explicit_oauth_servers(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
payload = custom_mcp_action(
"import",
{
"config": [
(
'{"mcpServers":{'
'"notion-work":{"url":"https://mcp.notion.com/mcp"},'
'"company-mcp":{"url":"https://mcp.example.com/mcp","auth":"oauth"},'
'"notion-pat":{"url":"https://mcp.notion.com/mcp",'
'"headers":{"Authorization":"Bearer secret"}}'
'}}'
)
],
},
)
config = load_config()
assert config.tools.mcp_servers["notion-work"].auth == "oauth"
assert config.tools.mcp_servers["company-mcp"].auth == "oauth"
assert config.tools.mcp_servers["notion-pat"].auth is None
rows = {row["name"]: row for row in payload["presets"]}
assert rows["notion-work"]["status"] == "authorization_required"
assert rows["company-mcp"]["status"] == "authorization_required"
assert rows["notion-pat"]["status"] == "configured"
assert "Bearer secret" not in str(payload)
@pytest.mark.asyncio
async def test_replacing_oauth_config_removes_its_stored_credentials(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
server_url = "https://mcp.example.com/mcp"
custom_mcp_action(
"custom",
{
"name": ["company-mcp"],
"transport": ["streamableHttp"],
"url": [server_url],
"auth": ["oauth"],
},
)
await MCPOAuthStorage("company-mcp", server_url).set_tokens(
OAuthToken(access_token="secret")
)
assert mcp_oauth_has_credentials("company-mcp", server_url)
custom_mcp_action(
"custom",
{
"name": ["company-mcp"],
"transport": ["streamableHttp"],
"url": [server_url],
},
)
assert not mcp_oauth_has_credentials("company-mcp", server_url)
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
+11 -46
View File
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
_clear_webui_oauth_flows,
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
@@ -35,17 +36,11 @@ from nanobot.webui.settings_api import (
update_transcription_settings,
update_web_search_settings,
)
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
@pytest.fixture
def oauth_flows() -> WebUIOAuthFlowRegistry:
return WebUIOAuthFlowRegistry()
def test_settings_payload_propagates_preset_resolution_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -1495,7 +1490,6 @@ def test_xai_grok_status_accepts_refreshable_login(
def test_openai_codex_oauth_login_passes_configured_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
proxy = "http://127.0.0.1:23458"
config_path = tmp_path / "config.json"
@@ -1528,10 +1522,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
fake_start,
)
payload = login_oauth_provider(
{"provider": ["openai-codex"]},
oauth_flows=oauth_flows,
)
payload = login_oauth_provider({"provider": ["openai-codex"]})
assert captured == {
"proxy": proxy,
@@ -1557,17 +1548,15 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
)
monkeypatch.setattr(
"nanobot.webui.settings_api.settings_payload",
lambda **_kwargs: {"settings": "ready"},
lambda: {"settings": "ready"},
)
pending = complete_oauth_provider(
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
oauth_flows=oauth_flows,
)
completed = complete_oauth_provider(
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
"http://localhost:1455/auth/callback?code=secret&state=test",
oauth_flows=oauth_flows,
)
assert pending == {
@@ -1585,7 +1574,6 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
def test_openai_codex_remote_login_uses_headless_dependency_mode(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -1611,11 +1599,10 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
try:
payload = login_oauth_provider(
{"provider": ["openai-codex"], "remote_browser": ["true"]},
oauth_flows=oauth_flows,
{"provider": ["openai-codex"], "remote_browser": ["true"]}
)
finally:
oauth_flows.clear("openai_codex")
_clear_webui_oauth_flows("openai_codex")
assert payload["completion_input"] == "callback_url"
assert captured["open_browser"] is False
@@ -1624,7 +1611,6 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
real_import = builtins.__import__
@@ -1636,10 +1622,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider(
{"provider": ["openai-codex"]},
oauth_flows=oauth_flows,
)
login_oauth_provider({"provider": ["openai-codex"]})
assert str(exc.value) == (
"This nanobot installation is missing the required oauth-cli-kit package. "
@@ -1649,7 +1632,6 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
real_import = builtins.__import__
@@ -1661,10 +1643,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider(
{"provider": ["github-copilot"]},
oauth_flows=oauth_flows,
)
login_oauth_provider({"provider": ["github-copilot"]})
assert str(exc.value) == (
"This nanobot installation is missing the required oauth-cli-kit package. "
@@ -1675,7 +1654,6 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
proxy = "http://127.0.0.1:23458"
config_path = tmp_path / "config.json"
@@ -1697,10 +1675,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
payload = login_oauth_provider(
{"provider": ["xai-grok"]},
oauth_flows=oauth_flows,
)
payload = login_oauth_provider({"provider": ["xai-grok"]})
assert captured["proxy"] == proxy
assert captured["timeout_s"] == 600
@@ -1724,17 +1699,15 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
)
monkeypatch.setattr(
"nanobot.webui.settings_api.settings_payload",
lambda **_kwargs: {"settings": "ready"},
lambda: {"settings": "ready"},
)
pending = complete_oauth_provider(
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
oauth_flows=oauth_flows,
)
completed = complete_oauth_provider(
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
"secret",
oauth_flows=oauth_flows,
)
assert pending == {
@@ -1749,7 +1722,6 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -1762,10 +1734,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider(
{"provider": ["xai-grok"]},
oauth_flows=oauth_flows,
)
login_oauth_provider({"provider": ["xai-grok"]})
assert exc.value.status == 502
assert str(exc.value) == (
@@ -1777,7 +1746,6 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
def test_xai_grok_logout_removes_token_through_shared_lock(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
oauth_flows: WebUIOAuthFlowRegistry,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
@@ -1791,10 +1759,7 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
lambda: token_path,
)
logout_oauth_provider(
{"provider": ["xai-grok"]},
oauth_flows=oauth_flows,
)
logout_oauth_provider({"provider": ["xai-grok"]})
assert not token_path.exists()
+28 -171
View File
@@ -2,21 +2,18 @@ from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlsplit
import pytest
from websockets.datastructures import Headers
from nanobot.config.loader import get_config_path
from nanobot.webui.http_utils import http_json_response
from nanobot.webui.settings_routes import WebUISettingsRouter
from nanobot.webui.settings_services import WebUISettingsServices
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
return WebUISettingsRouter(
settings=WebUISettingsServices.create(get_config_path()),
bus=SimpleNamespace(),
logger=SimpleNamespace(exception=lambda *_args: None),
check_api_token=lambda _request: authorized,
@@ -28,158 +25,30 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
),
runtime_surface="browser",
runtime_capabilities={},
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
)
def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
request = SimpleNamespace(path=path, headers=Headers())
request._nanobot_webui_mutation_request = True
request._nanobot_webui_mutation_payload = payload
request._nanobot_trusted_proxy_authenticated = True
return request
@pytest.mark.asyncio
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
config = SimpleNamespace(
type="streamableHttp",
auth="oauth",
url="https://app.xmind.com/api/mcp",
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.ensure_mcp_oauth_server",
lambda _query, *, config_path=None: ("xmind", config),
)
router = _router()
start = AsyncMock(return_value={
"status": "authorization_required",
"flow_id": "flow-123",
"name": "xmind",
"authorization_url": "https://xmind.example/authorize?state=state-123",
})
router._mcp_oauth = SimpleNamespace(start=start)
request = _mutation_request(
"/api/settings/mcp-oauth/start",
{"name": "xmind"},
)
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start")
assert response is not None
assert response.status_code == 200
assert json.loads(response.body)["flow_id"] == "flow-123"
start.assert_awaited_once_with(
"xmind",
config,
"https://gateway.example/auth/mcp/callback",
reload_mcp=ANY,
reset_credentials=False,
)
denied = _router(authorized=False)
denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start")
assert denied_response is not None
assert denied_response.status_code == 401
failed = _router()
failed._mcp_oauth = SimpleNamespace(
start=AsyncMock(side_effect=RuntimeError("upstream secret response"))
)
failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start")
assert failed_response is not None
assert failed_response.status_code == 500
assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"}
assert b"upstream secret response" not in failed_response.body
@pytest.mark.asyncio
async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None:
router = _router(authorized=False)
submit = MagicMock(return_value="xmind")
router._mcp_oauth = SimpleNamespace(submit_callback=submit)
request = SimpleNamespace(
path="/auth/mcp/callback?code=oauth-code&state=state-123",
headers=Headers(),
)
response = await router.dispatch(None, request, "/auth/mcp/callback")
assert response is not None
assert response.status_code == 200
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
assert response.headers["Cache-Control"] == "no-store"
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
assert b"window.close" in response.body
assert b"Authorization received" in response.body
assert b"oauth-code" not in response.body
submit.assert_called_once_with(state="state-123", code="oauth-code", error=None)
@pytest.mark.asyncio
async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None:
callback_url = (
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123"
)
router = _router()
submit = MagicMock(
return_value={
"flow_id": "flow-123",
"name": "linear",
"status": "connecting",
"expires_in": 299,
"completion_input": "callback_url",
}
)
router._mcp_oauth = SimpleNamespace(submit_callback_url=submit)
request = _mutation_request(
"/api/settings/mcp-oauth/complete",
{"flow_id": "flow-123", "callback_url": callback_url},
)
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete")
assert response is not None
assert response.status_code == 200
assert json.loads(response.body)["status"] == "connecting"
assert b"oauth-code" not in response.body
submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url)
denied = _router(authorized=False)
denied_response = await denied.dispatch(
None,
request,
"/api/settings/mcp-oauth/complete",
)
assert denied_response is not None
assert denied_response.status_code == 401
@pytest.mark.parametrize(
("provider", "authorization_response"),
("provider", "header_name", "authorization_response"),
[
("xai_grok", "secret"),
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
(
"openai_codex",
"X-Nanobot-OAuth-Callback",
"http://localhost:1455/auth/callback?code=secret&state=test",
),
],
)
@pytest.mark.asyncio
async def test_oauth_completion_reads_websocket_payload(
async def test_oauth_completion_reads_private_response_header(
monkeypatch,
provider: str,
header_name: str,
authorization_response: str,
) -> None:
captured: dict[str, object] = {}
def complete(
query,
authorization_response=None,
*,
oauth_flows=None,
config_path=None,
):
def complete(query, authorization_response=None):
captured.update(query=query, authorization_response=authorization_response)
return {
"status": "pending",
@@ -189,13 +58,19 @@ async def test_oauth_completion_reads_websocket_payload(
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
router = _router()
request = _mutation_request(
"/api/settings/provider/oauth-login/complete",
{
"provider": provider,
"flow_id": "flow-123",
"authorization_response": authorization_response,
},
request = SimpleNamespace(
path=(
"/api/settings/provider/oauth-login/complete"
f"?provider={provider}&flow_id=flow-123"
),
headers=Headers(
[
(
header_name,
authorization_response,
)
]
),
)
response = await router.dispatch(
@@ -215,29 +90,28 @@ async def test_oauth_completion_reads_websocket_payload(
"query": {"provider": [provider], "flow_id": ["flow-123"]},
"authorization_response": authorization_response,
}
assert request.path == "/api/settings/provider/oauth-login/complete"
assert not request.headers
assert authorization_response not in request.path
@pytest.mark.parametrize(
("route_path", "function_name", "payload", "expected_query"),
("request_path", "route_path", "function_name", "expected_query"),
[
(
"/api/settings/model-configurations/delete?name=spare",
"/api/settings/model-configurations/delete",
"delete_model_configuration",
{"name": "spare"},
{"name": ["spare"]},
),
(
"/api/settings/model-configurations/migrate",
"/api/settings/model-configurations/migrate",
"migrate_model_configurations",
{},
{},
),
(
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
"/api/settings/model-call-order/update",
"update_model_call_order",
{"order": ["backup"]},
{"order": ['["backup"]']},
),
],
@@ -245,19 +119,19 @@ async def test_oauth_completion_reads_websocket_payload(
@pytest.mark.asyncio
async def test_model_preset_mutation_routes(
monkeypatch,
request_path: str,
route_path: str,
function_name: str,
payload: dict[str, object],
expected_query: dict[str, list[str]],
) -> None:
captured: dict[str, object] = {}
def mutate(query, *, config_path=None):
def mutate(query):
captured["query"] = query
return {"routed": function_name}
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
request = _mutation_request(route_path, payload)
request = SimpleNamespace(path=request_path, headers=Headers())
response = await _router().dispatch(None, request, route_path)
@@ -267,23 +141,6 @@ async def test_model_preset_mutation_routes(
assert captured["query"] == expected_query
@pytest.mark.asyncio
async def test_settings_get_mutation_route_is_method_not_allowed() -> None:
path = "/api/settings/provider/update"
request = SimpleNamespace(
path=f"{path}?provider=openrouter&api_key=must-not-run",
headers=Headers(),
)
response = await _router().dispatch(None, request, path)
assert response is not None
assert response.status_code == 405
assert json.loads(response.body) == {
"error": "WebUI mutations require an authenticated WebSocket"
}
@pytest.mark.parametrize(
("update_info", "expected"),
[
-170
View File
@@ -1,170 +0,0 @@
from __future__ import annotations
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from nanobot.channels.websocket.runtime import WebSocketConfig
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.settings_api import settings_payload, update_agent_settings, update_api_settings
from nanobot.webui.settings_services import (
WebUIOAuthFlowRegistry,
WebUISettingsServices,
)
class _Flow:
def __init__(self, *, expired: bool = False) -> None:
self.expired = expired
self.cancel_count = 0
def cancel(self) -> None:
self.cancel_count += 1
def _gateway(config_path: Path, workspace: Path):
return build_gateway_services(
config=WebSocketConfig(),
bus=MagicMock(),
session_manager=None,
static_dist_path=None,
workspace_path=workspace,
default_restrict_to_workspace=False,
config_path=config_path,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
)
def test_gateway_settings_services_isolate_config_paths_and_oauth_flows(
tmp_path: Path,
) -> None:
first_path = tmp_path / "first" / "config.json"
second_path = tmp_path / "second" / "config.json"
first_config = Config()
first_config.api.host = "127.0.0.2"
second_config = Config()
second_config.api.host = "127.0.0.3"
save_config(first_config, first_path)
save_config(second_config, second_path)
first = _gateway(first_path, tmp_path / "first-workspace")
second = _gateway(second_path, tmp_path / "second-workspace")
assert first.settings.config.path == first_path.resolve()
assert second.settings.config.path == second_path.resolve()
assert first.http.settings_routes.settings is first.settings
assert second.http.settings_routes.settings is second.settings
assert first.settings.config.load().api.host == "127.0.0.2"
assert second.settings.config.load().api.host == "127.0.0.3"
assert first.settings.read(settings_payload)["api"]["host"] == "127.0.0.2"
assert second.settings.read(settings_payload)["api"]["host"] == "127.0.0.3"
first.settings.mutate(update_api_settings, {"port": ["19001"]})
assert load_config(first_path).api.port == 19001
assert load_config(second_path).api.port != 19001
first_flow = _Flow()
second_flow = _Flow()
first.settings.oauth_flows.register("openai_codex", "same-id", first_flow)
second.settings.oauth_flows.register("openai_codex", "same-id", second_flow)
assert first.settings.oauth_flows.get("openai_codex", "same-id") is first_flow
assert second.settings.oauth_flows.get("openai_codex", "same-id") is second_flow
first.settings.oauth_flows.clear("openai_codex")
assert first_flow.cancel_count == 1
assert second_flow.cancel_count == 0
def test_settings_mutations_serialize_read_modify_write(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
services = WebUISettingsServices.create(config_path)
first_loaded = threading.Event()
release_first = threading.Event()
second_started = threading.Event()
second_loaded = threading.Event()
errors: list[BaseException] = []
from nanobot.webui import settings_api
original_load = settings_api._load_settings_config
def controlled_load(path: Path | None) -> Config:
config = original_load(path)
if threading.current_thread().name == "settings-first":
first_loaded.set()
if not release_first.wait(timeout=2):
raise TimeoutError("timed out waiting to release first settings mutation")
elif threading.current_thread().name == "settings-second":
second_loaded.set()
return config
monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load)
def run_first() -> None:
try:
services.mutate(update_agent_settings, {"timezone": ["Asia/Tokyo"]})
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
errors.append(exc)
def run_second() -> None:
try:
second_started.set()
services.mutate(update_api_settings, {"host": ["127.0.0.9"]})
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
errors.append(exc)
first = threading.Thread(target=run_first, name="settings-first")
second = threading.Thread(target=run_second, name="settings-second")
first.start()
assert first_loaded.wait(timeout=2)
second.start()
assert second_started.wait(timeout=2)
assert not second_loaded.wait(timeout=0.1)
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
assert not first.is_alive()
assert not second.is_alive()
assert not errors
saved = load_config(config_path)
assert saved.agents.defaults.timezone == "Asia/Tokyo"
assert saved.api.host == "127.0.0.9"
def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None:
registry = WebUIOAuthFlowRegistry(max_flows=2)
expired = _Flow(expired=True)
oldest = _Flow()
newest = _Flow()
replacement = _Flow()
registry.register("openai_codex", "expired", expired)
registry.register("openai_codex", "oldest", oldest)
assert expired.cancel_count == 1
assert registry.get("openai_codex", "expired") is None
registry.register("xai_grok", "newest", newest)
registry.register("openai_codex", "replacement", replacement)
assert oldest.cancel_count == 1
assert registry.get("openai_codex", "oldest") is None
assert registry.get("xai_grok", "newest") is newest
assert registry.get("openai_codex", "newest") is None
registry.remove("xai_grok", "newest", newest, cancel=False)
assert newest.cancel_count == 0
assert registry.get("xai_grok", "newest") is None
registry.clear("openai_codex")
assert replacement.cancel_count == 1
assert registry.get("openai_codex", "replacement") is None
-38
View File
@@ -1,38 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from nanobot.channels.websocket.runtime import WebSocketConfig
from nanobot.webui.ws_http import GatewayHTTPHandler
def _handler(config: WebSocketConfig) -> GatewayHTTPHandler:
handler = object.__new__(GatewayHTTPHandler)
handler.config = config
return handler
def _request(**headers: str) -> WsRequest:
return cast(WsRequest, SimpleNamespace(headers=Headers(headers)))
def test_mcp_oauth_callback_uses_configured_public_websocket_origin() -> None:
handler = _handler(WebSocketConfig(path="/ws", public_ws_url="wss://agent.example/ws"))
redirect_uri = handler._mcp_oauth_redirect_uri(_request(Host="ignored.example"))
assert redirect_uri == "https://agent.example/auth/mcp/callback"
def test_mcp_oauth_callback_uses_safe_forwarded_request_origin() -> None:
handler = _handler(WebSocketConfig(path="/ws", host="127.0.0.1", port=8765))
redirect_uri = handler._mcp_oauth_redirect_uri(
_request(Host="nanobot.example:9443", **{"X-Forwarded-Proto": "https"})
)
assert redirect_uri == "https://nanobot.example:9443/auth/mcp/callback"
+21 -61
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
@@ -316,23 +316,13 @@ function AuthForm({
onSecret: (secret: string) => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState("");
const [passwordVisible, setPasswordVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [validationError, setValidationError] = useState<"required" | "invalid" | null>(
failed ? "invalid" : null,
);
const errorMessage = validationError ? t(`app.auth.${validationError}`) : null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const secret = value.trim();
if (!secret) {
setValidationError("required");
inputRef.current?.focus();
return;
}
if (!secret) return;
setSubmitting(true);
onSecret(secret);
};
@@ -343,57 +333,27 @@ function AuthForm({
onSubmit={handleSubmit}
className="flex w-full max-w-sm flex-col gap-4"
>
<div className="space-y-2">
<h1 className="text-sm font-medium text-foreground">
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
</h1>
<div className="relative">
<Input
ref={inputRef}
id="webui-access-password"
name="webui-access-password"
type={passwordVisible ? "text" : "password"}
autoComplete="current-password"
value={value}
onChange={(e) => {
setValue(e.target.value);
setValidationError(null);
}}
disabled={submitting}
aria-invalid={validationError ? true : undefined}
aria-describedby={validationError ? "webui-auth-error" : undefined}
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={submitting}
aria-label={t(
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
)}
aria-controls="webui-access-password"
onClick={() => setPasswordVisible((visible) => !visible)}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{passwordVisible ? (
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
) : (
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
)}
</Button>
</div>
{errorMessage ? (
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
{errorMessage}
</p>
) : null}
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
</div>
{failed && (
<p className="text-center text-sm text-destructive">
{t("app.auth.invalid")}
</p>
)}
<Input
type="password"
placeholder={t("app.auth.placeholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={submitting}
autoFocus
/>
<Button
type="submit"
className="w-full"
disabled={submitting}
disabled={!value.trim() || submitting}
>
{t("app.auth.submit")}
</Button>
@@ -2081,7 +2041,7 @@ function Shell({
setPairingBusyCode(code);
setPairingError(null);
try {
const payload = await runPairingAction(client, action, code);
const payload = await runPairingAction(getToken(), action, code);
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
setSnoozedPairingCodes((current) => {
if (!current.has(code)) return current;
@@ -2096,7 +2056,7 @@ function Shell({
setPairingBusyCode(null);
}
},
[client, refreshPairingRequests],
[getToken, refreshPairingRequests],
);
const onDismissPairingRequest = useCallback((code: string) => {
File diff suppressed because it is too large Load Diff
@@ -269,7 +269,7 @@ function SkillDetailSheet({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { client, getToken } = useClient();
const { getToken } = useClient();
const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false);
@@ -321,7 +321,7 @@ function SkillDetailSheet({
setActionBusy(true);
setActionError("");
try {
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
notifySkillsChanged(payload);
const updated = payload.skills.find((item) => item.name === activeSkill.name);
if (updated) {
@@ -345,7 +345,7 @@ function SkillDetailSheet({
setActionBusy(true);
setActionError("");
try {
const payload = await deleteSkill(client, activeSkill.name);
const payload = await deleteSkill(getToken(), activeSkill.name);
notifySkillsChanged(payload);
onOpenChange(false);
} catch (reason) {
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
installing: string;
onInstallingChange: (skillId: string) => void;
}) {
const { client, getToken } = useClient();
const { getToken } = useClient();
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
setError("");
try {
const payload = await installMarketplaceSkill(
client,
getToken(),
skill.provider,
skill.source,
skill.skill_id,
@@ -38,7 +38,6 @@ import type {
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export type ChannelInstancesPanelCustomization = {
countLabel?: (runningCount: number) => string;
@@ -51,6 +50,7 @@ export type ChannelInstancesPanelCustomization = {
};
export function ChannelInstancesPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
@@ -58,6 +58,7 @@ export function ChannelInstancesPanel({
onFeaturesUpdate,
customization = {},
}: {
token: string;
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
chatAppsDocsUrl?: string;
@@ -65,7 +66,6 @@ export function ChannelInstancesPanel({
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
customization?: ChannelInstancesPanelCustomization;
}) {
const { client } = useClient();
const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const displayName = localizedChannelDisplayName(feature, t);
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
setNotice(null);
try {
const payload = checked
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
setNotice(null);
try {
const payload = await configureChannel(
client,
token,
feature.name,
channelValuesForSave(instanceFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id },
@@ -14,7 +14,6 @@ import type {
ChannelConnectPayload,
NanobotFeaturesPayload,
} from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
export type ChannelQrConnectLabels = {
qrAlt: string;
@@ -44,6 +43,7 @@ export type ChannelQrConnectPendingContext = {
};
export function ChannelQrConnectFlow({
token,
channelName,
startOptions = {},
idleLabel,
@@ -69,7 +69,6 @@ export function ChannelQrConnectFlow({
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
suppressSucceeded?: boolean;
}) {
const { client } = useClient();
const pageVisible = usePageVisibility();
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -79,6 +78,8 @@ export function ChannelQrConnectFlow({
const [error, setError] = useState<string | null>(null);
const [handledRequestId, setHandledRequestId] = useState(0);
const pollInFlight = useRef(false);
const tokenRef = useRef(token);
tokenRef.current = token;
const startDomain = startOptions.domain;
const startInstanceId = startOptions.instanceId;
const startMode = startOptions.mode;
@@ -128,7 +129,7 @@ export function ChannelQrConnectFlow({
pollInFlight.current = true;
try {
const payload = await pollChannelConnect(
client,
tokenRef.current,
channelName,
sessionId,
);
@@ -162,7 +163,6 @@ export function ChannelQrConnectFlow({
};
}, [
channelName,
client,
connect?.interval_ms,
connect?.session_id,
connect?.status,
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
setBusy(true);
setError(null);
try {
const payload = await startChannelConnect(client, channelName, {
const payload = await startChannelConnect(tokenRef.current, channelName, {
domain: startDomain,
instanceId: startInstanceId,
mode: startMode,
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
} finally {
setBusy(false);
}
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
useEffect(() => {
if (!connectRequestId || connectRequestId === handledRequestId) return;
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
setBusy(true);
try {
const payload = await cancelChannelConnect(
client,
tokenRef.current,
channelName,
connect.session_id,
);
@@ -223,9 +223,10 @@ export function ChannelQrConnectFlow({
setError(null);
try {
const payload = await pollChannelConnect(
client,
tokenRef.current,
channelName,
connect.session_id,
"",
params,
);
setConnect((current) => ({
@@ -54,7 +54,6 @@ import type {
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function ChannelCatalogRow({
feature,
@@ -149,6 +148,7 @@ export function ChannelSetupPanel({
if (feature.instances !== undefined) {
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
@@ -269,7 +269,6 @@ function ChannelSetupSurface({
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { client } = useClient();
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [notice, setNotice] = useState<string | null>(null);
@@ -346,7 +345,7 @@ function ChannelSetupSurface({
setNotice(null);
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
try {
const validationPayload = await validateChannel(client, feature.name, values);
const validationPayload = await validateChannel(token, feature.name, values);
setValidation(validationPayload);
if (!validationPayload.can_enable) {
setNotice(
@@ -356,7 +355,7 @@ function ChannelSetupSurface({
return;
}
const payload = await configureChannel(
client,
token,
feature.name,
values,
{ enable: true },
@@ -378,7 +377,7 @@ function ChannelSetupSurface({
setNotice(null);
try {
const payload = await validateChannel(
client,
token,
feature.name,
channelValuesForSubmit(fields, fieldValues, touchedFields),
);
+35 -66
View File
@@ -179,11 +179,7 @@ function getVoiceShortcutLabel(): string {
}
interface ThreadComposerProps {
onSend: (
content: string,
images?: SendAttachment[],
options?: SendOptions,
) => boolean | void | Promise<boolean | void>;
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
disabled?: boolean;
placeholder?: string;
isStreaming?: boolean;
@@ -985,8 +981,6 @@ export function ThreadComposer({
end: number;
} | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const [sendPending, setSendPending] = useState(false);
const interactionDisabled = !!disabled || sendPending;
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
@@ -1077,7 +1071,7 @@ export function ThreadComposer({
const addFiles = useCallback(
(files: File[]) => {
if (interactionDisabled || files.length === 0) return;
if (files.length === 0) return;
secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files);
if (rejected.length > 0) {
@@ -1086,7 +1080,7 @@ export function ThreadComposer({
setInlineError(null);
}
},
[enqueue, formatRejection, interactionDisabled],
[enqueue, formatRejection],
);
const {
@@ -1099,20 +1093,18 @@ export function ThreadComposer({
} = useClipboardAndDrop(addFiles);
useEffect(() => {
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
return;
}
if (disabled || hasTouchPrimaryPointer) return;
const el = textareaRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id);
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
}, [disabled, hasTouchPrimaryPointer]);
useEffect(() => {
if (!focusRequest || interactionDisabled) return;
if (!focusRequest || disabled) return;
const id = requestAnimationFrame(() => textareaRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [focusRequest, interactionDisabled]);
}, [disabled, focusRequest]);
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
@@ -1126,17 +1118,15 @@ export function ThreadComposer({
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend =
!interactionDisabled
!disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& hasComposerContent;
const canOpenModelSettings = Boolean(
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
);
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canQueueGuidance =
isStreaming
&& !interactionDisabled
&& !disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
@@ -1144,14 +1134,14 @@ export function ThreadComposer({
&& !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => {
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
const commandToken = value.slice(1);
if (/\s/.test(commandToken)) return null;
return commandToken.toLowerCase();
}, [interactionDisabled, slashMenuDismissed, value]);
}, [disabled, slashMenuDismissed, value]);
const skillQuery = useMemo(() => {
if (interactionDisabled || slashMenuDismissed) return null;
if (disabled || slashMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
@@ -1161,7 +1151,7 @@ export function ThreadComposer({
start: match.index,
text: match[1].toLowerCase(),
};
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
}, [cursorPosition, disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
@@ -1289,7 +1279,7 @@ export function ThreadComposer({
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
if (interactionDisabled || cliAppMenuDismissed) return null;
if (disabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
@@ -1300,7 +1290,7 @@ export function ThreadComposer({
start: caret - query.length - 1,
end: caret,
};
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const availableSessionMentions = useMemo(
() => sessionMentionOptions(
@@ -1590,7 +1580,7 @@ export function ThreadComposer({
}, VOICE_ERROR_VISIBLE_MS);
}, [clearVoiceErrorTimers, t]);
const voiceRecorder = useVoiceRecorder({
disabled: interactionDisabled,
disabled,
onClearError: clearInlineError,
onError: setVoiceError,
onTranscript: appendTranscription,
@@ -1724,7 +1714,7 @@ export function ThreadComposer({
clearDraggedSession();
const preview = sessionDragPreview;
setSessionDragPreview(null);
if (interactionDisabled) return true;
if (disabled) return true;
const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
@@ -1742,17 +1732,11 @@ export function ThreadComposer({
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
);
return true;
}, [
availableSessionMentions,
insertMentionCandidate,
interactionDisabled,
sessionDragPreview,
value.length,
]);
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
const previewSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false;
if (interactionDisabled) {
if (disabled) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null);
return true;
@@ -1781,7 +1765,7 @@ export function ThreadComposer({
: { mention, start, end }
));
return true;
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
useEffect(() => {
if (!sessionDragPreview) return;
@@ -2024,16 +2008,7 @@ export function ThreadComposer({
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
const finalizeActiveTurn =
slashLifecycle === "finalize_active_turn";
const finishSend = () => {
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null);
};
const result = onSend(
onSend(
content,
payload,
isSlashSideChannel
@@ -2044,19 +2019,13 @@ export function ThreadComposer({
}
: options,
);
if (result instanceof Promise) {
setSendPending(true);
void result
.then((accepted) => {
if (accepted !== false) finishSend();
})
.catch((error: unknown) => {
console.error("Failed to send message", error);
})
.finally(() => setSendPending(false));
return;
}
if (result !== false) finishSend();
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message.
clear();
clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null);
}, [
activeCliMentionApps,
activeMcpPresetMentions,
@@ -2199,7 +2168,7 @@ export function ThreadComposer({
[removeChip],
);
const attachButtonDisabled = interactionDisabled || full;
const attachButtonDisabled = disabled || full;
const showVoiceButton = Boolean(onTranscribeAudio);
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
time: voiceRecorder.elapsedLabel,
@@ -2284,7 +2253,7 @@ export function ThreadComposer({
isHero
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
interactionDisabled && "opacity-60",
disabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
@@ -2399,7 +2368,7 @@ export function ThreadComposer({
onPaste={onPaste}
rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={interactionDisabled}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
@@ -2474,7 +2443,7 @@ export function ThreadComposer({
) : workspaceScope && !workspaceControlsHidden ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={interactionDisabled || workspaceScopeDisabled}
disabled={disabled || workspaceScopeDisabled}
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
isHero={isHero}
onChange={onWorkspaceScopeChange}
@@ -2552,7 +2521,7 @@ export function ThreadComposer({
<Button
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon"
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
aria-label={
showStopButton
? t("thread.composer.stop")
@@ -2593,7 +2562,7 @@ export function ThreadComposer({
<div className="composer-workspace-drawer-content">
<WorkspaceProjectPicker
isHero={isHero}
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
+2 -3
View File
@@ -1261,7 +1261,7 @@ export function ThreadShell({
const handleWelcomeSend = useCallback(
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
if (booting) return false;
if (booting) return;
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null);
@@ -1270,13 +1270,12 @@ export function ThreadShell({
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
setBooting(false);
return false;
return;
}
if (localModelPreset) {
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
}
setPendingFirstTargetChatId(newId);
return true;
},
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -34,14 +34,6 @@ import {
shortWorkspacePath,
} from "@/lib/workspace";
function workspacePathPlaceholder(defaultWorkspacePath: string, macPlaceholder: string): string {
const normalized = defaultWorkspacePath.trim().replace(/\\/g, "/");
const windowsDrive = normalized.match(/^([A-Za-z]):\//)?.[1];
if (windowsDrive) return `${windowsDrive.toUpperCase()}:\\path\\to\\project`;
if (normalized.startsWith("/Users/")) return macPlaceholder;
return "/home/name/project";
}
export function WorkspaceProjectPicker({
isHero,
compact = false,
@@ -68,9 +60,6 @@ export function WorkspaceProjectPicker({
const [pathDraft, setPathDraft] = useState("");
const [pathError, setPathError] = useState<string | null>(null);
const [pickingFolder, setPickingFolder] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathErrorId = useId();
const currentProjectScope = selectedProjectScope(scope, defaultScope);
const projectLabel = currentProjectScope
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
@@ -93,17 +82,9 @@ export function WorkspaceProjectPicker({
}, [disabled]);
useEffect(() => {
if (!error || !visible || disabled) return;
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
if (error && visible && !disabled) setOpen(true);
}, [disabled, error, visible]);
useEffect(() => {
if (!open || !error) return;
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
return () => window.cancelAnimationFrame(frame);
}, [error, open]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
const base = scope ?? defaultScope;
@@ -148,7 +129,6 @@ export function WorkspaceProjectPicker({
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
)}>
<button
ref={triggerRef}
type="button"
disabled={disabled || pickingFolder}
aria-label={t("thread.composer.workspace.projectAria")}
@@ -184,7 +164,6 @@ export function WorkspaceProjectPicker({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
ref={triggerRef}
type="button"
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
@@ -242,20 +221,14 @@ export function WorkspaceProjectPicker({
}}
>
<Input
ref={pathInputRef}
value={pathDraft}
disabled={disabled}
onChange={(event) => {
setPathDraft(event.target.value);
setPathError(null);
}}
placeholder={workspacePathPlaceholder(
defaultScope.project_path,
t("workspace.dialog.manualPlaceholder"),
)}
placeholder={t("workspace.dialog.manualPlaceholder")}
aria-label={t("workspace.dialog.manual")}
aria-invalid={pathError || error ? true : undefined}
aria-describedby={pathError || error ? pathErrorId : undefined}
className={cn(
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
@@ -270,22 +243,13 @@ export function WorkspaceProjectPicker({
</Button>
</form>
{pathError || error ? (
<p
id={pathErrorId}
role="alert"
className="px-1 text-[11.5px] font-medium text-destructive"
>
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</p>
) : null}
</div>
</PopoverContent>
</Popover>
{!compact && error && !open ? (
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
{error}
</span>
) : null}
</div>
);
}
+349 -27
View File
@@ -10,23 +10,6 @@ import {
} from "@/lib/tool-traces";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client";
import {
closeReasoningStream,
filterCoveredFileEditToolEvents,
finalizeStreamedTurn,
findActiveAssistantPlaceholderIndex,
findFileEditTraceIndex,
findStreamingAssistantIndex,
isReasoningOnlyPlaceholder,
matchesTurn,
mergeFileEdits,
pruneReasoningOnlyPlaceholders,
replaceMessageAt,
stampLastAssistantCompletion,
stripCoveredFileEditToolHintsFromMessages,
turnFieldsFromEvent,
} from "@/lib/thread-event-projection";
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import type {
InboundEvent,
@@ -36,8 +19,11 @@ import type {
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
ToolProgressEvent,
UIMediaAttachment,
UIFileEdit,
UIMessage,
UITurnPhase,
WorkspaceScopePayload,
} from "@/lib/types";
@@ -55,9 +41,54 @@ type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
fallbackPhase?: UITurnPhase,
): UIMessageTurnFields {
const fields: UIMessageTurnFields = {};
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
fields.turnId = ev.turn_id;
}
const phase = ev.turn_phase ?? fallbackPhase;
if (phase) fields.turnPhase = phase;
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
fields.turnSeq = ev.turn_seq;
}
return fields;
}
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
}
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */
function findStreamingAssistantIndex(
prev: UIMessage[],
closedStreamIds: ReadonlySet<string>,
turn: UIMessageTurnFields = {},
): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (
m.role === "assistant"
&& m.isStreaming
&& !closedStreamIds.has(m.id)
&& matchesTurn(m, turn)
) return i;
if (m.role === "user") break;
}
return null;
}
/**
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
*
@@ -121,6 +152,102 @@ function attachReasoningChunk(
];
}
/**
* Find the most recent assistant placeholder that an incoming answer
* delta should adopt instead of spawning a parallel row. We look for an
* empty-content assistant turn that is still marked ``isStreaming``
* typically created earlier by ``reasoning_delta``. Anything else means
* the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row.
*/
function findActiveAssistantPlaceholderIndex(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
if (!matchesTurn(last, turn)) return null;
return prev.length - 1;
}
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
const next = prev.slice();
next[index] = message;
return next;
}
/**
* Close the active reasoning stream segment, if any. Idempotent: a
* ``reasoning_end`` with no preceding deltas is a harmless no-op.
*/
function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (!candidate.reasoningStreaming) continue;
const latencyMs =
candidate.latencyMs === undefined
&& Number.isFinite(candidate.createdAt)
&& candidate.createdAt > 1_000_000_000_000
? Math.max(0, Math.round(Date.now() - candidate.createdAt))
: candidate.latencyMs;
const merged: UIMessage = {
...candidate,
reasoningStreaming: false,
...(latencyMs !== undefined ? { latencyMs } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
return prev;
}
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length === 0
&& !!message.reasoning
&& !message.reasoningStreaming
&& !message.media?.length
);
}
function isToolTrace(message: UIMessage | undefined): boolean {
return message?.kind === "trace";
}
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
return prev.filter((message, index) => {
if (!isReasoningOnlyPlaceholder(message)) return true;
// A reasoning-only assistant row immediately followed by tool traces is
// the live equivalent of a persisted assistant tool-call message with
// empty content, reasoning_content, and tool_calls. Keep it so live render
// and history replay stay isomorphic.
return isToolTrace(prev[index + 1]);
});
}
function stampLastAssistantCompletion(
prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (
m.role === "assistant"
&& m.kind !== "trace"
&& (!turnId || !m.turnId || m.turnId === turnId)
) {
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
}
return prev;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@@ -148,6 +275,193 @@ function absorbCompleteAssistantMessage(
];
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
}
function fileEditToolEventKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return fileEditKey(edit);
}
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
const fn = (event as { function?: { name?: unknown } }).function;
const name = typeof event.name === "string"
? event.name
: typeof fn?.name === "string"
? fn.name
: "";
const callId = typeof event.call_id === "string" ? event.call_id : "";
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
return `${callId}|${name}`;
}
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
const key = toolEventFileEditKey(event);
if (!key) return false;
return messages.some((message) =>
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
);
}
function filterCoveredFileEditToolEvents(
messages: UIMessage[],
events: ToolProgressEvent[],
): ToolProgressEvent[] {
if (events.length === 0) return events;
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
}
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
const events = message.toolEvents ?? [];
if (!events.length || incomingKeys.size === 0) return message;
const removedTraceLines = new Set<string>();
const keptEvents: ToolProgressEvent[] = [];
let changed = false;
for (const event of events) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) {
changed = true;
for (const line of toolTraceLinesFromEvents([event])) {
removedTraceLines.add(line);
}
continue;
}
keptEvents.push(event);
}
if (!changed) return message;
const previousTraces = message.traces?.length
? message.traces
: message.content
? [message.content]
: [];
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
return {
...message,
traces: nextTraces,
content: nextTraces[nextTraces.length - 1] ?? "",
toolEvents: keptEvents.length ? keptEvents : undefined,
};
}
function traceMessageIsEmpty(message: UIMessage): boolean {
const traces = message.traces;
const hasTrace = traces?.length
? traces.some((line) => line.trim().length > 0)
: (message.content ?? "").trim().length > 0;
return (
message.kind === "trace"
&& !hasTrace
&& !message.toolEvents?.length
&& !message.fileEdits?.length
&& !message.media?.length
);
}
function stripCoveredFileEditToolHintsFromMessages(
messages: UIMessage[],
edits: UIFileEdit[],
turn: UIMessageTurnFields,
): UIMessage[] {
if (edits.length === 0) return messages;
let next = messages;
for (let i = next.length - 1; i >= 0; i -= 1) {
const candidate = next[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (!matchesTurn(candidate, turn)) continue;
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
if (cleaned === candidate) continue;
if (next === messages) next = [...messages];
if (traceMessageIsEmpty(cleaned)) {
next.splice(i, 1);
} else {
next[i] = cleaned;
}
}
return next;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
const normalized: UIFileEdit = {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
? edit.status
: inferredStatus,
};
if (edit.pending && !edit.path) normalized.pending = true;
return normalized;
}
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
const next = [...(existing ?? [])];
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
for (const raw of incoming) {
const edit = normalizeFileEdit(raw);
if (!edit) continue;
const key = fileEditKey(edit);
let existingIndex = indexByKey.get(key);
if (existingIndex === undefined && edit.path) {
const eventKey = fileEditToolEventKey(edit);
const pendingIndex = next.findIndex((existing) =>
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
);
if (pendingIndex >= 0) existingIndex = pendingIndex;
}
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(edit);
continue;
}
const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
indexByKey.set(key, existingIndex);
}
return next;
}
function findFileEditTraceIndex(
prev: UIMessage[],
segmentId: string | null,
incoming: UIFileEdit[],
): number | null {
const incomingKeys = new Set(incoming.map(fileEditKey));
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits ?? []) {
if (
incomingKeys.has(fileEditKey(existing))
|| (
!existing.path
&& existing.pending
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
)
) return i;
}
}
return null;
}
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@@ -193,6 +507,17 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
}
function finalizeStreamedTurn(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): UIMessage[] {
return prev.map((m) =>
m.isStreaming && matchesTurn(m, turn)
? { ...m, isStreaming: false, reasoningStreaming: false }
: m,
);
}
function eventTurnId(ev: InboundEvent): string | undefined {
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
}
@@ -779,7 +1104,7 @@ export function useNanobotStream(
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
setMessages((prev) => closeReasoningStream(prev, Date.now()));
setMessages((prev) => closeReasoningStream(prev));
return;
}
@@ -849,15 +1174,12 @@ export function useNanobotStream(
const line = ev.text;
if (!line) return;
if (fileEditSegmentRef.current) clearActivitySegment();
setMessages((prev) => closeReasoningStream(
attachReasoningChunk(
prev,
line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
),
Date.now(),
));
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
prev,
line,
{ ensure: ensureActivitySegmentId },
turnFieldsFromEvent(ev, "reasoning"),
)));
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
+2 -2
View File
@@ -257,13 +257,13 @@ export function useSessions(): {
const deleteChat = useCallback(
async (key: string, options?: { deleteAutomations?: boolean }) => {
const result = await apiDeleteSession(client, key, options);
const result = await apiDeleteSession(tokenRef.current, key, options);
if (!result.deleted) return result;
optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key));
return result;
},
[client],
[],
);
const getSessionAutomations = useCallback(async (key: string) => {
+2 -22
View File
@@ -144,8 +144,6 @@ export function useSidebarState(
const { client, token } = useClient();
const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const connectionOpenRef = useRef(client.status === "open");
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true);
tokenRef.current = token;
@@ -173,32 +171,14 @@ export function useSidebarState(
};
}, []);
const persist = useCallback((next: SidebarStatePayload) => {
if (!connectionOpenRef.current) {
pendingPersistenceRef.current = next;
return;
}
void client.setSidebarState(next).catch(() => {
// Sidebar persistence is best-effort; the optimistic local state remains usable.
});
}, [client]);
useEffect(() => client.onStatus((status) => {
connectionOpenRef.current = status === "open";
if (status !== "open" || pendingPersistenceRef.current === null) return;
const pending = pendingPersistenceRef.current;
pendingPersistenceRef.current = null;
persist(pending);
}), [client, persist]);
const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current));
stateRef.current = next;
setState(next);
persist(next);
client.setSidebarState(next);
},
[persist],
[client],
);
const pruned = useMemo(() => {
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
},
"auth": {
"label": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"title": "Authentication required",
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
"placeholder": "Password",
"submit": "Connect",
"required": "Enter your password.",
"invalid": "Incorrect password. Try again."
"invalid": "Invalid password. Try again."
},
"account": {
"section": "Account",
@@ -319,8 +318,8 @@
"filterInstalled": "Enabled",
"filterNotInstalled": "Not enabled",
"searchPlaceholder": "Search MCP presets",
"moreOptions": "Add MCP server",
"moreOptionsSubtitle": "Connect a custom MCP server or import an existing configuration.",
"moreOptions": "Add integration",
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
"customTitle": "Custom MCP",
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
"customAction": "Custom",
@@ -328,14 +327,9 @@
"serverName": "Server name",
"serverUrl": "URL",
"transport": "Transport",
"authentication": "Authentication",
"authNone": "None",
"authHeaders": "Headers",
"command": "Command",
"args": "Args JSON",
"headers": "Headers JSON",
"oauthAfterSave": "Save the server, then select Connect to sign in.",
"headersHelp": "Add the request headers used by this server.",
"env": "Env JSON",
"timeout": "Tool timeout",
"advancedOptions": "Advanced options",
@@ -362,21 +356,6 @@
"keepExisting": "Leave blank to keep existing",
"statusConfigured": "Configured",
"statusMissingCredentials": "Needs key",
"connectingAccount": "Connecting {{name}}",
"connectingLabel": "Connecting…",
"continueSignIn": "Continue sign-in",
"preparingSignIn": "Preparing secure sign-in…",
"openSignInToContinue": "Open the sign-in page to continue.",
"finishSignInInBrowser": "Finish signing in in the browser window.",
"manualCallbackRequired": "Finish signing in, then paste the callback URL into nanobot.",
"manualCallbackHelp": "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
"finishingConnection": "Finishing connection…",
"activatingTools": "Activating tools…",
"connected": "Connected.",
"connectionFailed": "Connection failed.",
"connectionCancelled": "Connection cancelled.",
"reloadFailed": "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
"oauthFailed": "Unable to connect. Try signing in again.",
"statusMissingDependency": "Needs dependency",
"statusComingSoon": "Coming soon",
"comingSoon": "Coming soon",
@@ -604,28 +583,20 @@
"apps": {
"description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App",
"mcpLabel": "MCP",
"mcpLabel": "Integration",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "Ready",
"filterPlugins": "Plugins",
"filterCli": "Apps",
"filterMcp": "MCP",
"filterMcp": "Integrations",
"enabledSummary": "{{count}} ready",
"caption": "{{cli}} apps · {{mcp}} MCP tools",
"caption": "{{cli}} apps · {{mcp}} integrations",
"searchPlaceholder": "Search tools",
"featured": "Tools",
"mcpTools": "MCP tools",
"loading": "Loading Apps...",
"empty": "No tools match your search.",
"emptyApps": "No apps available.",
"emptyIntegrations": "No MCP tools available.",
"emptyReady": "No tools are ready yet.",
"clearSearch": "Clear search",
"browseApps": "Browse apps",
"browseIntegrations": "Browse MCP tools",
"emptyIntegrationsHint": "Add a custom MCP server below.",
"restartRequired": "Restart nanobot to apply updated apps and MCP tools."
"empty": "No tools match this view.",
"restartRequired": "Restart nanobot to apply updated apps and integrations."
},
"channels": {
"description": "Connect chat apps, email, and WebUI to nanobot.",
@@ -727,9 +698,7 @@
"loading": "Loading automations...",
"noMatches": "No automations match this view.",
"empty": "No automations yet.",
"emptyHint": "Create automations in a chat so they keep the right context.",
"emptyAction": "Open a chat",
"clearFilters": "Clear filters",
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
"oneShot": "One-time",
"systemTask": "System-managed automation",
"localTrigger": "Local trigger",
@@ -1412,7 +1381,7 @@
},
"workspaceScopeRejected": {
"title": "Workspace was not changed",
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
},
"turnRejected": {
"title": "Message was not sent",
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
},
"auth": {
"label": "Contraseña",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña",
"title": "Autenticación requerida",
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
"placeholder": "Contraseña",
"submit": "Conectar",
"required": "Introduce la contraseña.",
"invalid": "Contraseña incorrecta. Inténtalo de nuevo."
"invalid": "Contraseña no válida. Inténtalo de nuevo."
},
"account": {
"section": "Cuenta",
@@ -504,8 +503,8 @@
"filterInstalled": "Habilitados",
"filterNotInstalled": "No habilitados",
"searchPlaceholder": "Buscar preajustes MCP",
"moreOptions": "Añadir servidor MCP",
"moreOptionsSubtitle": "Conecta un servidor MCP personalizado o importa una configuración existente.",
"moreOptions": "Más opciones de MCP",
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
"customTitle": "MCP personalizado",
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
"customAction": "Personalizado",
@@ -513,14 +512,9 @@
"serverName": "Nombre del servidor",
"serverUrl": "URL",
"transport": "Transporte",
"authentication": "Autenticación",
"authNone": "Ninguna",
"authHeaders": "Encabezados",
"command": "Comando",
"args": "Argumentos JSON",
"headers": "Encabezados JSON",
"oauthAfterSave": "Guarda el servidor y selecciona Conectar para iniciar sesión.",
"headersHelp": "Añade los encabezados de solicitud que utiliza este servidor.",
"env": "Entorno JSON",
"timeout": "Tiempo límite de herramienta",
"advancedOptions": "Opciones avanzadas",
@@ -547,21 +541,6 @@
"keepExisting": "Déjalo en blanco para conservar el valor actual",
"statusConfigured": "Configurado",
"statusMissingCredentials": "Necesita clave",
"connectingAccount": "Conectando {{name}}",
"connectingLabel": "Conectando…",
"continueSignIn": "Continuar inicio de sesión",
"preparingSignIn": "Preparando un inicio de sesión seguro…",
"openSignInToContinue": "Abre la página de inicio de sesión para continuar.",
"finishSignInInBrowser": "Termina de iniciar sesión en la ventana del navegador.",
"manualCallbackRequired": "Termina de iniciar sesión y pega la URL de devolución en nanobot.",
"manualCallbackHelp": "Después de aprobar el acceso, la página de localhost no se cargará. Copia la URL completa de la barra de direcciones y pégala aquí.",
"finishingConnection": "Finalizando la conexión…",
"activatingTools": "Activando herramientas…",
"connected": "Conectado.",
"connectionFailed": "Error de conexión.",
"connectionCancelled": "Conexión cancelada.",
"reloadFailed": "Has iniciado sesión, pero nanobot no pudo conectar las herramientas. Prueba a reiniciar nanobot.",
"oauthFailed": "No se pudo conectar. Intenta iniciar sesión de nuevo.",
"statusMissingDependency": "Necesita dependencia",
"statusComingSoon": "Próximamente",
"comingSoon": "Próximamente",
@@ -591,28 +570,20 @@
"apps": {
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "Aplicación",
"mcpLabel": "MCP",
"mcpLabel": "Integración",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Listo",
"filterPlugins": "Complementos",
"filterCli": "Aplicaciones",
"filterMcp": "MCP",
"filterMcp": "Integraciones",
"enabledSummary": "{{count}} listos",
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
"caption": "{{cli}} aplicaciones · {{mcp}} integraciones",
"searchPlaceholder": "Buscar aplicaciones",
"featured": "Herramientas",
"mcpTools": "Herramientas MCP",
"loading": "Cargando aplicaciones...",
"empty": "Ninguna herramienta coincide con tu búsqueda.",
"emptyApps": "No hay aplicaciones disponibles.",
"emptyIntegrations": "No hay herramientas MCP disponibles.",
"emptyReady": "Todavía no hay herramientas listas.",
"clearSearch": "Borrar búsqueda",
"browseApps": "Explorar aplicaciones",
"browseIntegrations": "Explorar herramientas MCP",
"emptyIntegrationsHint": "Añade un servidor MCP personalizado abajo.",
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y herramientas MCP actualizadas."
"empty": "Ninguna herramienta coincide con esta vista.",
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
},
"channels": {
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
@@ -714,9 +685,7 @@
"loading": "Cargando automatizaciones...",
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
"empty": "Aún no hay automatizaciones.",
"emptyHint": "Crea automatizaciones en un chat para que conserven el contexto correcto.",
"emptyAction": "Abrir un chat",
"clearFilters": "Borrar filtros",
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
"oneShot": "Una vez",
"systemTask": "Automatización administrada por el sistema",
"localTrigger": "Activador local",
@@ -1399,7 +1368,7 @@
},
"workspaceScopeRejected": {
"title": "El espacio de trabajo no cambió",
"body": "El gateway rechazó este proyecto o modo de acceso. Elige un proyecto existente u otro modo de acceso e inténtalo de nuevo."
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
},
"turnRejected": {
"title": "El mensaje no se envió",
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "Assurez-vous que la gateway est en cours dexécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
},
"auth": {
"label": "Mot de passe",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"title": "Authentification requise",
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
"placeholder": "Mot de passe",
"submit": "Se connecter",
"required": "Saisissez le mot de passe.",
"invalid": "Mot de passe incorrect. Réessayez."
"invalid": "Mot de passe invalide. Réessayez."
},
"account": {
"section": "Compte",
@@ -504,8 +503,8 @@
"filterInstalled": "Activés",
"filterNotInstalled": "Non activés",
"searchPlaceholder": "Rechercher des préréglages MCP",
"moreOptions": "Ajouter un serveur MCP",
"moreOptionsSubtitle": "Connectez un serveur MCP personnalisé ou importez une configuration existante.",
"moreOptions": "Plus d'options MCP",
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
"customTitle": "MCP personnalisé",
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
"customAction": "Personnalisé",
@@ -513,14 +512,9 @@
"serverName": "Nom du serveur",
"serverUrl": "URL",
"transport": "Transport",
"authentication": "Authentification",
"authNone": "Aucune",
"authHeaders": "En-têtes",
"command": "Commande",
"args": "Arguments JSON",
"headers": "En-têtes JSON",
"oauthAfterSave": "Enregistrez le serveur, puis sélectionnez Se connecter pour vous identifier.",
"headersHelp": "Ajoutez les en-têtes de requête utilisés par ce serveur.",
"env": "Environnement JSON",
"timeout": "Délai d'outil",
"advancedOptions": "Options avancées",
@@ -547,21 +541,6 @@
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
"statusConfigured": "Configuré",
"statusMissingCredentials": "Clé requise",
"connectingAccount": "Connexion à {{name}}",
"connectingLabel": "Connexion…",
"continueSignIn": "Continuer la connexion",
"preparingSignIn": "Préparation dune connexion sécurisée…",
"openSignInToContinue": "Ouvrez la page de connexion pour continuer.",
"finishSignInInBrowser": "Terminez la connexion dans la fenêtre du navigateur.",
"manualCallbackRequired": "Terminez la connexion, puis collez lURL de rappel dans nanobot.",
"manualCallbackHelp": "Après avoir autorisé laccès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre dadresse et collez-la ici.",
"finishingConnection": "Finalisation de la connexion…",
"activatingTools": "Activation des outils…",
"connected": "Connecté.",
"connectionFailed": "Échec de la connexion.",
"connectionCancelled": "Connexion annulée.",
"reloadFailed": "Connexion réussie, mais nanobot na pas pu activer les outils. Essayez de redémarrer nanobot.",
"oauthFailed": "Connexion impossible. Essayez de vous reconnecter.",
"statusMissingDependency": "Dépendance requise",
"statusComingSoon": "Bientôt disponible",
"comingSoon": "Bientôt disponible",
@@ -590,28 +569,20 @@
"apps": {
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "Application",
"mcpLabel": "MCP",
"mcpLabel": "Intégration",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Prêts",
"filterPlugins": "Extensions",
"filterCli": "Applications",
"filterMcp": "MCP",
"filterMcp": "Intégrations",
"enabledSummary": "{{count}} prêts",
"caption": "{{cli}} applications · {{mcp}} outils MCP",
"caption": "{{cli}} applications · {{mcp}} intégrations",
"searchPlaceholder": "Rechercher des applications",
"featured": "Outils",
"mcpTools": "Outils MCP",
"loading": "Chargement des applications...",
"empty": "Aucun outil ne correspond à votre recherche.",
"emptyApps": "Aucune application disponible.",
"emptyIntegrations": "Aucun outil MCP disponible.",
"emptyReady": "Aucun outil nest encore prêt.",
"clearSearch": "Effacer la recherche",
"browseApps": "Parcourir les applications",
"browseIntegrations": "Parcourir les outils MCP",
"emptyIntegrationsHint": "Ajoutez un serveur MCP personnalisé ci-dessous.",
"restartRequired": "Redémarrez nanobot pour appliquer les applications et outils MCP mis à jour."
"empty": "Aucun outil ne correspond à cette vue.",
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
},
"channels": {
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
@@ -713,9 +684,7 @@
"loading": "Chargement des automatisations...",
"noMatches": "Aucune automatisation ne correspond à cette vue.",
"empty": "Aucune automatisation pour le moment.",
"emptyHint": "Créez les automatisations dans un chat afin de conserver le bon contexte.",
"emptyAction": "Ouvrir un chat",
"clearFilters": "Effacer les filtres",
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
"oneShot": "Ponctuelle",
"systemTask": "Automatisation gérée par le système",
"localTrigger": "Déclencheur local",
@@ -1398,7 +1367,7 @@
},
"workspaceScopeRejected": {
"title": "Lespace de travail na pas changé",
"body": "La passerelle a refusé ce projet ou ce mode daccès. Choisissez un projet existant ou un autre mode daccès, puis réessayez."
"body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
},
"turnRejected": {
"title": "Le message na pas été envoyé",
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
},
"auth": {
"label": "Kata sandi",
"showPassword": "Tampilkan kata sandi",
"hidePassword": "Sembunyikan kata sandi",
"title": "Autentikasi diperlukan",
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
"placeholder": "Kata sandi",
"submit": "Hubungkan",
"required": "Masukkan kata sandi.",
"invalid": "Kata sandi salah. Coba lagi."
"invalid": "Kata sandi tidak valid. Coba lagi."
},
"account": {
"section": "Akun",
@@ -504,8 +503,8 @@
"filterInstalled": "Aktif",
"filterNotInstalled": "Tidak aktif",
"searchPlaceholder": "Cari prasetel MCP",
"moreOptions": "Tambahkan server MCP",
"moreOptionsSubtitle": "Hubungkan server MCP khusus atau impor konfigurasi yang ada.",
"moreOptions": "Opsi MCP lainnya",
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
"customTitle": "MCP khusus",
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
"customAction": "Khusus",
@@ -513,14 +512,9 @@
"serverName": "Nama server",
"serverUrl": "URL",
"transport": "Transport",
"authentication": "Autentikasi",
"authNone": "Tidak ada",
"authHeaders": "Header",
"command": "Perintah",
"args": "Argumen JSON",
"headers": "Header JSON",
"oauthAfterSave": "Simpan server, lalu pilih Hubungkan untuk masuk.",
"headersHelp": "Tambahkan header permintaan yang digunakan server ini.",
"env": "Lingkungan JSON",
"timeout": "Batas waktu alat",
"advancedOptions": "Opsi lanjutan",
@@ -547,21 +541,6 @@
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
"statusConfigured": "Terkonfigurasi",
"statusMissingCredentials": "Butuh kunci",
"connectingAccount": "Menghubungkan {{name}}",
"connectingLabel": "Menghubungkan…",
"continueSignIn": "Lanjutkan masuk",
"preparingSignIn": "Menyiapkan proses masuk yang aman…",
"openSignInToContinue": "Buka halaman masuk untuk melanjutkan.",
"finishSignInInBrowser": "Selesaikan proses masuk di jendela browser.",
"manualCallbackRequired": "Selesaikan proses masuk, lalu tempel URL callback ke nanobot.",
"manualCallbackHelp": "Setelah menyetujui akses, halaman localhost tidak akan terbuka. Salin URL lengkap dari bilah alamat lalu tempel di sini.",
"finishingConnection": "Menyelesaikan koneksi…",
"activatingTools": "Mengaktifkan alat…",
"connected": "Terhubung.",
"connectionFailed": "Koneksi gagal.",
"connectionCancelled": "Koneksi dibatalkan.",
"reloadFailed": "Anda sudah masuk, tetapi nanobot tidak dapat menghubungkan alat. Coba mulai ulang nanobot.",
"oauthFailed": "Tidak dapat terhubung. Coba masuk lagi.",
"statusMissingDependency": "Butuh dependensi",
"statusComingSoon": "Segera hadir",
"comingSoon": "Segera hadir",
@@ -590,28 +569,20 @@
"apps": {
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi",
"mcpLabel": "MCP",
"mcpLabel": "Integrasi",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Siap",
"filterPlugins": "Plugin",
"filterCli": "Aplikasi",
"filterMcp": "MCP",
"filterMcp": "Integrasi",
"enabledSummary": "{{count}} siap",
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
"searchPlaceholder": "Cari aplikasi",
"featured": "Alat",
"mcpTools": "Alat MCP",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
"emptyApps": "Tidak ada aplikasi yang tersedia.",
"emptyIntegrations": "Tidak ada alat MCP yang tersedia.",
"emptyReady": "Belum ada alat yang siap.",
"clearSearch": "Hapus pencarian",
"browseApps": "Jelajahi aplikasi",
"browseIntegrations": "Jelajahi alat MCP",
"emptyIntegrationsHint": "Tambahkan server MCP khusus di bawah.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan alat MCP yang diperbarui."
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
},
"channels": {
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
@@ -713,9 +684,7 @@
"loading": "Memuat otomasi...",
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
"empty": "Belum ada otomasi.",
"emptyHint": "Buat otomatisasi di chat agar konteks yang tepat tetap tersimpan.",
"emptyAction": "Buka chat",
"clearFilters": "Hapus filter",
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
"oneShot": "Satu kali",
"systemTask": "Automasi yang dikelola sistem",
"localTrigger": "Pemicu lokal",
@@ -1398,7 +1367,7 @@
},
"workspaceScopeRejected": {
"title": "Ruang kerja tidak berubah",
"body": "Gateway menolak proyek atau mode akses ini. Pilih proyek yang sudah ada atau mode akses lain, lalu coba lagi."
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
},
"turnRejected": {
"title": "Pesan tidak terkirim",
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "gateway`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
},
"auth": {
"label": "パスワード",
"showPassword": "パスワードを表示",
"hidePassword": "パスワードを隠す",
"title": "認証が必要です",
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
"placeholder": "パスワード",
"submit": "接続",
"required": "パスワードを入力してください。",
"invalid": "パスワードが正しくありません。もう一度お試しください。"
"invalid": "パスワードが無効です。もう一度お試しください。"
},
"account": {
"section": "アカウント",
@@ -504,8 +503,8 @@
"filterInstalled": "有効",
"filterNotInstalled": "未有効",
"searchPlaceholder": "MCP プリセットを検索",
"moreOptions": "MCP サーバーを追加",
"moreOptionsSubtitle": "カスタム MCP サーバーを接続するか、既存の設定をインポートします。",
"moreOptions": "その他の MCP オプション",
"moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
"customTitle": "カスタム MCP",
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
"customAction": "カスタム",
@@ -513,14 +512,9 @@
"serverName": "サーバー名",
"serverUrl": "URL",
"transport": "トランスポート",
"authentication": "認証",
"authNone": "なし",
"authHeaders": "ヘッダー",
"command": "コマンド",
"args": "引数 JSON",
"headers": "ヘッダー JSON",
"oauthAfterSave": "サーバーを保存してから、[接続]を選択してサインインします。",
"headersHelp": "このサーバーで使用するリクエストヘッダーを追加します。",
"env": "環境変数 JSON",
"timeout": "ツールのタイムアウト",
"advancedOptions": "詳細オプション",
@@ -547,21 +541,6 @@
"keepExisting": "既存の値を維持するには空欄のままにします",
"statusConfigured": "設定済み",
"statusMissingCredentials": "キーが必要",
"connectingAccount": "{{name}} に接続しています",
"connectingLabel": "接続中…",
"continueSignIn": "サインインを続ける",
"preparingSignIn": "安全なサインインを準備しています…",
"openSignInToContinue": "サインインページを開いて続行してください。",
"finishSignInInBrowser": "ブラウザウィンドウでサインインを完了してください。",
"manualCallbackRequired": "サインインを完了し、コールバック URL を nanobot に貼り付けてください。",
"manualCallbackHelp": "アクセスを承認すると localhost ページは開きません。アドレスバーから完全な URL をコピーして、ここに貼り付けてください。",
"finishingConnection": "接続を完了しています…",
"activatingTools": "ツールを有効にしています…",
"connected": "接続しました。",
"connectionFailed": "接続に失敗しました。",
"connectionCancelled": "接続をキャンセルしました。",
"reloadFailed": "サインインしましたが、nanobot はツールに接続できませんでした。nanobot を再起動してください。",
"oauthFailed": "接続できません。もう一度サインインしてください。",
"statusMissingDependency": "依存関係が必要",
"statusComingSoon": "近日公開",
"comingSoon": "近日公開",
@@ -590,28 +569,20 @@
"apps": {
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ",
"mcpLabel": "MCP",
"mcpLabel": "連携",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "使用可能",
"filterPlugins": "プラグイン",
"filterCli": "アプリ",
"filterMcp": "MCP",
"filterMcp": "連携",
"enabledSummary": "{{count}} 件使用可能",
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
"searchPlaceholder": "アプリを検索",
"featured": "ツール",
"mcpTools": "MCP ツール",
"loading": "アプリを読み込み中...",
"empty": "検索条件に一致するツールはありません。",
"emptyApps": "利用できるアプリはありません。",
"emptyIntegrations": "利用できる MCP ツールはありません。",
"emptyReady": "使用可能なツールはまだありません。",
"clearSearch": "検索をクリア",
"browseApps": "アプリを見る",
"browseIntegrations": "MCP ツールを見る",
"emptyIntegrationsHint": "下からカスタム MCP サーバーを追加できます。",
"restartRequired": "更新したアプリと MCP ツールを反映するには nanobot を再起動してください。"
"empty": "この表示に一致するツールはありません。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
},
"channels": {
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
@@ -713,9 +684,7 @@
"loading": "自動タスクを読み込み中...",
"noMatches": "この表示に一致する自動タスクはありません。",
"empty": "自動タスクはまだありません。",
"emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
"emptyAction": "チャットを開く",
"clearFilters": "フィルターをクリア",
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
"oneShot": "一回限り",
"systemTask": "システム管理の自動タスク",
"localTrigger": "ローカルトリガー",
@@ -1398,7 +1367,7 @@
},
"workspaceScopeRejected": {
"title": "ワークスペースは変更されませんでした",
"body": "このプロジェクトまたはアクセスモードゲートウェイ拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
"body": "要求されたプロジェクトまたはアクセスモードゲートウェイ拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
},
"turnRejected": {
"title": "メッセージは送信されませんでした",
+12 -43
View File
@@ -10,11 +10,10 @@
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
},
"auth": {
"label": "비밀번호",
"showPassword": "비밀번호 표시",
"hidePassword": "비밀번호 숨기기",
"title": "인증이 필요합니다",
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
"placeholder": "비밀번호",
"submit": "연결",
"required": "비밀번호를 입력하세요.",
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
},
"account": {
@@ -504,8 +503,8 @@
"filterInstalled": "활성화됨",
"filterNotInstalled": "비활성",
"searchPlaceholder": "MCP 프리셋 검색",
"moreOptions": "MCP 서버 추가",
"moreOptionsSubtitle": "사용자 지정 MCP 서버를 연결하거나 기존 구성을 가져옵니다.",
"moreOptions": "추가 MCP 옵션",
"moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
"customTitle": "사용자 지정 MCP",
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
"customAction": "사용자 지정",
@@ -513,14 +512,9 @@
"serverName": "서버 이름",
"serverUrl": "URL",
"transport": "전송 방식",
"authentication": "인증",
"authNone": "없음",
"authHeaders": "헤더",
"command": "명령",
"args": "인자 JSON",
"headers": "헤더 JSON",
"oauthAfterSave": "서버를 저장한 다음 연결을 선택하여 로그인하세요.",
"headersHelp": "이 서버에서 사용하는 요청 헤더를 추가하세요.",
"env": "환경 변수 JSON",
"timeout": "도구 제한 시간",
"advancedOptions": "고급 옵션",
@@ -547,21 +541,6 @@
"keepExisting": "기존 값을 유지하려면 비워 두세요",
"statusConfigured": "구성됨",
"statusMissingCredentials": "키 필요",
"connectingAccount": "{{name}} 연결 중",
"connectingLabel": "연결 중…",
"continueSignIn": "로그인 계속",
"preparingSignIn": "안전한 로그인을 준비하는 중…",
"openSignInToContinue": "계속하려면 로그인 페이지를 여세요.",
"finishSignInInBrowser": "브라우저 창에서 로그인을 완료하세요.",
"manualCallbackRequired": "로그인을 완료한 다음 콜백 URL을 nanobot에 붙여 넣으세요.",
"manualCallbackHelp": "접근을 승인하면 localhost 페이지가 열리지 않습니다. 주소 표시줄에서 전체 URL을 복사해 여기에 붙여 넣으세요.",
"finishingConnection": "연결을 마무리하는 중…",
"activatingTools": "도구를 활성화하는 중…",
"connected": "연결됨.",
"connectionFailed": "연결에 실패했습니다.",
"connectionCancelled": "연결을 취소했습니다.",
"reloadFailed": "로그인했지만 nanobot에서 도구를 연결하지 못했습니다. nanobot을 다시 시작해 보세요.",
"oauthFailed": "연결할 수 없습니다. 다시 로그인해 보세요.",
"statusMissingDependency": "의존성 필요",
"statusComingSoon": "곧 제공",
"comingSoon": "곧 제공",
@@ -590,28 +569,20 @@
"apps": {
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "앱",
"mcpLabel": "MCP",
"mcpLabel": "연동",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "사용 가능",
"filterPlugins": "플러그인",
"filterCli": "앱",
"filterMcp": "MCP",
"filterMcp": "연동",
"enabledSummary": "{{count}}개 사용 가능",
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
"searchPlaceholder": "앱 검색",
"featured": "도구",
"mcpTools": "MCP 도구",
"loading": "앱을 불러오는 중...",
"empty": "검색과 일치하는 도구가 없습니다.",
"emptyApps": "사용 가능한 앱이 없습니다.",
"emptyIntegrations": "사용 가능한 MCP 도구가 없습니다.",
"emptyReady": "아직 준비된 도구가 없습니다.",
"clearSearch": "검색 지우기",
"browseApps": "앱 둘러보기",
"browseIntegrations": "MCP 도구 둘러보기",
"emptyIntegrationsHint": "아래에서 사용자 지정 MCP 서버를 추가하세요.",
"restartRequired": "업데이트된 앱과 MCP 도구를 적용하려면 nanobot을 다시 시작하세요."
"empty": "이 보기에 일치하는 도구가 없습니다.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
},
"channels": {
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
@@ -713,9 +684,7 @@
"loading": "자동화를 불러오는 중...",
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
"empty": "아직 자동화가 없습니다.",
"emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
"emptyAction": "채팅 열기",
"clearFilters": "필터 지우기",
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
"oneShot": "일회성",
"systemTask": "시스템 관리 자동화",
"localTrigger": "로컬 트리거",
@@ -1398,7 +1367,7 @@
},
"workspaceScopeRejected": {
"title": "작업공간이 변경되지 않았습니다",
"body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
},
"turnRejected": {
"title": "메시지가 전송되지 않았습니다",
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
},
"auth": {
"label": "Senha",
"showPassword": "Mostrar senha",
"hidePassword": "Ocultar senha",
"title": "Autenticação necessária",
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.",
"placeholder": "Senha",
"submit": "Conectar",
"required": "Digite a senha.",
"invalid": "Senha incorreta. Tente novamente."
"invalid": "Senha inválida. Tente novamente."
},
"account": {
"section": "Conta",
@@ -319,8 +318,8 @@
"filterInstalled": "Habilitadas",
"filterNotInstalled": "Não habilitadas",
"searchPlaceholder": "Buscar predefinições MCP",
"moreOptions": "Adicionar servidor MCP",
"moreOptionsSubtitle": "Conecte um servidor MCP personalizado ou importe uma configuração existente.",
"moreOptions": "Adicionar integração",
"moreOptionsSubtitle": "Conecte um servidor de ferramentas personalizado ou importe uma configuração existente.",
"customTitle": "MCP personalizado",
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
"customAction": "Personalizado",
@@ -328,14 +327,9 @@
"serverName": "Nome do servidor",
"serverUrl": "URL",
"transport": "Transporte",
"authentication": "Autenticação",
"authNone": "Nenhuma",
"authHeaders": "Cabeçalhos",
"command": "Comando",
"args": "Argumentos JSON",
"headers": "Cabeçalhos JSON",
"oauthAfterSave": "Salve o servidor e selecione Conectar para entrar.",
"headersHelp": "Adicione os cabeçalhos de solicitação usados por este servidor.",
"env": "Ambiente JSON",
"timeout": "Tempo limite da ferramenta",
"advancedOptions": "Opções avançadas",
@@ -362,21 +356,6 @@
"keepExisting": "Deixe em branco para manter o valor atual",
"statusConfigured": "Configurado",
"statusMissingCredentials": "Precisa de chave",
"connectingAccount": "Conectando {{name}}",
"connectingLabel": "Conectando…",
"continueSignIn": "Continuar login",
"preparingSignIn": "Preparando login seguro…",
"openSignInToContinue": "Abra a página de login para continuar.",
"finishSignInInBrowser": "Conclua o login na janela do navegador.",
"manualCallbackRequired": "Conclua o login e cole a URL de callback no nanobot.",
"manualCallbackHelp": "Depois de autorizar o acesso, a página localhost não será carregada. Copie a URL completa da barra de endereços e cole-a aqui.",
"finishingConnection": "Finalizando conexão…",
"activatingTools": "Ativando ferramentas…",
"connected": "Conectado.",
"connectionFailed": "Falha na conexão.",
"connectionCancelled": "Conexão cancelada.",
"reloadFailed": "Login concluído, mas o nanobot não conseguiu conectar as ferramentas. Tente reiniciar o nanobot.",
"oauthFailed": "Não foi possível conectar. Tente fazer login novamente.",
"statusMissingDependency": "Precisa de dependência",
"statusComingSoon": "Em breve",
"comingSoon": "Em breve",
@@ -604,28 +583,20 @@
"apps": {
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"cliLabel": "Aplicativo",
"mcpLabel": "MCP",
"mcpLabel": "Integração",
"channelLabel": "Canal",
"featureLabel": "Recurso",
"filterAll": "Prontos",
"filterPlugins": "Complementos",
"filterCli": "Aplicativos",
"filterMcp": "MCP",
"filterMcp": "Integrações",
"enabledSummary": "{{count}} prontos",
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
"caption": "{{cli}} aplicativos · {{mcp}} integrações",
"searchPlaceholder": "Buscar ferramentas",
"featured": "Ferramentas",
"mcpTools": "Ferramentas MCP",
"loading": "Carregando aplicativos...",
"empty": "Nenhuma ferramenta corresponde à sua busca.",
"emptyApps": "Nenhum aplicativo disponível.",
"emptyIntegrations": "Nenhuma ferramenta MCP disponível.",
"emptyReady": "Ainda não há ferramentas prontas.",
"clearSearch": "Limpar busca",
"browseApps": "Explorar aplicativos",
"browseIntegrations": "Explorar ferramentas MCP",
"emptyIntegrationsHint": "Adicione um servidor MCP personalizado abaixo.",
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e ferramentas MCP atualizados."
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
},
"channels": {
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
@@ -727,9 +698,7 @@
"loading": "Carregando automações...",
"noMatches": "Nenhuma automação corresponde a esta visualização.",
"empty": "Nenhuma automação ainda.",
"emptyHint": "Crie automações em uma conversa para que mantenham o contexto correto.",
"emptyAction": "Abrir uma conversa",
"clearFilters": "Limpar filtros",
"emptyHint": "Crie uma de onde ela deve rodar para que o nanobot mantenha o contexto correto.",
"oneShot": "Uma vez",
"systemTask": "Automação gerenciada pelo sistema",
"localTrigger": "Gatilho local",
@@ -1412,7 +1381,7 @@
},
"workspaceScopeRejected": {
"title": "O espaço de trabalho não foi alterado",
"body": "O gateway rejeitou este projeto ou modo de acesso. Escolha um projeto existente ou outro modo de acesso e tente novamente."
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
},
"turnRejected": {
"title": "A mensagem não foi enviada",
+13 -44
View File
@@ -10,12 +10,11 @@
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
},
"auth": {
"label": "Mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"title": "Cần xác thực",
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
"placeholder": "Mật khẩu",
"submit": "Kết nối",
"required": "Nhập mật khẩu.",
"invalid": "Mật khẩu không đúng. Hãy thử lại."
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
},
"account": {
"section": "Tài khoản",
@@ -504,8 +503,8 @@
"filterInstalled": "Đã bật",
"filterNotInstalled": "Chưa bật",
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
"moreOptions": "Thêm máy ch MCP",
"moreOptionsSubtitle": "Kết nối máy chủ MCP tùy chỉnh hoặc nhập cấu hình hiện có.",
"moreOptions": "Tùy chọn MCP khác",
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
"customTitle": "MCP tùy chỉnh",
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
"customAction": "Tùy chỉnh",
@@ -513,14 +512,9 @@
"serverName": "Tên máy chủ",
"serverUrl": "URL",
"transport": "Giao thức truyền",
"authentication": "Xác thực",
"authNone": "Không có",
"authHeaders": "Header",
"command": "Lệnh",
"args": "Đối số JSON",
"headers": "Header JSON",
"oauthAfterSave": "Lưu máy chủ, sau đó chọn Kết nối để đăng nhập.",
"headersHelp": "Thêm các header yêu cầu mà máy chủ này sử dụng.",
"env": "Môi trường JSON",
"timeout": "Thời gian chờ công cụ",
"advancedOptions": "Tùy chọn nâng cao",
@@ -547,21 +541,6 @@
"keepExisting": "Để trống để giữ giá trị hiện tại",
"statusConfigured": "Đã cấu hình",
"statusMissingCredentials": "Cần khóa",
"connectingAccount": "Đang kết nối {{name}}",
"connectingLabel": "Đang kết nối…",
"continueSignIn": "Tiếp tục đăng nhập",
"preparingSignIn": "Đang chuẩn bị đăng nhập an toàn…",
"openSignInToContinue": "Mở trang đăng nhập để tiếp tục.",
"finishSignInInBrowser": "Hoàn tất đăng nhập trong cửa sổ trình duyệt.",
"manualCallbackRequired": "Hoàn tất đăng nhập, rồi dán URL callback vào nanobot.",
"manualCallbackHelp": "Sau khi phê duyệt quyền truy cập, trang localhost sẽ không tải được. Hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào đây.",
"finishingConnection": "Đang hoàn tất kết nối…",
"activatingTools": "Đang kích hoạt công cụ…",
"connected": "Đã kết nối.",
"connectionFailed": "Kết nối thất bại.",
"connectionCancelled": "Đã hủy kết nối.",
"reloadFailed": "Đã đăng nhập nhưng nanobot không thể kết nối các công cụ. Hãy thử khởi động lại nanobot.",
"oauthFailed": "Không thể kết nối. Hãy thử đăng nhập lại.",
"statusMissingDependency": "Cần phụ thuộc",
"statusComingSoon": "Sắp ra mắt",
"comingSoon": "Sắp ra mắt",
@@ -590,28 +569,20 @@
"apps": {
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"cliLabel": "Ứng dụng",
"mcpLabel": "MCP",
"mcpLabel": "Tích hợp",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Sẵn sàng",
"filterPlugins": "Plugin",
"filterCli": "Ứng dụng",
"filterMcp": "MCP",
"filterMcp": "Tích hợp",
"enabledSummary": "{{count}} sẵn sàng",
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Công cụ",
"mcpTools": "Công cụ MCP",
"loading": "Đang tải ứng dụng...",
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
"emptyApps": "Không có ứng dụng nào.",
"emptyIntegrations": "Không có công cụ MCP nào.",
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
"clearSearch": "Xóa tìm kiếm",
"browseApps": "Xem ứng dụng",
"browseIntegrations": "Xem công cụ MCP",
"emptyIntegrationsHint": "Thêm máy chủ MCP tùy chỉnh ở bên dưới.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và công cụ MCP đã cập nhật."
"empty": "Không có công cụ phù hợp với chế độ xem này.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
},
"channels": {
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
@@ -713,9 +684,7 @@
"loading": "Đang tải tự động hóa...",
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
"empty": "Chưa có tự động hóa.",
"emptyHint": "Tạo tác vụ tự động trong cuộc trò chuyện để giữ đúng ngữ cảnh.",
"emptyAction": "Mở cuộc trò chuyện",
"clearFilters": "Xóa bộ lọc",
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
"oneShot": "Một lần",
"systemTask": "Tự động hóa do hệ thống quản lý",
"localTrigger": "Trình kích hoạt cục bộ",
@@ -1398,7 +1367,7 @@
},
"workspaceScopeRejected": {
"title": "Không gian làm việc không thay đổi",
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập này. Chọn dự án hiện có hoặc chế độ truy cập khác rồi thử lại."
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ không gian làm việc trước đó."
},
"turnRejected": {
"title": "Tin nhắn chưa được gửi",
+14 -45
View File
@@ -10,12 +10,11 @@
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
},
"auth": {
"label": "密码",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"title": "需要验证",
"hint": "请输入网关配置中的 tokenIssueSecret。",
"placeholder": "密码",
"submit": "连接",
"required": "请输入密码。",
"invalid": "密码错误,请重试。"
"invalid": "密码无效,请重试。"
},
"account": {
"section": "账户",
@@ -319,8 +318,8 @@
"filterInstalled": "已启用",
"filterNotInstalled": "未启用",
"searchPlaceholder": "搜索 MCP 预设",
"moreOptions": "添加 MCP 服务",
"moreOptionsSubtitle": "连接自定义 MCP 服务,或导入已有配置。",
"moreOptions": "添加集成",
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
"customTitle": "自定义 MCP",
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
"customAction": "自定义",
@@ -328,14 +327,9 @@
"serverName": "服务名",
"serverUrl": "URL",
"transport": "传输方式",
"authentication": "身份验证",
"authNone": "无",
"authHeaders": "请求头",
"command": "命令",
"args": "Args JSON",
"headers": "请求头 JSON",
"oauthAfterSave": "保存服务器后,选择“连接”以完成登录。",
"headersHelp": "添加此服务器要求的请求头。",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "工具超时",
"advancedOptions": "高级选项",
@@ -362,21 +356,6 @@
"keepExisting": "留空则保留当前值",
"statusConfigured": "已配置",
"statusMissingCredentials": "需要密钥",
"connectingAccount": "正在连接 {{name}}",
"connectingLabel": "正在连接…",
"continueSignIn": "继续登录",
"preparingSignIn": "正在准备安全登录…",
"openSignInToContinue": "打开登录页面以继续。",
"finishSignInInBrowser": "请在浏览器窗口中完成登录。",
"manualCallbackRequired": "完成登录后,将回调 URL 粘贴到 nanobot。",
"manualCallbackHelp": "授权后,localhost 页面将无法打开。请复制地址栏中的完整 URL 并粘贴到这里。",
"finishingConnection": "正在完成连接…",
"activatingTools": "正在启用工具…",
"connected": "已连接。",
"connectionFailed": "连接失败。",
"connectionCancelled": "已取消连接。",
"reloadFailed": "已登录,但 nanobot 无法连接这些工具。请尝试重启 nanobot。",
"oauthFailed": "无法连接,请重新登录。",
"statusMissingDependency": "缺少依赖",
"statusComingSoon": "暂不支持",
"comingSoon": "即将推出",
@@ -604,28 +583,20 @@
"apps": {
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"cliLabel": "应用",
"mcpLabel": "MCP",
"mcpLabel": "集成",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "可用",
"filterPlugins": "插件",
"filterCli": "应用",
"filterMcp": "MCP",
"filterMcp": "集成",
"enabledSummary": "{{count}} 个可用",
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
"searchPlaceholder": "搜索工具",
"featured": "工具",
"mcpTools": "MCP 工具",
"loading": "正在加载应用...",
"empty": "没有与搜索条件匹配的工具。",
"emptyApps": "暂无可用应用。",
"emptyIntegrations": "暂无可用 MCP 工具。",
"emptyReady": "还没有就绪的工具。",
"clearSearch": "清除搜索",
"browseApps": "浏览应用",
"browseIntegrations": "浏览 MCP 工具",
"emptyIntegrationsHint": "可在下方添加自定义 MCP 服务器。",
"restartRequired": "重启 nanobot 以应用更新后的应用和 MCP 工具。"
"empty": "当前视图没有匹配的工具。",
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
},
"channels": {
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
@@ -727,9 +698,7 @@
"loading": "正在加载自动任务...",
"noMatches": "当前视图没有匹配的自动任务。",
"empty": "暂无自动任务。",
"emptyHint": "请在对话中创建自动任务,以便保留正确上下文。",
"emptyAction": "打开对话",
"clearFilters": "清除筛选",
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
"oneShot": "一次性",
"systemTask": "系统管理的自动任务",
"localTrigger": "本地触发器",
@@ -1412,7 +1381,7 @@
},
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
},
"turnRejected": {
"title": "消息未发送",
+14 -45
View File
@@ -10,12 +10,11 @@
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
},
"auth": {
"label": "密碼",
"showPassword": "顯示密碼",
"hidePassword": "隱藏密碼",
"title": "需要驗證",
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。",
"placeholder": "密碼",
"submit": "連線",
"required": "請輸入密碼。",
"invalid": "密碼錯誤,請再試一次。"
"invalid": "密碼無效,請再試一次。"
},
"account": {
"section": "帳戶",
@@ -504,8 +503,8 @@
"filterInstalled": "已啟用",
"filterNotInstalled": "未啟用",
"searchPlaceholder": "搜尋 MCP 預設",
"moreOptions": "新增 MCP 服務",
"moreOptionsSubtitle": "連線自訂 MCP 服務,或匯入現有設定。",
"moreOptions": "新增整合",
"moreOptionsSubtitle": "連線自訂工具伺服器,或匯入現有設定。",
"customTitle": "自訂 MCP",
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
"customAction": "自訂",
@@ -513,14 +512,9 @@
"serverName": "伺服器名稱",
"serverUrl": "URL",
"transport": "傳輸方式",
"authentication": "驗證方式",
"authNone": "無",
"authHeaders": "請求標頭",
"command": "指令",
"args": "Args JSON",
"headers": "請求標頭 JSON",
"oauthAfterSave": "儲存伺服器後,選擇「連線」以登入。",
"headersHelp": "新增此伺服器使用的請求標頭。",
"headers": "Headers JSON",
"env": "Env JSON",
"timeout": "工具逾時",
"advancedOptions": "進階選項",
@@ -547,21 +541,6 @@
"keepExisting": "留空以保留目前值",
"statusConfigured": "已設定",
"statusMissingCredentials": "需要金鑰",
"connectingAccount": "正在連接 {{name}}",
"connectingLabel": "正在連線…",
"continueSignIn": "繼續登入",
"preparingSignIn": "正在準備安全登入…",
"openSignInToContinue": "開啟登入頁面以繼續。",
"finishSignInInBrowser": "請在瀏覽器視窗中完成登入。",
"manualCallbackRequired": "完成登入後,將回呼 URL 貼到 nanobot。",
"manualCallbackHelp": "授權後,localhost 頁面將無法開啟。請複製網址列中的完整 URL 並貼到這裡。",
"finishingConnection": "正在完成連線…",
"activatingTools": "正在啟用工具…",
"connected": "已連線。",
"connectionFailed": "連線失敗。",
"connectionCancelled": "已取消連線。",
"reloadFailed": "已登入,但 nanobot 無法連接這些工具。請嘗試重新啟動 nanobot。",
"oauthFailed": "無法連線,請重新登入。",
"statusMissingDependency": "需要相依項",
"statusComingSoon": "即將推出",
"comingSoon": "即將推出",
@@ -590,28 +569,20 @@
"apps": {
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"cliLabel": "應用程式",
"mcpLabel": "MCP",
"mcpLabel": "整合",
"channelLabel": "通訊管道",
"featureLabel": "功能",
"filterAll": "就緒",
"filterPlugins": "外掛程式",
"filterCli": "應用程式",
"filterMcp": "MCP",
"filterMcp": "整合",
"enabledSummary": "{{count}} 個就緒",
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
"caption": "{{cli}} 個應用程式 · {{mcp}} 個整合服務",
"searchPlaceholder": "搜尋工具",
"featured": "工具",
"mcpTools": "MCP 工具",
"loading": "正在載入應用程式…",
"empty": "沒有符合搜尋條件的工具。",
"emptyApps": "沒有可用的應用程式。",
"emptyIntegrations": "沒有可用的 MCP 工具。",
"emptyReady": "尚無就緒的工具。",
"clearSearch": "清除搜尋",
"browseApps": "瀏覽應用程式",
"browseIntegrations": "瀏覽 MCP 工具",
"emptyIntegrationsHint": "可在下方新增自訂 MCP 伺服器。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與 MCP 工具。"
"empty": "沒有符合條件的工具。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
},
"channels": {
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
@@ -713,9 +684,7 @@
"loading": "正在載入自動任務…",
"noMatches": "目前沒有符合條件的自動任務。",
"empty": "尚無自動任務。",
"emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
"emptyAction": "開啟聊天",
"clearFilters": "清除篩選",
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。",
"oneShot": "單次",
"systemTask": "系統管理的自動任務",
"localTrigger": "本機觸發器",
@@ -1398,7 +1367,7 @@
},
"workspaceScopeRejected": {
"title": "工作區未變更",
"body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
},
"turnRejected": {
"title": "訊息未傳送",
+362 -329
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -9,9 +9,7 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
}
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
return payload.presets.filter(
(preset) => preset.enabled ?? (preset.installed && preset.configured),
);
return payload.presets.filter((preset) => preset.installed && preset.configured);
}
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
+2 -91
View File
@@ -108,16 +108,6 @@ interface PendingRequest<T> {
timer: ReturnType<typeof setTimeout>;
}
export class WebUIMutationError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "WebUIMutationError";
}
}
interface PendingChatRequest extends PendingRequest<string> {
temporary: boolean;
}
@@ -213,7 +203,6 @@ export class NanobotClient {
private pendingNewChat: PendingChatRequest | null = null;
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>();
// Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = [];
private reconnectAttempts = 0;
@@ -818,60 +807,6 @@ export class NanobotClient {
});
}
/**
* Send one non-replayable WebUI mutation over the authenticated socket.
* A client-side timeout only abandons the reply; the server may finish work
* that already started, so timed-out requests are never retried automatically.
*/
requestMutation<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs: number = 20_000,
): Promise<T> {
const socket = this.socket;
if (!socket || socket.readyState !== WS_OPEN) {
return Promise.reject(
new WebUIMutationError(503, "WebUI connection is not open"),
);
}
const requestId = crypto.randomUUID();
const frame: Outbound = {
type: "webui_request",
request_id: requestId,
action,
payload,
};
if (!this.frameFitsTransport(frame)) {
return Promise.reject(
new WebUIMutationError(413, "WebUI mutation payload is too large"),
);
}
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingWebUIRequests.delete(requestId);
reject(
new WebUIMutationError(
504,
`WebUI request timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
this.pendingWebUIRequests.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
});
try {
socket.send(JSON.stringify(frame));
} catch {
clearTimeout(timer);
this.pendingWebUIRequests.delete(requestId);
reject(new WebUIMutationError(503, "Could not send WebUI request"));
}
});
}
/** Ask the server to create a non-destructive fork before a user-message index. */
forkChat(
sourceChatId: string,
@@ -979,8 +914,8 @@ export class NanobotClient {
});
}
setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
setSidebarState(state: SidebarStatePayload): void {
this.queueSend({ type: "set_sidebar_state", state });
}
// -- internals ---------------------------------------------------------
@@ -1030,23 +965,6 @@ export class NanobotClient {
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
}
if (parsed.event === "webui_response") {
const pending = this.pendingWebUIRequests.get(parsed.request_id);
if (!pending) return;
clearTimeout(pending.timer);
this.pendingWebUIRequests.delete(parsed.request_id);
if (parsed.ok) {
pending.resolve(parsed.result);
} else {
const status = Number.isFinite(parsed.error?.status)
? parsed.error.status
: 500;
const message = parsed.error?.message || "WebUI mutation failed";
pending.reject(new WebUIMutationError(status, message));
}
return;
}
if (parsed.event === "error" && !parsed.turn_id) {
const fallback = this.legacyRejectionTarget(parsed);
if (fallback) {
@@ -1233,13 +1151,6 @@ export class NanobotClient {
this.pendingNewChat = null;
}
this.rejectAllTranscriptions("socket closed");
for (const pending of this.pendingWebUIRequests.values()) {
clearTimeout(pending.timer);
pending.reject(
new WebUIMutationError(503, "Socket closed before WebUI response"),
);
}
this.pendingWebUIRequests.clear();
for (const pending of this.pendingSystemCommands.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("socket closed"));
-357
View File
@@ -1,357 +0,0 @@
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
import type {
ToolProgressEvent,
UIFileEdit,
UIMessage,
UITurnPhase,
} from "@/lib/types";
export type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
/**
* PR3 projection seam: replay can share these folds once GatewayContext exposes
* an ordered canonical-event sequence and a monotonic per-thread revision.
* Snapshot acceptance and revision comparison stay outside this projection;
* until then, history continues to consume server-projected UIMessage snapshots.
*/
export function turnFieldsFromEvent(
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
fallbackPhase?: UITurnPhase,
): UIMessageTurnFields {
const fields: UIMessageTurnFields = {};
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
fields.turnId = ev.turn_id;
}
const phase = ev.turn_phase ?? fallbackPhase;
if (phase) fields.turnPhase = phase;
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
fields.turnSeq = ev.turn_seq;
}
return fields;
}
export function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
}
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
* as streaming until ``turn_end`` for visual continuity, but they must not
* receive later delta segments. */
export function findStreamingAssistantIndex(
prev: UIMessage[],
closedStreamIds: ReadonlySet<string>,
turn: UIMessageTurnFields = {},
): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (
m.role === "assistant"
&& m.isStreaming
&& !closedStreamIds.has(m.id)
&& matchesTurn(m, turn)
) return i;
if (m.role === "user") break;
}
return null;
}
/**
* Find the most recent assistant placeholder that an incoming answer
* delta should adopt instead of spawning a parallel row.
*/
export function findActiveAssistantPlaceholderIndex(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
if (!matchesTurn(last, turn)) return null;
return prev.length - 1;
}
export function replaceMessageAt(
prev: UIMessage[],
index: number,
message: UIMessage,
): UIMessage[] {
const next = prev.slice();
next[index] = message;
return next;
}
/** Close the active reasoning stream segment. ``now`` is supplied by the caller
* so the projection remains deterministic for replay and fixture tests. */
export function closeReasoningStream(prev: UIMessage[], now: number): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (!candidate.reasoningStreaming) continue;
const latencyMs =
candidate.latencyMs === undefined
&& Number.isFinite(candidate.createdAt)
&& candidate.createdAt > 1_000_000_000_000
? Math.max(0, Math.round(now - candidate.createdAt))
: candidate.latencyMs;
const merged: UIMessage = {
...candidate,
reasoningStreaming: false,
...(latencyMs !== undefined ? { latencyMs } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
return prev;
}
export function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length === 0
&& !!message.reasoning
&& !message.reasoningStreaming
&& !message.media?.length
);
}
function isToolTrace(message: UIMessage | undefined): boolean {
return message?.kind === "trace";
}
export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
return prev.filter((message, index) => {
if (!isReasoningOnlyPlaceholder(message)) return true;
// A reasoning-only assistant row immediately followed by tool traces is
// the live equivalent of a persisted assistant tool-call message with
// empty content, reasoning_content, and tool_calls. Keep it so live render
// and history replay stay isomorphic.
return isToolTrace(prev[index + 1]);
});
}
export function stampLastAssistantCompletion(
prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
turnId?: string,
): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (
m.role === "assistant"
&& m.kind !== "trace"
&& (!turnId || !m.turnId || m.turnId === turnId)
) {
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
}
return prev;
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
}
function fileEditToolEventKey(
edit: Pick<UIFileEdit, "call_id" | "tool" | "path">,
): string {
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return fileEditKey(edit);
}
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
const fn = (event as { function?: { name?: unknown } }).function;
const name = typeof event.name === "string"
? event.name
: typeof fn?.name === "string"
? fn.name
: "";
const callId = typeof event.call_id === "string" ? event.call_id : "";
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
return `${callId}|${name}`;
}
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
const key = toolEventFileEditKey(event);
if (!key) return false;
return messages.some((message) =>
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
);
}
export function filterCoveredFileEditToolEvents(
messages: UIMessage[],
events: ToolProgressEvent[],
): ToolProgressEvent[] {
if (events.length === 0) return events;
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
}
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
const events = message.toolEvents ?? [];
if (!events.length || incomingKeys.size === 0) return message;
const removedTraceLines = new Set<string>();
const keptEvents: ToolProgressEvent[] = [];
let changed = false;
for (const event of events) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) {
changed = true;
for (const line of toolTraceLinesFromEvents([event])) {
removedTraceLines.add(line);
}
continue;
}
keptEvents.push(event);
}
if (!changed) return message;
const previousTraces = message.traces?.length
? message.traces
: message.content
? [message.content]
: [];
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
return {
...message,
traces: nextTraces,
content: nextTraces[nextTraces.length - 1] ?? "",
toolEvents: keptEvents.length ? keptEvents : undefined,
};
}
function traceMessageIsEmpty(message: UIMessage): boolean {
const traces = message.traces;
const hasTrace = traces?.length
? traces.some((line) => line.trim().length > 0)
: (message.content ?? "").trim().length > 0;
return (
message.kind === "trace"
&& !hasTrace
&& !message.toolEvents?.length
&& !message.fileEdits?.length
&& !message.media?.length
);
}
export function stripCoveredFileEditToolHintsFromMessages(
messages: UIMessage[],
edits: UIFileEdit[],
turn: UIMessageTurnFields,
): UIMessage[] {
if (edits.length === 0) return messages;
let next = messages;
for (let i = next.length - 1; i >= 0; i -= 1) {
const candidate = next[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (!matchesTurn(candidate, turn)) continue;
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
if (cleaned === candidate) continue;
if (next === messages) next = [...messages];
if (traceMessageIsEmpty(cleaned)) {
next.splice(i, 1);
} else {
next[i] = cleaned;
}
}
return next;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus =
edit.phase === "error"
? "error"
: edit.phase === "end"
? "done"
: "editing";
const normalized: UIFileEdit = {
...edit,
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
? edit.status
: inferredStatus,
};
if (edit.pending && !edit.path) normalized.pending = true;
return normalized;
}
export function mergeFileEdits(
existing: UIFileEdit[] | undefined,
incoming: UIFileEdit[],
): UIFileEdit[] {
const next = [...(existing ?? [])];
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
for (const raw of incoming) {
const edit = normalizeFileEdit(raw);
if (!edit) continue;
const key = fileEditKey(edit);
let existingIndex = indexByKey.get(key);
if (existingIndex === undefined && edit.path) {
const eventKey = fileEditToolEventKey(edit);
const pendingIndex = next.findIndex((existing) =>
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
);
if (pendingIndex >= 0) existingIndex = pendingIndex;
}
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(edit);
continue;
}
const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
indexByKey.set(key, existingIndex);
}
return next;
}
export function findFileEditTraceIndex(
prev: UIMessage[],
segmentId: string | null,
incoming: UIFileEdit[],
): number | null {
const incomingKeys = new Set(incoming.map(fileEditKey));
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits ?? []) {
if (
incomingKeys.has(fileEditKey(existing))
|| (
!existing.path
&& existing.pending
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
)
) return i;
}
}
return null;
}
export function finalizeStreamedTurn(
prev: UIMessage[],
turn: UIMessageTurnFields = {},
): UIMessage[] {
return prev.map((m) =>
m.isStreaming && matchesTurn(m, turn)
? { ...m, isStreaming: false, reasoningStreaming: false }
: m,
);
}
-44
View File
@@ -956,13 +956,11 @@ export interface McpPresetInfo {
description: string;
docs_url: string;
transport: "stdio" | "streamableHttp" | "sse" | "oauth" | string;
auth?: "oauth" | null;
requires: string;
note: string;
install_supported: boolean;
installed: boolean;
configured: boolean;
enabled?: boolean;
available: boolean;
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
logo_url?: string | null;
@@ -978,30 +976,6 @@ export interface McpPresetInfo {
manifest?: AppManifest;
}
export type McpOAuthFlowStatus =
| "starting"
| "authorization_required"
| "connecting"
| "authorized"
| "connected"
| "failed"
| "cancelled";
export interface McpOAuthFlowPayload {
flow_id: string;
name: string;
status: McpOAuthFlowStatus;
expires_in: number;
authorization_url?: string;
completion_input?: "callback_url";
error?: string;
hot_reload?: {
ok: boolean;
message?: string;
requires_restart?: boolean;
};
}
export interface McpPresetsPayload {
presets: McpPresetInfo[];
installed_count: number;
@@ -1287,18 +1261,6 @@ export type InboundEvent =
detail?: string;
provider?: string;
}
| {
event: "webui_response";
request_id: string;
ok: true;
result: unknown;
}
| {
event: "webui_response";
request_id: string;
ok: false;
error: { status: number; message: string };
}
| {
event: "error";
chat_id?: string;
@@ -1377,12 +1339,6 @@ export interface FilePreviewPayload {
export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "new_temporary_chat" }
| {
type: "webui_request";
request_id: string;
action: string;
payload: Record<string, unknown>;
}
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_sidebar_state"; state: SidebarStatePayload }
File diff suppressed because it is too large Load Diff
+78 -202
View File
@@ -19,7 +19,6 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const requestMutationSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
const sendMessageSpy = vi.fn();
@@ -243,7 +242,6 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
newTemporaryChat = newTemporaryChatSpy;
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
requestMutation = requestMutationSpy;
discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
@@ -272,8 +270,7 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
setSidebarStateSpy.mockReset().mockResolvedValue({});
requestMutationSpy.mockReset();
setSidebarStateSpy.mockReset();
discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0;
newTemporaryChatSpy.mockImplementation(async () => (
@@ -318,68 +315,11 @@ describe("App layout", () => {
render(<App />);
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
.toBeInTheDocument();
const password = screen.getByLabelText("Password");
expect(password).toHaveAttribute(
"autocomplete",
"current-password",
);
expect(password).not.toHaveAttribute("placeholder");
expect(screen.queryByText("Authentication required")).not.toBeInTheDocument();
expect(
screen.queryByText("Incorrect password. Try again."),
).not.toBeInTheDocument();
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
expect(connectSpy).not.toHaveBeenCalled();
});
it("toggles password visibility without changing the password", async () => {
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
new Error("bootstrap failed: HTTP 401"),
);
const user = userEvent.setup();
render(<App />);
const password = await screen.findByLabelText("Password");
await user.type(password, "correct horse battery staple");
expect(password).toHaveAttribute("type", "password");
await user.click(screen.getByRole("button", { name: "Show password" }));
expect(password).toHaveAttribute("type", "text");
expect(password).toHaveValue("correct horse battery staple");
const hidePassword = screen.getByRole("button", { name: "Hide password" });
expect(hidePassword).toHaveFocus();
await user.click(hidePassword);
expect(password).toHaveAttribute("type", "password");
expect(password).toHaveValue("correct horse battery staple");
expect(screen.getByRole("button", { name: "Show password" })).toHaveFocus();
});
it("explains and focuses an empty auth password", async () => {
vi.mocked(fetchBootstrap).mockRejectedValue(
new Error("bootstrap failed: HTTP 401"),
);
render(<App />);
const password = await screen.findByLabelText("Password");
const connect = screen.getByRole("button", { name: "Connect" });
expect(connect).toBeEnabled();
fireEvent.click(connect);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Enter your password.",
);
expect(password).toHaveAttribute("aria-invalid", "true");
expect(password).toHaveAttribute("aria-describedby", "webui-auth-error");
expect(password).toHaveFocus();
expect(fetchBootstrap).toHaveBeenCalledTimes(1);
});
it("shows the auth form when bootstrap does not issue an API token", async () => {
vi.mocked(fetchBootstrap).mockRejectedValueOnce(
new BootstrapAuthRequiredError(
@@ -389,11 +329,8 @@ describe("App layout", () => {
render(<App />);
expect(await screen.findByRole("heading", { level: 1, name: "Password" }))
.toBeInTheDocument();
expect(
screen.queryByText("Incorrect password. Try again."),
).not.toBeInTheDocument();
expect(await screen.findByText("Authentication required")).toBeInTheDocument();
expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument();
expect(connectSpy).not.toHaveBeenCalled();
});
@@ -404,16 +341,11 @@ describe("App layout", () => {
render(<App />);
const password = await screen.findByLabelText("Password");
const password = await screen.findByPlaceholderText("Password");
fireEvent.change(password, { target: { value: "wrong-password" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
const retryPassword = await screen.findByLabelText("Password");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Incorrect password. Try again.",
);
expect(retryPassword).toHaveAttribute("aria-invalid", "true");
expect(retryPassword).toHaveFocus();
expect(await screen.findByText("Invalid password. Try again.")).toBeInTheDocument();
expect(fetchBootstrap).toHaveBeenLastCalledWith("", "wrong-password");
expect(connectSpy).not.toHaveBeenCalled();
});
@@ -433,21 +365,6 @@ describe("App layout", () => {
expect(asideClassNames.some((cls) => cls.includes("lg:block"))).toBe(true);
});
it("uses one main landmark and a page heading in desktop settings", async () => {
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
).toBeInTheDocument();
expect(container.querySelectorAll("main")).toHaveLength(1);
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
});
it("places Automations after Skills in the main sidebar", async () => {
render(<App />);
@@ -735,57 +652,6 @@ describe("App layout", () => {
});
});
it("preserves the first message when the gateway rejects a project", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
createChatSpy.mockRejectedValueOnce(
new Error("workspace_scope_rejected:project_path must be an existing directory"),
);
mockFetchRoutes({
"/api/workspaces": {
schema_version: 1,
default_access_mode: "restricted",
default_scope: {
project_path: "C:\\workspace",
project_name: "workspace",
access_mode: "restricted",
restrict_to_workspace: true,
},
controls: { can_change_project: true, can_use_full_access: true },
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: "Choose project" }));
fireEvent.change(await screen.findByLabelText("Paste path"), {
target: { value: "C:\\missing-project" },
});
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
const message = screen.getByLabelText("Message input");
fireEvent.change(message, { target: { value: "keep this first message" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
expect(message).toHaveValue("keep this first message");
const projectButton = screen.getByRole("button", { name: "Choose project" });
await waitFor(() => expect(projectButton).toHaveFocus());
expect(screen.getByRole("alert")).toHaveTextContent(
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
);
fireEvent.click(projectButton);
const projectPath = await screen.findByLabelText("Paste path");
expect(projectPath).toHaveValue("C:\\missing-project");
expect(projectPath).toHaveAttribute("aria-invalid", "true");
expect(projectPath).toHaveFocus();
expect(screen.getByRole("alert")).toHaveTextContent(
"The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again.",
);
expect(window.location.hash).toBe("");
consoleError.mockRestore();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
@@ -880,36 +746,40 @@ describe("App layout", () => {
}],
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
},
});
requestMutationSpy.mockResolvedValueOnce({
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
{
"/api/webui/skills/update?name=github&enabled=false": {
skills: [
{
name: "cron",
description: "Schedule reminders.",
source: "builtin",
enabled: true,
deletable: false,
available: true,
},
{
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: {
name: "github",
description: "Work with GitHub.",
source: "builtin",
enabled: false,
deletable: false,
available: false,
unavailable_reason: "CLI: gh",
deleted: false,
},
{
name: "custom-skill",
description: "A workspace skill.",
source: "workspace",
enabled: true,
deletable: true,
available: true,
},
],
last_action: { name: "github", enabled: false, deleted: false },
},
});
render(<App />);
@@ -1009,10 +879,14 @@ describe("App layout", () => {
},
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
},
});
requestMutationSpy.mockResolvedValueOnce({
skills: [],
last_action: { name: "custom-skill", enabled: false, deleted: true },
"/api/webui/skills/delete?name=custom-skill": {
skills: [],
last_action: {
name: "custom-skill",
enabled: false,
deleted: true,
},
},
});
render(<App />);
@@ -1144,8 +1018,9 @@ describe("App layout", () => {
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-testing": [] },
},
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing":
() => pendingInstall,
});
requestMutationSpy.mockImplementationOnce(() => pendingInstall);
render(<App />);
@@ -1193,14 +1068,11 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
await waitFor(() => {
expect(requestMutationSpy).toHaveBeenCalledWith(
"skill.install",
{
provider: "skills_sh",
source: "acme/agent-skills",
skill: "react-testing",
},
150_000,
expect(fetch).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing",
expect.objectContaining({
headers: { Authorization: expect.any(String) },
}),
);
});
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
@@ -1358,12 +1230,14 @@ describe("App layout", () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] },
});
requestMutationSpy.mockResolvedValueOnce({
jobs: [{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
}],
"/api/webui/automations/update?id=past-one-shot": {
jobs: [
{
...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
},
],
},
});
render(<App />);
@@ -1389,18 +1263,20 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(requestMutationSpy).toHaveBeenCalledWith(
"automation.update",
{
id: "past-one-shot",
values: {
name: "Past one-shot",
message: "Updated one-shot message",
},
},
20_000,
expect(fetch).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot",
expect.any(Object),
);
});
const updateCall = vi.mocked(fetch).mock.calls.find(
([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot",
);
expect(updateCall).toBeTruthy();
const headers = updateCall?.[1]?.headers as Record<string, string>;
expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({
name: "Past one-shot",
message: "Updated one-shot message",
});
});
it("keeps long automation details expandable without nested scrolling", async () => {
@@ -1822,9 +1698,6 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
act(() => {
statusHandlers.forEach((handler) => handler("open"));
});
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
@@ -2577,14 +2450,17 @@ describe("App layout", () => {
mockFetchRoutes({
"/api/settings": initialSettings,
});
const fetchMock = vi.mocked(fetch);
window.history.replaceState(null, "", "/#/settings?section=runtime");
render(<App />);
expect(await screen.findByText("UTC")).toBeInTheDocument();
expect(
requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
).toBe(false);
fetchMock.mock.calls.filter(([input]) =>
String(input).startsWith("/api/settings/update?timezone="),
),
).toHaveLength(0);
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
expect(
screen.queryByText("Used for schedules and time-aware replies."),
@@ -1,600 +0,0 @@
{
"cases": [
{
"name": "reasoning_then_streamed_answer",
"chat_id": "fixture-reasoning",
"initial_messages": [
{
"id": "fixture-user-reasoning",
"role": "user",
"content": "Explain event projection.",
"turnId": "turn-reasoning",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000000000
}
],
"live_events": [
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "Compare ",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "state.",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 3
},
{
"event": "reasoning_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "Use one ",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "fold.",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "stream_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 7
},
{
"event": "turn_end",
"chat_id": "fixture-reasoning",
"latency_ms": 42,
"turn_id": "turn-reasoning",
"turn_phase": "complete",
"turn_seq": 8
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-reasoning",
"text": "Explain event projection.",
"turn_id": "turn-reasoning",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000000000
},
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "Compare ",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "state.",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 3
},
{
"event": "reasoning_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "Use one ",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "fold.",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "stream_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 7
},
{
"event": "turn_end",
"chat_id": "fixture-reasoning",
"latency_ms": 42,
"turn_id": "turn-reasoning",
"turn_phase": "complete",
"turn_seq": 8
}
],
"expected": [
{
"role": "user",
"content": "Explain event projection.",
"turnId": "turn-reasoning",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "assistant",
"content": "Use one fold.",
"reasoning": "Compare state.",
"activitySegmentId": "segment-1",
"latencyMs": 42,
"turnId": "turn-reasoning",
"turnPhase": "answer",
"turnSeq": 6
}
]
},
{
"name": "length_recovery_merges_answer_segments",
"chat_id": "fixture-length",
"initial_messages": [
{
"id": "fixture-user-length",
"role": "user",
"content": "Continue after the limit.",
"turnId": "turn-length",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000001000
}
],
"live_events": [
{
"event": "delta",
"chat_id": "fixture-length",
"text": "first ",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 2
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"text": "first ",
"resuming": true,
"merge_next": true,
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 3
},
{
"event": "delta",
"chat_id": "fixture-length",
"text": "second",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "turn_end",
"chat_id": "fixture-length",
"latency_ms": 17,
"turn_id": "turn-length",
"turn_phase": "complete",
"turn_seq": 6
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-length",
"text": "Continue after the limit.",
"turn_id": "turn-length",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000001000
},
{
"event": "delta",
"chat_id": "fixture-length",
"text": "first ",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 2
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"text": "first ",
"resuming": true,
"merge_next": true,
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 3
},
{
"event": "delta",
"chat_id": "fixture-length",
"text": "second",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "turn_end",
"chat_id": "fixture-length",
"latency_ms": 17,
"turn_id": "turn-length",
"turn_phase": "complete",
"turn_seq": 6
}
],
"expected": [
{
"role": "user",
"content": "Continue after the limit.",
"turnId": "turn-length",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "assistant",
"content": "first second",
"latencyMs": 17,
"turnId": "turn-length",
"turnPhase": "answer",
"turnSeq": 4
}
]
},
{
"name": "tool_activity_then_complete_answer",
"chat_id": "fixture-activity",
"initial_messages": [
{
"id": "fixture-user-activity",
"role": "user",
"content": "Inspect the projection code.",
"turnId": "turn-activity",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000002000
}
],
"live_events": [
{
"event": "message",
"chat_id": "fixture-activity",
"kind": "tool_hint",
"text": "search projection helpers",
"turn_id": "turn-activity",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-activity",
"text": "Review results.",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "reasoning_end",
"chat_id": "fixture-activity",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 5
},
{
"event": "message",
"chat_id": "fixture-activity",
"text": "Projection matches.",
"turn_id": "turn-activity",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "turn_end",
"chat_id": "fixture-activity",
"latency_ms": 8,
"turn_id": "turn-activity",
"turn_phase": "complete",
"turn_seq": 7
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-activity",
"text": "Inspect the projection code.",
"turn_id": "turn-activity",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000002000
},
{
"event": "message",
"chat_id": "fixture-activity",
"kind": "tool_hint",
"text": "search projection helpers",
"turn_id": "turn-activity",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-activity",
"text": "Review results.",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "reasoning_end",
"chat_id": "fixture-activity",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 5
},
{
"event": "message",
"chat_id": "fixture-activity",
"text": "Projection matches.",
"turn_id": "turn-activity",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "turn_end",
"chat_id": "fixture-activity",
"latency_ms": 8,
"turn_id": "turn-activity",
"turn_phase": "complete",
"turn_seq": 7
}
],
"expected": [
{
"role": "user",
"content": "Inspect the projection code.",
"turnId": "turn-activity",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "tool",
"content": "search projection helpers",
"kind": "trace",
"traces": [
"search projection helpers"
],
"activitySegmentId": "segment-1",
"turnId": "turn-activity",
"turnPhase": "activity",
"turnSeq": 2
},
{
"role": "assistant",
"content": "Projection matches.",
"reasoning": "Review results.",
"activitySegmentId": "segment-1",
"latencyMs": 8,
"turnId": "turn-activity",
"turnPhase": "answer",
"turnSeq": 6
}
]
},
{
"name": "file_edit_lifecycle_merges_by_call_and_path",
"chat_id": "fixture-file-edit",
"initial_messages": [
{
"id": "fixture-user-file-edit",
"role": "user",
"content": "Update app.py.",
"turnId": "turn-file-edit",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000003000
}
],
"live_events": [
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "start",
"added": 0,
"deleted": 0,
"status": "editing"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "end",
"added": 3,
"deleted": 1,
"status": "done"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 3
},
{
"event": "message",
"chat_id": "fixture-file-edit",
"text": "Updated app.py.",
"turn_id": "turn-file-edit",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "turn_end",
"chat_id": "fixture-file-edit",
"latency_ms": 9,
"turn_id": "turn-file-edit",
"turn_phase": "complete",
"turn_seq": 5
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-file-edit",
"text": "Update app.py.",
"turn_id": "turn-file-edit",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000003000
},
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "start",
"added": 0,
"deleted": 0,
"status": "editing"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "end",
"added": 3,
"deleted": 1,
"status": "done"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 3
},
{
"event": "message",
"chat_id": "fixture-file-edit",
"text": "Updated app.py.",
"turn_id": "turn-file-edit",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "turn_end",
"chat_id": "fixture-file-edit",
"latency_ms": 9,
"turn_id": "turn-file-edit",
"turn_phase": "complete",
"turn_seq": 5
}
],
"expected": [
{
"role": "user",
"content": "Update app.py.",
"turnId": "turn-file-edit",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "tool",
"content": "",
"kind": "trace",
"traces": [],
"fileEdits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "end",
"added": 3,
"deleted": 1,
"status": "done"
}
],
"activitySegmentId": "segment-1",
"turnId": "turn-file-edit",
"turnPhase": "activity",
"turnSeq": 3
},
{
"role": "assistant",
"content": "Updated app.py.",
"latencyMs": 9,
"turnId": "turn-file-edit",
"turnPhase": "answer",
"turnSeq": 4
}
]
}
]
}
-12
View File
@@ -75,18 +75,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.apps.description",
"settings.apps.caption",
"settings.apps.restartRequired",
"settings.mcp.connectingAccount",
"settings.mcp.continueSignIn",
"settings.mcp.preparingSignIn",
"settings.mcp.openSignInToContinue",
"settings.mcp.finishSignInInBrowser",
"settings.mcp.finishingConnection",
"settings.mcp.activatingTools",
"settings.mcp.connected",
"settings.mcp.connectionFailed",
"settings.mcp.connectionCancelled",
"settings.mcp.reloadFailed",
"settings.mcp.oauthFailed",
"settings.skills.views",
"settings.skills.installedTab",
"settings.skills.discoverTab",
+3 -137
View File
@@ -71,122 +71,6 @@ afterEach(() => {
});
describe("NanobotClient", () => {
it("correlates successful WebUI mutation replies by request id", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation<{ saved: boolean }>(
"settings.provider.update",
{ provider: "openrouter", apiKey: "secret" },
);
const frame = JSON.parse(socket.sent.at(-1) as string);
expect(frame).toMatchObject({
type: "webui_request",
action: "settings.provider.update",
payload: { provider: "openrouter", apiKey: "secret" },
});
expect(frame.request_id).toEqual(expect.any(String));
socket.fakeMessage({
event: "webui_response",
request_id: frame.request_id,
ok: true,
result: { saved: true },
});
await expect(pending).resolves.toEqual({ saved: true });
});
it("surfaces correlated WebUI mutation errors with status", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation("settings.channel.configure", {});
const requestId = JSON.parse(socket.sent.at(-1) as string).request_id;
socket.fakeMessage({
event: "webui_response",
request_id: requestId,
ok: false,
error: { status: 400, message: "missing channel name" },
});
await expect(pending).rejects.toMatchObject({
status: 400,
message: "missing channel name",
});
});
it("times out WebUI mutations without replaying them", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = expect(
client.requestMutation("skill.install", { skill: "docs" }, 25),
).rejects.toMatchObject({
status: 504,
message: "WebUI request timed out after 25ms",
});
expect(socket.sent).toHaveLength(1);
await vi.advanceTimersByTimeAsync(25);
await pending;
expect(socket.sent).toHaveLength(1);
});
it("rejects in-flight WebUI mutations when the socket closes", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
const socket = lastSocket();
socket.fakeOpen();
const pending = client.requestMutation("session.delete", {
key: "websocket:chat-1",
});
socket.fakeCloseWithCode(1006);
await expect(pending).rejects.toMatchObject({
status: 503,
message: "Socket closed before WebUI response",
});
});
it("does not queue WebUI mutations before the authenticated socket opens", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({
status: 503,
message: "WebUI connection is not open",
});
expect(lastSocket().sent).toEqual([]);
});
it("keeps temporary chats out of attachment and reconnect state", async () => {
const client = new NanobotClient({
url: "ws://test",
@@ -1187,7 +1071,7 @@ describe("NanobotClient", () => {
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
});
it("sends large sidebar ordering state as a correlated WebUI request", async () => {
it("sends large sidebar ordering state outside the HTTP request line", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
@@ -1218,29 +1102,11 @@ describe("NanobotClient", () => {
client.connect();
lastSocket().fakeOpen();
const pending = client.setSidebarState(state);
client.setSidebarState(state);
const [serialized] = lastSocket().sent;
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192);
const request = JSON.parse(serialized) as {
type: string;
request_id: string;
action: string;
payload: { state: SidebarStatePayload };
};
expect(request).toEqual({
type: "webui_request",
request_id: expect.any(String),
action: "sidebar.update",
payload: { state },
});
lastSocket().fakeMessage({
event: "webui_response",
request_id: request.request_id,
ok: true,
result: state,
});
await expect(pending).resolves.toEqual(state);
expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state });
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {

Some files were not shown because too many files have changed in this diff Show More