mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cf5403cb |
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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,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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exé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 d’une 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 l’URL de rappel dans nanobot.",
|
||||
"manualCallbackHelp": "Après avoir autorisé l’accès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre d’adresse 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 n’a 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 n’est 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": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé ce projet ou ce mode d’accès. Choisissez un projet existant ou un autre mode d’accès, puis réessayez."
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Le message n’a pas été envoyé",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メッセージは送信されませんでした",
|
||||
|
||||
@@ -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": "메시지가 전송되지 않았습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "消息未发送",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
+355
-376
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user