Compare commits

...
Author SHA1 Message Date
Xubin Ren 1a04750ada refactor(agent): clarify plugin module boundary 2026-08-10 16:42:09 +09:00
Xubin Ren 70bd454d32 feat(webui): show packaged agent plugin logos 2026-08-10 16:11:32 +09:00
Xubin Ren a9fed841e0 fix(plugins): honor plugin enable state 2026-08-10 15:33:06 +09:00
Xubin Ren 71972a5308 test(webui): use canonical computer use URL 2026-08-10 15:33:06 +09:00
Xubin Ren 9d8a6cc9bb feat(plugins): run portable MCP components 2026-08-10 15:33:06 +09:00
Xubin Ren 833c85f373 fix(apps): preserve legacy CLI app skills 2026-08-10 15:33:06 +09:00
Xubin Ren 6f361cd476 feat(apps): package CLI apps as Agent Plugins 2026-08-10 15:33:06 +09:00
Xubin Ren 2c083856e0 feat(skills): load Agent Plugins v1 skills 2026-08-10 15:33:06 +09:00
chengyongruandchengyongru 43511decc9 fix(weixin): install QR code dependency 2026-08-10 13:48:49 +08:00
chengyongruandchengyongru 8dd2059be3 fix(weixin): require fresh credentials for forced login 2026-08-10 13:48:49 +08:00
KDBandchengyongru 7b1646f58c fix(weixin): honor forced QR login 2026-08-10 13:48:49 +08:00
chengyongruandchengyongru e620944150 fix(mcp): clean up failed HTTP connections 2026-08-10 13:15:04 +08:00
chengyongruandGitHub 66316f21da docs: refresh WebUI user guidance (#5312) 2026-08-10 11:47:58 +08:00
chengyongruandGitHub 55ecda275d test: strengthen user-path coverage and CI gates (#5308) 2026-08-09 21:37:06 +08:00
chengyongruandGitHub 411d6061ae fix(webui): explain HTTPS requirement for voice input (#5304) 2026-08-09 21:17:57 +08:00
57 changed files with 3142 additions and 228 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ jobs:
- name: Test WebUI
working-directory: webui
run: bun run test
run: bun run test:coverage
- name: Build WebUI
working-directory: webui
+3 -2
View File
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
## 🌐 WebUI
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, 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, temporary chats, 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,9 +250,10 @@ 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, chat channels, Apps, Skills, and Automations from one place.
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
+1 -1
View File
@@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring.
+46
View File
@@ -2306,6 +2306,52 @@ 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 also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
Agent Plugins v1 and may provide skills, MCP servers, or both:
```text
plugins/
└── release-tools/
├── plugin.json
├── mcp.json
└── skills/
└── release-notes/
└── SKILL.md
```
Plugin skills use the same progressive loading and `$skill-name` invocation as workspace
skills after the plugin is explicitly enabled. Disabling a plugin removes both its skills and
MCP servers from the agent. A workspace skill wins when it has the same name as an enabled
plugin skill; plugin skills win over built-in skills. Invalid manifests, invalid Agent Skills,
nested skill directories, and paths that resolve outside the plugin root are ignored.
Portable MCP servers declared in `mcp.json` appear in **Apps**, but are never started merely
because a package exists. Enabling a plugin there is the explicit trust decision that activates
its executable components. The host expands `PLUGIN_ROOT` and an isolated `PLUGIN_DATA`, checks
package paths before launch, and hot-reloads MCP connections. Explicit `tools.mcpServers`
configuration wins over a plugin server if their host names collide. The v1 host currently
supports plugin `stdio` servers; unsupported remote transports are skipped independently.
Treat enabled plugins as local code running with the nanobot user's privileges. Manifest
permissions are descriptive; nanobot does not currently enforce them with an OS sandbox.
Plugins may optionally declare a shell-free `extensions.dev.nanobot.installCommand` array. The
local WebUI runs it once per plugin version before first enable; remote WebUI clients cannot run
plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does
not define a registry, so package distribution remains separate from discovery and execution.
The optional `extensions.dev.nanobot.logo` field points to a packaged PNG, JPEG, or WebP asset
such as `./assets/icon.png`. nanobot only reads contained raster files up to 256 KiB and embeds
them locally in the Apps catalog; invalid or missing assets fall back to the plugin initials.
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
its catalog adapter, then writes and enables a skills-only Agent Plugin under
`<workspace>/plugins/`; updates refresh that package and uninstall removes it. The external
executable remains managed by the CLI Apps installer rather than by the Agent Plugins manifest.
## 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.
+6
View File
@@ -270,6 +270,12 @@ 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
+68 -19
View File
@@ -1,10 +1,10 @@
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
The WebUI is nanobot's browser workbench for persistent topics, visible
agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place.
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 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, switch, search, fork, and delete browser topics |
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent 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 available built-in and workspace skills before relying on them |
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
| 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,6 +90,10 @@ 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.
@@ -103,6 +107,28 @@ 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
@@ -145,7 +171,8 @@ 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; plain text that happens to start with `@` does not attach history.
reference, or drag that topic from the sidebar into the composer. 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,10 +231,20 @@ After an App or integration is available, mention it from the composer with
## Skills
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.
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.
## Automations
@@ -288,10 +325,17 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
`http://<your-ip>:8765` from the other device and enter the secret in the login
form.
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:
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
capture only in secure contexts. Voice input works on same-machine localhost;
from another device, serve the WebUI over HTTPS with a certificate that device
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:
```json
{
@@ -302,12 +346,13 @@ environment through the WebUI, opt in explicitly:
```
Use this only for a private deployment where every authenticated WebUI user is
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.
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.
Optional feature installs use pip's configured package index, including
`PIP_INDEX_URL`.
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
Leave remote package installs disabled when the WebUI is exposed beyond a
private, trusted network.
@@ -322,6 +367,10 @@ If the page does not open, check these in order:
4. You are opening port `8765`, not the gateway health port.
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
If voice input asks for a secure connection, use HTTPS with a certificate the
device trusts. Browsers do not expose microphone capture to
`http://<your-ip>` origins.
For detailed diagnostics, see
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
For frontend development, see [`../webui/README.md`](../webui/README.md).
+3 -1
View File
@@ -480,6 +480,8 @@ class AgentLoop:
config,
provider_snapshot_loader,
)
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls(
bus=bus,
provider=provider,
@@ -494,7 +496,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=config.tools.mcp_servers,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
+595
View File
@@ -0,0 +1,595 @@
"""Discover portable Agent Plugins from the agent workspace."""
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 Any, cast
import yaml
from filelock import FileLock
from loguru import logger
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])?$")
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_FRONTMATTER = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_MANIFEST_FIELDS = {
"$schema",
"name",
"version",
"description",
"author",
"homepage",
"repository",
"license",
"keywords",
"extensions",
}
_STRING_FIELDS = {"version", "description", "homepage", "repository", "license"}
_AUTHOR_FIELDS = {"name", "email", "url"}
_MCP_SERVER_FIELDS = {
"stdio": {"type", "command", "args", "env", "cwd"},
}
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
_SETUP_TIMEOUT_SECONDS = 600
_LOGO_SUFFIXES = {".jpeg", ".jpg", ".png", ".webp"}
_MAX_LOGO_BYTES = 256 * 1024
@dataclass(frozen=True)
class AgentPluginSkill:
"""One skill supplied by a valid Agent Plugins v1 package."""
name: str
path: Path
plugin: str
@dataclass(frozen=True)
class AgentPlugin:
"""A validated Agent Plugins v1 package installed in the workspace."""
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 valid packages from ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
plugins_root = workspace / "plugins"
if not plugins_root.is_dir():
return []
try:
root = plugins_root.resolve(strict=True)
except OSError:
return []
if not root.is_relative_to(workspace):
logger.warning("Ignoring Agent Plugins directory outside the workspace")
return []
try:
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
return []
plugins: list[AgentPlugin] = []
for candidate in candidates:
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 discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
"""Discover direct-child skills under ``<workspace>/plugins/*``.
Agent Plugins does not prescribe an install location. nanobot uses the
workspace ``plugins`` directory so packages stay explicit and portable
with the rest of the agent workspace.
"""
skills: list[AgentPluginSkill] = []
for plugin in discover_agent_plugins(workspace):
skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
return skills
def enabled_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
"""Return skills from plugins the user has explicitly enabled."""
skills: list[AgentPluginSkill] = []
for plugin in discover_agent_plugins(workspace):
if _enabled(workspace, plugin.name):
skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
return skills
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
manifest = _contained_file(plugin_root / "plugin.json", plugin_root)
if manifest is None:
return None
try:
value = cast(object, json.loads(manifest.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid Agent Plugin manifest '{}': {}", manifest, exc)
return None
if not isinstance(value, dict):
logger.warning("Ignoring Agent Plugin manifest '{}': expected a JSON object", manifest)
return None
payload = cast(dict[str, Any], value)
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 '{}': invalid name", manifest)
return None
if not _valid_optional_fields(payload):
logger.warning("Ignoring Agent Plugin manifest '{}': invalid metadata", manifest)
return None
for field in payload.keys() - _MANIFEST_FIELDS:
logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest)
if "extensions" in payload and not isinstance(payload["extensions"], dict):
logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest)
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
for name, server in (configured or {}).items():
if name in servers:
logger.warning("Configured MCP server '{}' overrides an Agent Plugin server", name)
servers[name] = server
return servers
def discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
"""Return component and lifecycle state for discovered plugins."""
states: list[AgentPluginState] = []
for plugin in discover_agent_plugins(workspace):
states.append(
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"),
)
)
return states
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)
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
if enabled:
if plugin.install_command and _setup_version(workspace, plugin.name) != (
plugin.version or "unknown"
):
_run_install(plugin, data)
_write_state(data / "setup-version", plugin.version or "unknown")
_write_state(data / "enabled", "1")
else:
(data / "enabled").unlink(missing_ok=True)
return plugin
def _valid_optional_fields(payload: dict[str, Any]) -> bool:
if any(field in payload and not isinstance(payload[field], str) for field in _STRING_FIELDS):
return False
keywords = payload.get("keywords")
if "keywords" in payload and (
not isinstance(keywords, list)
or not all(isinstance(keyword, str) for keyword in cast(list[object], keywords))
):
return False
author = payload.get("author")
if "author" not in payload:
return True
if not isinstance(author, dict):
return False
author_payload = cast(dict[str, object], author)
return not (author_payload.keys() - _AUTHOR_FIELDS) and all(
isinstance(value, str) for value in author_payload.values()
)
def _string(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _string_tuple(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
items = cast(list[object], value)
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)
if logo is None or logo.suffix.lower() not in _LOGO_SUFFIXES:
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
try:
if logo.stat().st_size > _MAX_LOGO_BYTES:
logger.warning("Ignoring oversized Agent Plugin logo in '{}'", plugin_root)
return None
except OSError:
return None
return logo
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]:
path = _contained_file(plugin.root / "mcp.json", plugin.root)
if path is None:
return {}
try:
value = cast(object, json.loads(path.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}': {}", plugin.name, exc)
return {}
if not isinstance(value, dict):
return {}
payload = cast(dict[str, Any], value)
raw_servers = payload.get("mcpServers")
if (
payload.keys() != {"$schema", "mcpServers"}
or 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, Any], raw)
transport = payload.get("type")
allowed = _MCP_SERVER_FIELDS.get(transport) if isinstance(transport, str) else None
if allowed is None or payload.keys() - allowed:
return None
if transport == "stdio":
command = _stdio_command(payload.get("command"), root)
args = payload.get("args", [])
env = payload.get("env", {})
cwd = _stdio_cwd(payload.get("cwd"), root, data)
if (
command is None
or not isinstance(args, list)
or not all(isinstance(item, str) for item in cast(list[object], args))
or not isinstance(env, dict)
or cwd is None
):
return None
env_payload = cast(dict[object, object], env)
if any(
not isinstance(key, str)
or key in {"PLUGIN_ROOT", "PLUGIN_DATA"}
or not isinstance(value, str)
for key, value in env_payload.items()
):
return None
string_env = cast(dict[str, str], env)
replacements = {"${PLUGIN_ROOT}": str(root), "${PLUGIN_DATA}": str(data)}
return MCPServerConfig(
type="stdio",
command=command,
args=[_expand(item, replacements) for item in cast(list[str], args)],
env={
**{key: _expand(value, replacements) for key, value in string_env.items()},
"PLUGIN_ROOT": str(root),
"PLUGIN_DATA": str(data),
},
cwd=str(cwd),
)
return None
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, replacements: dict[str, str]) -> str:
for token, replacement in replacements.items():
value = value.replace(token, replacement)
return value
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_data_root = config_root / "plugin-data"
if create:
plugin_data_root.mkdir(parents=True, exist_ok=True)
try:
resolved_plugin_data = plugin_data_root.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin data root is unavailable") from exc
if not resolved_plugin_data.is_relative_to(config_root):
raise RuntimeError("Agent Plugin data root escapes the nanobot config directory")
state_root = resolved_plugin_data / workspace_id
if create:
resolved_plugin_data.chmod(0o700)
state_root.mkdir(parents=True, exist_ok=True)
try:
resolved_state = state_root.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin state directory is unavailable") from exc
if not resolved_state.is_relative_to(config_root):
raise RuntimeError("Agent Plugin state directory escapes the nanobot config directory")
if create:
resolved_state.chmod(0o700)
data = resolved_state / name
if create:
data.mkdir(exist_ok=True)
resolved_data = data.resolve(strict=True)
if not resolved_data.is_relative_to(resolved_state):
raise RuntimeError("Agent Plugin data directory escapes its state directory")
resolved_data.chmod(0o700)
return resolved_data
return data
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.parent.mkdir(parents=True, exist_ok=True)
path.parent.chmod(0o700)
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_text(value, encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(path)
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[AgentPluginSkill]:
skills_root = plugin_root / "skills"
if not skills_root.exists():
return []
resolved_skills_root = _contained_directory(skills_root, plugin_root)
if resolved_skills_root is None:
logger.warning("Ignoring invalid skills component in Agent Plugin '{}'", plugin_name)
return []
try:
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
skill_root = _contained_directory(candidate, resolved_skills_root)
if skill_root is None:
continue
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name):
continue
skills.append(
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
)
return skills
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
return False
match = _SKILL_FRONTMATTER.match(content)
if match is None:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
try:
metadata = cast(object, yaml.safe_load(match.group(1)))
except yaml.YAMLError:
metadata = None
if not isinstance(metadata, dict):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
payload = cast(dict[object, object], metadata)
name = payload.get("name")
description = payload.get("description")
valid = (
name == directory_name
and isinstance(name, str)
and len(name) <= 64
and _SKILL_NAME.fullmatch(name) is not None
and isinstance(description, str)
and 1 <= len(description.strip()) <= 1024
)
if not valid:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
return valid
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 _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
+29 -8
View File
@@ -60,11 +60,26 @@ 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")
workspace_names = {entry["name"] for entry in skills}
seen_names = {entry["name"] for entry in skills}
for plugin_skill in plugin_skills:
if plugin_skill.name in seen_names:
continue
skills.append(
{
"name": plugin_skill.name,
"path": str(plugin_skill.path),
"source": "plugin",
"plugin": plugin_skill.plugin,
}
)
seen_names.add(plugin_skill.name)
if self.builtin_skills and self.builtin_skills.exists():
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
)
if self.disabled_skills:
@@ -84,13 +99,18 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
roots = [self.workspace_skills]
workspace_path = self.workspace_skills / name / "SKILL.md"
if workspace_path.exists():
return workspace_path.read_text(encoding="utf-8")
from nanobot.agent.plugins import enabled_agent_plugin_skills
for plugin_skill in enabled_agent_plugin_skills(self.workspace):
if plugin_skill.name == name and plugin_skill.path.is_file():
return plugin_skill.path.read_text(encoding="utf-8")
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")
builtin_path = self.builtin_skills / name / "SKILL.md"
if builtin_path.exists():
return builtin_path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
@@ -145,6 +165,7 @@ 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:
+24 -30
View File
@@ -975,11 +975,8 @@ async def connect_mcp_servers(
from mcp.client.streamable_http import streamable_http_client
async def open_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, AsyncExitStack | None]:
server_stack = AsyncExitStack()
await server_stack.__aenter__()
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
) -> bool:
try:
transport_type = cfg.type
if not transport_type:
@@ -991,8 +988,7 @@ async def connect_mcp_servers(
)
else:
logger.warning("MCP server '{}': no command or url configured, skipping", name)
await server_stack.aclose()
return name, None
return False
if transport_type in {"sse", "streamableHttp"}:
ok, error = validate_url_target(cfg.url)
@@ -1003,8 +999,7 @@ async def connect_mcp_servers(
_redact_url(cfg.url),
error,
)
await server_stack.aclose()
return name, None
return False
if transport_type == "stdio":
command, args, env = _normalize_windows_stdio_command(
@@ -1022,8 +1017,7 @@ 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))
await server_stack.aclose()
return name, None
return False
def httpx_client_factory(
headers: dict[str, str] | None = None,
@@ -1050,8 +1044,7 @@ async def connect_mcp_servers(
elif transport_type == "streamableHttp":
if not await _probe_http_url(cfg.url):
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
await server_stack.aclose()
return name, None
return False
http_client = await server_stack.enter_async_context(
httpx.AsyncClient(
@@ -1067,8 +1060,7 @@ async def connect_mcp_servers(
)
else:
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
await server_stack.aclose()
return name, None
return False
read = _filter_malformed_mcp_progress_notifications(read, name)
session = await server_stack.enter_async_context(ClientSession(read, write))
@@ -1171,7 +1163,7 @@ async def connect_mcp_servers(
logger.info(
"MCP server '{}': connected, {} capabilities registered", name, registered_count
)
return name, server_stack
return True
except Exception as e:
hint = ""
@@ -1191,9 +1183,7 @@ async def connect_mcp_servers(
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
)
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
with suppress(Exception):
await server_stack.aclose()
return name, None
return False
async def connect_single_server(
name: str, cfg: "MCPServerConfig"
@@ -1203,30 +1193,30 @@ async def connect_mcp_servers(
close_requested = asyncio.Event()
async def own_connection() -> None:
stack: AsyncExitStack | None = None
try:
_, 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()
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()
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:
except BaseException as exc:
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()
@@ -1296,10 +1286,14 @@ 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 = dict(config.tools.mcp_servers)
next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {
+2 -2
View File
@@ -453,9 +453,9 @@ class WebSearchTool(Tool):
async def _search_olostep(self, query: str, n: int) -> str:
try:
from olostep import ( # pyright: ignore[reportMissingImports]
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
)
except ImportError:
return ToolResult.error(
+78 -12
View File
@@ -18,6 +18,7 @@ from typing import Any, cast
from urllib.parse import urlparse
import httpx
import yaml
from loguru import logger
from nanobot.apps.protocol import app_manifest, compact_dict
@@ -27,6 +28,7 @@ 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 = (
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_SKILL_NAME_LINE_RE = re.compile(r"^name\s*:.*$", re.MULTILINE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({
@@ -211,10 +215,21 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
return f"cli-app-{clean or 'app'}"
def _legacy_skill_name(name: str) -> str:
"""Return the workspace skill name emitted before Agent Plugins support."""
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
def _plugin_skill_relative_path(name: str) -> str:
skill_name = _safe_skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
@@ -613,7 +628,7 @@ class CliAppManager:
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"skill": self.skill_relative_path(installed_name),
"tool": "run_cli_app",
}
)
@@ -640,7 +655,20 @@ class CliAppManager:
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
skill_name = _safe_skill_name(name)
return self.workspace / "plugins" / skill_name / "skills" / skill_name / "SKILL.md"
def _legacy_skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _legacy_skill_name(name) / "SKILL.md"
def _installed_skill_path(self, name: str) -> Path:
path = self._skill_path(name)
legacy_path = self._legacy_skill_path(name)
return legacy_path if not path.is_file() and legacy_path.is_file() else path
def skill_relative_path(self, name: str) -> str:
"""Return the existing skill path, falling back to the canonical plugin path."""
return self._installed_skill_path(name).relative_to(self.workspace).as_posix()
def _app_payload(
self,
@@ -677,7 +705,7 @@ class CliAppManager:
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
"skill_installed": self._installed_skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
@@ -713,7 +741,8 @@ class CliAppManager:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_safe_skill_name(name)}"
capabilities = [
compact_dict({
"type": "cli",
@@ -726,13 +755,13 @@ class CliAppManager:
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [skill_path],
"managed_paths": [plugin_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [skill_path],
"managed_paths": [plugin_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
@@ -1032,11 +1061,10 @@ 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."
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
return f"""---
name: {_safe_skill_name(name)}
description: >-
{description}
description: {json.dumps(description, ensure_ascii=False)}
---
# {display}
@@ -1072,25 +1100,63 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content
def _normalise_skill(self, content: str, app: dict[str, Any]) -> str:
"""Give a catalog skill the identity required by its plugin directory."""
match = _SKILL_FRONTMATTER_RE.match(content)
if match is None:
return self._fallback_skill(app)
try:
metadata = _as_object_dict(cast(object, yaml.safe_load(match.group(1))))
except yaml.YAMLError:
return self._fallback_skill(app)
description = metadata.get("description") if metadata is not None else None
if not isinstance(description, str) or not 1 <= len(description.strip()) <= 1024:
return self._fallback_skill(app)
name = _safe_skill_name(str(app["name"]))
frontmatter, replaced = _SKILL_NAME_LINE_RE.subn(f"name: {name}", match.group(1), count=1)
if not replaced:
frontmatter = f"name: {name}\n{frontmatter}"
body = content[match.end():].lstrip()
return f"---\n{frontmatter.strip()}\n---\n\n{body}"
def install_skill(self, app: dict[str, Any]) -> Path:
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 = self._normalise_skill(content, 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": _safe_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._legacy_skill_path(str(app["name"])).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
return path
def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
plugin_root = self._skill_path(name).parents[2]
if plugin_root.is_dir():
shutil.rmtree(plugin_root)
legacy_dir = self._legacy_skill_path(name).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_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, _safe_skill_name(str(app["name"])), True)
return entry
def install(self, name: str) -> dict[str, Any]:
+4 -1
View File
@@ -20,6 +20,9 @@ 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 import CliAppManager
manager = CliAppManager(workspace=workspace)
structured_items = cast(list[Any], structured)
mentions = [
cast(Mapping[str, Any], item) for item in structured_items
@@ -32,7 +35,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=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
f"skill={manager.skill_relative_path(str(item['name']))}). "
"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()
@@ -238,20 +238,6 @@ class TestStreamEndReactionCleanup:
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_both_ids_missing(self):
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
)
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock()
await ch.send_delta("oc_chat1", "", stream_end=True)
ch._remove_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_no_removal_when_not_stream_end(self):
ch = _make_channel()
+16 -3
View File
@@ -22,6 +22,7 @@ class WeixinConnectSession:
channel: WeixinChannel
current_poll_base_url: str
refresh_count: int
force: bool
created_wall: float
deadline: float
last_error: str | None = None
@@ -72,7 +73,7 @@ class WeixinConnectStore:
channel.connect_open_client()
try:
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
except Exception as exc:
await self._close_channel(channel)
raise ChannelConnectError(
@@ -89,6 +90,7 @@ 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,
)
@@ -187,7 +189,7 @@ class WeixinConnectStore:
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
await session.channel.connect_fetch_qr_code(force=session.force)
)
except Exception as exc:
self._sessions.pop(session_id, None)
@@ -204,6 +206,17 @@ 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)
@@ -234,7 +247,7 @@ class WeixinConnectStore:
}
try:
session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
await session.channel.connect_fetch_qr_code(force=session.force)
)
except Exception as exc:
self._sessions.pop(session_id, None)
+17 -12
View File
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
break
return tokens
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()
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()
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) -> bool:
"""Perform QR code login flow. Returns True on success."""
async def _qr_login(self, *, force: bool = False) -> bool:
"""Perform QR login; forced flows accept only newly confirmed credentials."""
try:
refresh_count = 0
qrcode_id, scan_url = await self._fetch_qr_code()
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
self._print_qr_code(scan_url)
current_poll_base_url = self.config.base_url
verify_code = ""
@@ -825,11 +825,16 @@ 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()
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
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
@@ -846,7 +851,7 @@ class WeixinChannel(BaseChannel):
MAX_QR_REFRESH_COUNT,
)
return False
qrcode_id, scan_url = await self._fetch_qr_code()
qrcode_id, scan_url = await self._fetch_qr_code(force=force)
current_poll_base_url = self.config.base_url
verify_code = ""
self._print_qr_code(scan_url)
@@ -893,8 +898,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) -> tuple[str, str]:
return await self._fetch_qr_code()
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
return await self._fetch_qr_code(force=force)
async def connect_poll_qr_code(
self,
@@ -947,14 +952,14 @@ class WeixinChannel(BaseChannel):
if force:
self._token = ""
self._get_updates_buf = ""
if self._token or self._load_state():
if self._token or (not force and 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()
return await self._qr_login(force=force)
finally:
self._running = False
if self._client:
+41 -11
View File
@@ -25,7 +25,9 @@ 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) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
@@ -86,14 +88,31 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-reconnect", "https://qr.example/reconnect"
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"}
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"
@@ -116,7 +135,9 @@ 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) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-cancel", "https://qr.example/cancel"
async def fake_api_get_with_base(
@@ -162,7 +183,9 @@ 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) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-verify", "https://qr.example/verify"
responses = [
@@ -204,7 +227,7 @@ async def test_weixin_connect_store_handles_verification_code(
@pytest.mark.asyncio
async def test_weixin_connect_store_treats_existing_binding_as_success(
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -221,7 +244,12 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel,
*,
force: bool = False,
) -> tuple[str, str]:
assert force is True
return "qr-existing", "https://qr.example/existing"
async def fake_api_get_with_base(
@@ -237,8 +265,8 @@ async def test_weixin_connect_store_treats_existing_binding_as_success(
started = await store.start(force=True)
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
assert "already connected" in completed["message"]
assert completed["status"] == "failed"
assert "new WeChat login" in completed["message"]
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
@@ -255,7 +283,9 @@ 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) -> tuple[str, str]:
async def fake_fetch_qr_code(
self: WeixinChannel, **_kwargs: Any
) -> tuple[str, str]:
return "qr-missing", "https://qr.example/missing"
async def fake_api_get_with_base(
@@ -268,7 +298,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=True)
started = await store.start(force=False)
completed = await store.poll(started["session_id"])
assert completed["status"] == "failed"
@@ -196,6 +196,86 @@ 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()
+96 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import base64
import json
import os
import re
@@ -16,6 +17,11 @@ from datetime import datetime, timezone
from pathlib import Path
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.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
@@ -48,6 +54,12 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
_PLUGIN_LOGO_MIME_TYPES = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
McpReload = Callable[[], Awaitable[dict[str, Any]]]
@@ -837,6 +849,51 @@ 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
suffix = path.suffix.lower()
valid = (
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"
)
if not valid:
return None
encoded = base64.b64encode(data).decode("ascii")
return f"data:{_PLUGIN_LOGO_MIME_TYPES[suffix]};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": True,
"installed": True,
"configured": state.enabled,
"available": state.enabled,
"status": "configured" if state.enabled else "not_installed",
"logo_url": _plugin_logo_data_url(plugin.logo),
"brand_color": plugin.accent_color,
"required_fields": [],
"connection_summary": ", ".join(state.mcp_servers),
"enabled_tools": ["*"],
"tool_names": [],
"source": "agent-plugin",
}
def mcp_presets_payload(
*,
last_action: dict[str, Any] | None = None,
@@ -854,9 +911,21 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known
]
plugin_states = [
state
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
]
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
plugin_rows = [
row
for state in plugin_states
if (row := _agent_plugin_payload(state))["name"] not in existing_names
]
payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows],
"installed_count": len(config.tools.mcp_servers),
"presets": [*preset_rows, *custom_rows, *plugin_rows],
"installed_count": len(config.tools.mcp_servers)
+ sum(int(row["configured"]) for row in plugin_rows),
}
if last_action is not None:
payload["last_action"] = last_action
@@ -1343,6 +1412,31 @@ async def mcp_presets_settings_action(
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
if action is None:
return mcp_presets_payload()
name = (_query_first(query, "name") or "").strip()
if name.startswith("plugin-"):
config = load_config()
plugin_name = name.removeprefix("plugin-")
installed = {
state.plugin.name
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
}
if name not in config.tools.mcp_servers and plugin_name in installed:
if action not in {"enable", "remove"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
plugin = await asyncio.to_thread(
set_agent_plugin_enabled,
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}."}
)
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
if action == "test":
return await mcp_presets_test_action(query)
if action in _CUSTOM_ACTIONS:
+23 -3
View File
@@ -18,6 +18,7 @@ from urllib.parse import unquote
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.plugins import discover_agent_plugin_states
from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
@@ -222,12 +223,12 @@ 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(request)
return await self._handle_settings_mcp_presets(connection, 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(request, mcp_action)
return await self._handle_settings_mcp_presets(connection, request, mcp_action)
return None
def _query(self, request: WsRequest) -> QueryParams:
@@ -1144,15 +1145,34 @@ class WebUISettingsRouter:
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)
name = (_query_first(query, "name") or "").strip()
if action == "enable" and name.startswith("plugin-"):
config = load_config()
plugin_names = {
f"plugin-{state.plugin.name}"
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
}
if (
name not in config.tools.mcp_servers
and name in plugin_names
and not self._allow_feature_package_install(connection, request)
):
return self._error_response(
403,
"Agent Plugin setup is restricted to the local WebUI",
)
payload = await mcp_presets_settings_action(
action,
self._parse_mcp_settings_query(request),
query,
reload_mcp=lambda: request_mcp_reload(self.bus),
)
except Exception as e:
+8 -1
View File
@@ -10,6 +10,7 @@ import time
from typing import Any
import httpx
from packaging.version import InvalidVersion, Version
from nanobot import __version__
@@ -42,7 +43,13 @@ def check_for_update() -> dict[str, Any] | None:
return None
_cache = (now, latest)
if not latest or latest == __version__:
if not isinstance(latest, str) or not latest:
return None
try:
if Version(latest) <= Version(__version__):
return None
except InvalidVersion:
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
return None
return {
"currentVersion": __version__,
+1
View File
@@ -37,6 +37,7 @@ 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",
+424
View File
@@ -0,0 +1,424 @@
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_skills,
discover_agent_plugin_states,
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 _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 {
"$schema": AGENT_PLUGIN_SCHEMA,
"name": name or directory,
}
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
return root
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",
"plugin": "acme-tools",
}
]
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",
)
assert [skill.name for skill in discover_agent_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"},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "author": None},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "keywords": None},
],
)
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_skills(tmp_path) == []
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "demo",
"futureField": True,
"extensions": "invalid but non-fatal",
},
)
_write_skill(plugin, "example")
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
def test_agent_plugin_discovers_contained_raster_logo(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "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 agent_plugins.discover_agent_plugins(tmp_path)[0].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={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "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 agent_plugins.discover_agent_plugins(tmp_path)[0].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")
assert discover_agent_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}")
assert discover_agent_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")
(plugin / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"desktop": {
"type": "stdio",
"command": "./bin/server",
"args": ["--data", "${PLUGIN_DATA}/state"],
"cwd": "${PLUGIN_ROOT}",
}
},
}
),
encoding="utf-8",
)
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 = _write_plugin(
tmp_path,
"desktop",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "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")
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:
plugin = _write_plugin(
tmp_path,
"desktop",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "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")
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")
(plugin / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
"local": {"type": "stdio", "command": "./bin/server"},
"escape": {"type": "stdio", "command": "../outside"},
},
}
),
encoding="utf-8",
)
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 the nanobot config directory"):
set_agent_plugin_enabled(tmp_path, "desktop", True)
-4
View File
@@ -84,10 +84,6 @@ class TestToolHintKnownTools:
assert '"C:/Program Files/Git/project"' not in result
assert '"' in result
def test_exec_short_command_unchanged(self):
result = _hint([_tc("exec", {"command": "npm install typescript"})])
assert result == "$ npm install typescript"
def test_exec_chained_commands_truncated_not_mid_path(self):
"""Long chained commands should truncate preserving abbreviated paths."""
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
@@ -241,24 +241,6 @@ async def test_file_edit_events_route_to_channel_capability(manager):
channel._send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
channel = manager.channels["mock"]
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
msg = outbound_message_for_event(
channel="mock",
chat_id="c1",
event=ProgressEvent(file_edit_events=edits),
)
await manager._send_once(channel, msg)
channel._file_edit_mock.assert_awaited_once_with(
"c1", edits, msg.metadata
)
channel._send_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_base_channel_file_edit_events_are_noop_safe():
class _Plain(BaseChannel):
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from typer.testing import CliRunner
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_message_for_event,
)
from nanobot.cli.commands import app
from nanobot.config.schema import Config
runner = CliRunner()
@pytest.mark.parametrize("streamed", [False, True])
def test_interactive_agent_routes_a_complete_user_turn(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
streamed: bool,
) -> None:
config = Config()
config.agents.defaults.workspace = str(tmp_path / "workspace")
seen: dict[str, object] = {}
renderers: list[object] = []
class _Renderer:
def __init__(self, **_kwargs: object) -> None:
self.streamed = False
self.header_printed = False
self.deltas: list[str] = []
self.ends: list[bool] = []
self.closed = 0
renderers.append(self)
async def on_delta(self, content: str) -> None:
self.streamed = True
self.deltas.append(content)
async def on_end(self, *, resuming: bool = False) -> None:
self.ends.append(resuming)
async def close(self) -> None:
self.closed += 1
def stop_for_input(self) -> None:
return None
class _AgentLoop:
channels_config = None
@classmethod
def from_config(cls, _config, bus, **_kwargs):
instance = cls(bus)
seen["loop"] = instance
return instance
def __init__(self, bus) -> None:
self.bus = bus
self.stopped = asyncio.Event()
self.close_mcp_calls = 0
async def run(self) -> None:
message = await self.bus.consume_inbound()
seen["inbound"] = message
if streamed:
for event in (
StreamDeltaEvent(content="hello "),
StreamDeltaEvent(content="world"),
StreamEndEvent(),
StreamedResponseEvent(),
):
await self.bus.publish_outbound(
outbound_message_for_event(
channel=message.channel,
chat_id=message.chat_id,
event=event,
content="hello world" if isinstance(event, StreamedResponseEvent) else None,
)
)
else:
await self.bus.publish_outbound(
OutboundMessage(
channel=message.channel,
chat_id=message.chat_id,
content="hello world",
)
)
await self.stopped.wait()
def stop(self) -> None:
self.stopped.set()
async def close_mcp(self) -> None:
self.close_mcp_calls += 1
read_input = AsyncMock(side_effect=["hello nanobot", "exit"])
print_response = MagicMock()
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
monkeypatch.setattr("nanobot.cli.agent.sync_workspace_templates", lambda *_args: None)
monkeypatch.setattr("nanobot.cli.agent.is_default_workspace", lambda *_args: False)
monkeypatch.setattr("nanobot.cli.agent._set_nanobot_logs", lambda *_args: None)
monkeypatch.setattr("nanobot.cli.agent._model_display", lambda *_args: ("test-model", ""))
monkeypatch.setattr("nanobot.cli.agent.consume_restart_notice_from_env", lambda: None)
monkeypatch.setattr("nanobot.cli.agent.AgentLoop", _AgentLoop)
monkeypatch.setattr("nanobot.cli.agent.StreamRenderer", _Renderer)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda *_args: object())
monkeypatch.setattr(
"nanobot.providers.image_generation.image_gen_provider_configs",
lambda *_args: [],
)
monkeypatch.setattr("nanobot.cron.service.CronService", lambda *_args: object())
monkeypatch.setattr("nanobot.cli.agent.signal.signal", lambda *_args: None)
monkeypatch.setattr("nanobot.cli.terminal._init_prompt_session", lambda: None)
monkeypatch.setattr("nanobot.cli.terminal._flush_pending_tty_input", lambda: None)
monkeypatch.setattr("nanobot.cli.terminal._restore_terminal", lambda: None)
monkeypatch.setattr("nanobot.cli.terminal._read_interactive_input_async", read_input)
monkeypatch.setattr("nanobot.cli.terminal._print_agent_response", print_response)
result = runner.invoke(app, ["agent", "--session", "cli:journey"])
assert result.exit_code == 0, result.output
inbound = seen["inbound"]
assert isinstance(inbound, InboundMessage)
assert (inbound.channel, inbound.chat_id, inbound.content) == (
"cli",
"journey",
"hello nanobot",
)
assert inbound.metadata == {"_wants_stream": True}
loop = seen["loop"]
assert isinstance(loop, _AgentLoop)
assert loop.close_mcp_calls == 1
assert len(renderers) == 1
renderer = renderers[0]
assert isinstance(renderer, _Renderer)
if streamed:
assert renderer.deltas == ["hello ", "world"]
assert renderer.ends == [False]
assert renderer.closed == 0
print_response.assert_not_called()
else:
assert renderer.deltas == []
assert renderer.closed == 1
print_response.assert_called_once_with(
"hello world",
render_markdown=True,
metadata={},
)
-21
View File
@@ -1231,27 +1231,6 @@ def test_openai_compat_provider_passes_model_through():
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
def test_make_provider_uses_github_copilot_backend():
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
}
}
)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider"
def test_openai_codex_proxy_config_affects_provider_and_signature():
def config_with_proxy(proxy: str) -> Config:
return Config.model_validate(
+74 -6
View File
@@ -9,9 +9,21 @@ from types import SimpleNamespace
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.plugins import discover_agent_plugin_skills
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(
@@ -391,6 +403,9 @@ 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")
@@ -400,9 +415,26 @@ 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"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "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 discover_agent_plugin_skills(manager.workspace)] == [
"cli-app-gimp"
]
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(
@@ -487,7 +519,14 @@ 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 / "skills" / "cli-app-feishu" / "SKILL.md"
skill = (
manager.workspace
/ "plugins"
/ "cli-app-feishu"
/ "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")
@@ -704,7 +743,8 @@ 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"}})
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -717,7 +757,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 skill_dir.exists()
assert not plugin_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -845,19 +885,47 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"skill": "plugins/cli-app-gimp/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)
+21 -2
View File
@@ -38,7 +38,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
@@ -58,4 +58,23 @@ 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=skills/cli-app-zoom/SKILL.md" 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)
-21
View File
@@ -243,27 +243,6 @@ def test_workspace_override(tmp_path):
assert bot._loop.workspace == custom_ws
def test_sdk_make_provider_uses_github_copilot_backend():
from nanobot.config.schema import Config
from nanobot.providers.factory import make_provider
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
}
}
)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = make_provider(config)
assert provider.__class__.__name__ == "GitHubCopilotProvider"
@pytest.mark.asyncio
async def test_run_custom_session_key(tmp_path):
from nanobot.bus.events import OutboundMessage
+55
View File
@@ -5,10 +5,13 @@ 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")
@@ -171,6 +174,58 @@ 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."""
+45 -3
View File
@@ -1082,10 +1082,22 @@ 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:
sessions = {"good": _make_fake_session(["demo"])}
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"]),
}
class _SelectiveClientSession:
def __init__(self, read: object, _write: object) -> None:
@@ -1099,7 +1111,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":
if params.command == "bad" and failure_mode == "exception":
raise RuntimeError("boom")
yield params.command, object()
@@ -1109,8 +1121,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{
"good": MCPServerConfig(command="good"),
"bad": MCPServerConfig(command="bad"),
"good": MCPServerConfig(command="good"),
},
registry,
)
@@ -1121,6 +1133,36 @@ 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],
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, MagicMock
import pytest
import nanobot.webui.forking as forking
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY
def test_create_fork_rebuilds_missing_transcript_and_saves_clean_title(
monkeypatch: pytest.MonkeyPatch,
) -> None:
forked = SimpleNamespace(messages=[{"role": "user", "content": "hello"}], metadata={})
manager = MagicMock()
manager.fork_session_before_user_index.return_value = forked
rebuild = MagicMock()
marker = MagicMock()
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "fork-id")
monkeypatch.setattr(forking, "fork_transcript_before_user_index", lambda *_args: False)
monkeypatch.setattr(forking, "write_session_messages_as_transcript", rebuild)
monkeypatch.setattr(forking, "append_fork_marker", marker)
result = forking.create_webui_chat_fork(
manager,
source_chat_id="source",
before_user_index=2,
title=" Useful fork ",
)
assert result == ("fork-id", "websocket:fork-id")
manager.fork_session_before_user_index.assert_called_once_with(
"websocket:source",
"websocket:fork-id",
2,
)
rebuild.assert_called_once_with("websocket:fork-id", forked.messages)
marker.assert_called_once_with("websocket:fork-id")
assert forked.metadata[WEBUI_TITLE_METADATA_KEY] == "Useful fork"
manager.save.assert_called_once_with(forked, fsync=True)
def test_create_fork_rolls_back_session_and_transcript_together(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = MagicMock()
manager.fork_session_before_user_index.return_value = SimpleNamespace(
messages=[],
metadata={},
)
delete_transcript = MagicMock()
monkeypatch.setattr(forking.uuid, "uuid4", lambda: "failed-fork")
monkeypatch.setattr(
forking,
"fork_transcript_before_user_index",
MagicMock(side_effect=OSError("disk full")),
)
monkeypatch.setattr(forking, "delete_webui_transcript", delete_transcript)
with pytest.raises(OSError, match="disk full"):
forking.create_webui_chat_fork(
manager,
source_chat_id="source",
before_user_index=1,
)
delete_transcript.assert_called_once_with("websocket:failed-fork")
manager.delete_session.assert_called_once_with("websocket:failed-fork")
def test_create_fork_stops_before_transcript_work_when_source_is_invalid(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = MagicMock()
manager.fork_session_before_user_index.return_value = None
fork_transcript = MagicMock()
monkeypatch.setattr(forking, "fork_transcript_before_user_index", fork_transcript)
result = forking.create_webui_chat_fork(
manager,
source_chat_id="missing",
before_user_index=1,
)
assert result is None
fork_transcript.assert_not_called()
@pytest.mark.parametrize(
("envelope", "detail"),
[
({"source_chat_id": "bad/id", "before_user_index": 0}, "invalid source_chat_id"),
({"source_chat_id": "source", "before_user_index": True}, "invalid before_user_index"),
({"source_chat_id": "source", "before_user_index": -1}, "invalid before_user_index"),
],
)
@pytest.mark.asyncio
async def test_fork_handler_rejects_invalid_protocol_input(
envelope: dict[str, object],
detail: str,
) -> None:
connection = object()
channel = SimpleNamespace(
send_webui_protocol_error=AsyncMock(),
gateway=SimpleNamespace(session_manager=MagicMock()),
)
await forking.handle_webui_fork_chat(channel, connection, envelope)
channel.send_webui_protocol_error.assert_awaited_once_with(connection, detail)
@pytest.mark.asyncio
async def test_fork_handler_reports_unavailable_session_manager() -> None:
connection = object()
channel = SimpleNamespace(
send_webui_protocol_error=AsyncMock(),
gateway=SimpleNamespace(session_manager=None),
)
await forking.handle_webui_fork_chat(
channel,
connection,
{"source_chat_id": "source", "before_user_index": 0},
)
channel.send_webui_protocol_error.assert_awaited_once_with(
connection,
"session_manager_unavailable",
)
@pytest.mark.asyncio
async def test_fork_handler_maps_invalid_source_and_internal_failure_to_stable_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = object()
channel = SimpleNamespace(
send_webui_protocol_error=AsyncMock(),
gateway=SimpleNamespace(session_manager=MagicMock()),
logger=SimpleNamespace(warning=MagicMock()),
)
envelope = {"source_chat_id": "source", "before_user_index": 0}
monkeypatch.setattr(forking, "create_webui_chat_fork", lambda *_args, **_kwargs: None)
await forking.handle_webui_fork_chat(channel, connection, envelope)
channel.send_webui_protocol_error.assert_awaited_once_with(
connection,
"invalid fork source or index",
)
channel.send_webui_protocol_error.reset_mock()
monkeypatch.setattr(
forking,
"create_webui_chat_fork",
MagicMock(side_effect=RuntimeError("broken transcript")),
)
await forking.handle_webui_fork_chat(channel, connection, envelope)
channel.logger.warning.assert_called_once_with("fork_chat failed: {}", ANY)
channel.send_webui_protocol_error.assert_awaited_once_with(connection, "fork_chat_failed")
+109 -1
View File
@@ -1,22 +1,70 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
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:
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
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")
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"],
}
},
}
),
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",
)
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -55,6 +103,66 @@ 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)
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["installed"] is True
assert row["configured"] is False
async def reload() -> dict[str, object]:
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
enabled = asyncio.run(
mcp_presets_settings_action(
"enable",
{"name": ["plugin-desktop"]},
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["requires_restart"] is False
disabled = asyncio.run(
mcp_presets_settings_action(
"remove",
{"name": ["plugin-desktop"]},
reload_mcp=reload,
)
)
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
assert disabled_row["configured"] is False
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
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")
_write_agent_plugin(load_config().workspace_path)
rows = [
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
]
assert len(rows) == 1
assert rows[0]["source"] == "custom"
def test_enable_browserbase_writes_scrubbed_config_payload(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
+60
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlsplit
import pytest
@@ -138,3 +139,62 @@ async def test_model_preset_mutation_routes(
assert response.status_code == 200
assert json.loads(response.body)["routed"] == function_name
assert captured["query"] == expected_query
@pytest.mark.parametrize(
("update_info", "expected"),
[
(None, {"updateAvailable": None}),
(
{
"currentVersion": "1.2.0",
"latestVersion": "1.3.0",
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
},
{
"updateAvailable": {
"currentVersion": "1.2.0",
"latestVersion": "1.3.0",
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
}
},
),
],
)
@pytest.mark.asyncio
async def test_version_check_route_returns_stable_payload(
monkeypatch: pytest.MonkeyPatch,
update_info: dict[str, str] | None,
expected: dict[str, object],
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_routes.check_for_update",
lambda: update_info,
)
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
response = await _router().dispatch(None, request, request.path)
assert response is not None
assert response.status_code == 200
assert json.loads(response.body) == expected
@pytest.mark.asyncio
async def test_version_check_route_enforces_auth_and_bounds_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
check = MagicMock(side_effect=RuntimeError("upstream secret body"))
monkeypatch.setattr("nanobot.webui.settings_routes.check_for_update", check)
request = SimpleNamespace(path="/api/settings/version-check", headers=Headers())
unauthorized = await _router(authorized=False).dispatch(None, request, request.path)
assert unauthorized is not None
assert unauthorized.status_code == 401
check.assert_not_called()
failed = await _router().dispatch(None, request, request.path)
assert failed is not None
assert failed.status_code == 500
assert json.loads(failed.body) == {"error": "version check failed"}
assert "upstream secret body" not in failed.body.decode()
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
import nanobot.webui.version_check as version_check
@pytest.fixture(autouse=True)
def _reset_version_cache(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(version_check, "_cache", (0.0, None))
monkeypatch.setattr(version_check.time, "monotonic", lambda: 1_000.0)
def _pypi_response(latest: object) -> MagicMock:
response = MagicMock()
response.json.return_value = {"info": {"version": latest}}
return response
def test_version_check_reports_only_a_newer_release_and_caches_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
get = MagicMock(return_value=_pypi_response("1.3.0"))
monkeypatch.setattr(version_check, "__version__", "1.2.0")
monkeypatch.setattr(version_check.httpx, "get", get)
expected = {
"currentVersion": "1.2.0",
"latestVersion": "1.3.0",
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
}
assert version_check.check_for_update() == expected
assert version_check.check_for_update() == expected
get.assert_called_once_with(
"https://pypi.org/pypi/nanobot-ai/json",
timeout=5.0,
follow_redirects=True,
)
@pytest.mark.parametrize("latest", ["1.2.0", "1.1.9", "not-a-version", 42, None])
def test_version_check_ignores_non_newer_or_invalid_releases(
monkeypatch: pytest.MonkeyPatch,
latest: object,
) -> None:
monkeypatch.setattr(version_check, "__version__", "1.2.0")
monkeypatch.setattr(version_check.httpx, "get", lambda *_args, **_kwargs: _pypi_response(latest))
assert version_check.check_for_update() is None
def test_version_check_treats_network_failure_as_best_effort(
monkeypatch: pytest.MonkeyPatch,
) -> None:
get = MagicMock(side_effect=TimeoutError("offline"))
monkeypatch.setattr(version_check.httpx, "get", get)
assert version_check.check_for_update() is None
# Failures are not cached, so a later explicit check can recover.
assert version_check.check_for_update() is None
assert get.call_count == 2
+79
View File
@@ -40,6 +40,7 @@
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.9",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -61,6 +62,8 @@
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -103,6 +106,8 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
@@ -191,6 +196,10 @@
"@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -209,6 +218,8 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
@@ -467,6 +478,8 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@2.1.9", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.12", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, "peerDependencies": { "@vitest/browser": "2.1.9", "vitest": "2.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ=="],
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
@@ -673,6 +686,8 @@
"dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -741,6 +756,8 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -755,6 +772,8 @@
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
@@ -763,6 +782,8 @@
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
@@ -799,6 +820,8 @@
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
@@ -845,6 +868,16 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@@ -893,6 +926,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
@@ -999,6 +1036,8 @@
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
@@ -1023,6 +1062,8 @@
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="],
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
@@ -1037,6 +1078,8 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
@@ -1163,6 +1206,8 @@
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
@@ -1175,10 +1220,14 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
@@ -1189,6 +1238,8 @@
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
@@ -1197,6 +1248,8 @@
"tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="],
"test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -1295,6 +1348,8 @@
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
@@ -1317,6 +1372,12 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1353,6 +1414,8 @@
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
@@ -1365,12 +1428,16 @@
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
"make-dir/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="],
@@ -1387,14 +1454,24 @@
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
@@ -1415,6 +1492,8 @@
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
+532
View File
@@ -44,6 +44,7 @@
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.9",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -75,6 +76,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
@@ -348,6 +363,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
"license": "MIT"
},
"node_modules/@braintree/sanitize-url": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
@@ -990,6 +1012,119 @@
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
@@ -1071,6 +1206,17 @@
"node": ">= 8"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"license": "MIT"
@@ -3003,6 +3149,39 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz",
"integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.7",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.12",
"magicast": "^0.3.5",
"std-env": "^3.8.0",
"test-exclude": "^7.0.1",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "2.1.9",
"vitest": "2.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"dev": true,
@@ -4247,6 +4426,13 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.340",
"dev": true,
@@ -4703,6 +4889,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/format": {
"version": "0.2.2",
"engines": {
@@ -4768,6 +4971,27 @@
"node": ">=6"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"dev": true,
@@ -4779,6 +5003,39 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "17.6.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
@@ -4810,6 +5067,16 @@
"node": ">=18.0.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"dev": true,
@@ -5143,6 +5410,13 @@
"version": "1.0.0",
"license": "CC0-1.0"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -5378,6 +5652,76 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"dev": true,
@@ -5587,6 +5931,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.4",
"@babel/types": "^7.25.4",
"source-map-js": "^1.2.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"license": "MIT",
@@ -6573,6 +6958,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
@@ -6699,6 +7094,13 @@
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/package-manager-detector": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
@@ -6761,6 +7163,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": {
"version": "1.1.2",
"dev": true,
@@ -7619,6 +8045,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true,
@@ -7697,6 +8136,22 @@
"node": ">=8"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"license": "MIT",
@@ -7729,6 +8184,20 @@
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"dev": true,
@@ -7789,6 +8258,19 @@
"node": ">= 6"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"dev": true,
@@ -7864,6 +8346,21 @@
"node": ">=4"
}
},
"node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
"integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^10.4.1",
"minimatch": "^10.2.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"dev": true,
@@ -8521,6 +9018,41 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+2
View File
@@ -8,6 +8,7 @@
"build": "tsc -p tsconfig.build.json && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
},
@@ -47,6 +48,7 @@
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "2.1.9",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
+44 -23
View File
@@ -7383,7 +7383,11 @@ function AppsCatalogSettings({
]
.filter((item) => {
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
return filter === "ready" ? appsReady(item) : item.kind === filter;
if (filter === "ready") return appsReady(item);
if (filter === "cli") {
return item.kind === "cli" || item.preset.source === "agent-plugin";
}
return item.kind === "mcp" && item.preset.source !== "agent-plugin";
})
.sort((left, right) => {
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
@@ -7623,6 +7627,7 @@ function McpAppsCatalogRow({
const testBusy = actionKey === `test:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`;
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
const agentPlugin = preset.source === "agent-plugin";
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0;
@@ -7634,8 +7639,13 @@ function McpAppsCatalogRow({
const enabledTools = preset.enabled_tools ?? ["*"];
const allowAllTools = enabledTools.includes("*");
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools);
const description = preset.description || preset.note || preset.requires || preset.name;
const statusLabel = mcpPresetStatusLabel(preset.status, tx);
const description = preset.description || preset.note || preset.name;
const detail = agentPlugin && preset.requires
? `${description} · ${preset.requires}`
: description || preset.requires;
const statusLabel = agentPlugin
? tx("settings.apps.pluginEnabled", "Plugin enabled")
: mcpPresetStatusLabel(preset.status, tx);
useEffect(() => {
if (preset.configured || !preset.install_supported) setSetupOpen(false);
@@ -7668,9 +7678,13 @@ function McpAppsCatalogRow({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "Integration")}</AppsTypeBadge>
<AppsTypeBadge>
{agentPlugin
? tx("settings.apps.pluginLabel", "Plugin")
: tx("settings.apps.mcpLabel", "Integration")}
</AppsTypeBadge>
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{readyInstalled ? (
@@ -7687,35 +7701,41 @@ function McpAppsCatalogRow({
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")}
</DropdownMenuItem>
{toolNames.length ? (
{!agentPlugin ? (
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")}
</DropdownMenuItem>
) : null}
{!agentPlugin && toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
tone="destructive"
tone={agentPlugin ? undefined : "destructive"}
disabled={busy}
onClick={() => onAction("remove", preset.name)}
>
<Trash2 aria-hidden />
{tx("settings.mcp.remove", "Remove")}
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{agentPlugin
? tx("settings.apps.pluginDisable", "Disable")
: tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy}
disabled={busy && !removeBusy}
tone="danger"
onClick={() => onAction("remove", preset.name)}
>
<Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
{!agentPlugin ? (
<AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy}
disabled={busy && !removeBusy}
tone="danger"
onClick={() => onAction("remove", preset.name)}
>
<Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : null}
</>
) : preset.installed && !preset.configured ? (
<AppsActionButton
@@ -8212,6 +8232,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
const bg = preset.brand_color || "hsl(var(--muted))";
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const packagedLogo = preset.logo_url?.startsWith("data:image/") === true;
const initials = preset.display_name
.split(/\s+/)
.filter(Boolean)
@@ -8219,7 +8240,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
.map((part) => part[0]?.toUpperCase())
.join("") || preset.name.slice(0, 2).toUpperCase();
if (showBrandLogos && logoUrl) {
if ((showBrandLogos || packagedLogo) && logoUrl) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
@@ -2383,7 +2383,7 @@ export function ThreadComposer({
<div
role="alert"
className={cn(
"mx-3 mb-1 max-h-10 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"mx-3 mb-1 max-h-24 overflow-hidden rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
"text-[11.5px] font-medium text-destructive transition-[max-height,margin,padding,opacity] [transition-duration:220ms] ease-out motion-reduce:transition-none",
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
)}
+5
View File
@@ -28,6 +28,7 @@ const VOICE_MIME_CANDIDATES = [
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
export type VoiceRecorderErrorKey =
| "failed"
| "insecureContext"
| "noDevice"
| "notConfigured"
| "permission"
@@ -163,6 +164,10 @@ export function useVoiceRecorder({
const startRecording = useCallback(async () => {
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
onClearError();
if (window.isSecureContext === false) {
onError("insecureContext");
return;
}
const mediaDevices = navigator.mediaDevices;
const MediaRecorderCtor = mediaRecorderConstructor();
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
+4
View File
@@ -584,6 +584,9 @@
"description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App",
"mcpLabel": "Integration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin enabled",
"pluginDisable": "Disable",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "Ready",
@@ -1147,6 +1150,7 @@
"recordingStatus": "Recording {{time}}"
},
"voiceErrors": {
"insecureContext": "Chrome and other browsers block microphone access on remote HTTP pages for security. Open this WebUI over HTTPS to use voice input.",
"unsupported": "Voice input is not supported in this browser.",
"permission": "Allow microphone access in the address bar, then retry.",
"notConfigured": "Configure a transcription provider first.",
+4
View File
@@ -571,6 +571,9 @@
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "Aplicación",
"mcpLabel": "Integración",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activado",
"pluginDisable": "Desactivar",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Listo",
@@ -1134,6 +1137,7 @@
"recordingStatus": "Grabando {{time}}"
},
"voiceErrors": {
"insecureContext": "Por seguridad, Chrome y otros navegadores bloquean el acceso al micrófono en páginas HTTP remotas. Abre esta WebUI mediante HTTPS para usar la entrada de voz.",
"unsupported": "Este navegador no admite entrada de voz.",
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
"notConfigured": "Configura primero un proveedor de transcripción.",
+4
View File
@@ -570,6 +570,9 @@
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "Application",
"mcpLabel": "Intégration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activé",
"pluginDisable": "Désactiver",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Prêts",
@@ -1133,6 +1136,7 @@
"recordingStatus": "Enregistrement {{time}}"
},
"voiceErrors": {
"insecureContext": "Pour des raisons de sécurité, Chrome et dautres navigateurs bloquent laccès au microphone sur les pages HTTP distantes. Ouvrez cette WebUI en HTTPS pour utiliser la saisie vocale.",
"unsupported": "La saisie vocale n'est pas prise en charge par ce navigateur.",
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
+4
View File
@@ -570,6 +570,9 @@
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi",
"mcpLabel": "Integrasi",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin aktif",
"pluginDisable": "Nonaktifkan",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Siap",
@@ -1133,6 +1136,7 @@
"recordingStatus": "Merekam {{time}}"
},
"voiceErrors": {
"insecureContext": "Demi keamanan, Chrome dan browser lain memblokir akses mikrofon pada halaman HTTP jarak jauh. Buka WebUI ini melalui HTTPS untuk menggunakan input suara.",
"unsupported": "Input suara tidak didukung di browser ini.",
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
+4
View File
@@ -570,6 +570,9 @@
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ",
"mcpLabel": "連携",
"pluginLabel": "プラグイン",
"pluginEnabled": "プラグインは有効です",
"pluginDisable": "無効にする",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "使用可能",
@@ -1133,6 +1136,7 @@
"recordingStatus": "録音中 {{time}}"
},
"voiceErrors": {
"insecureContext": "セキュリティ上、Chrome などのブラウザーはリモートの HTTP ページからのマイクアクセスをブロックします。音声入力を使用するには、この WebUI を HTTPS で開いてください。",
"unsupported": "このブラウザーは音声入力に対応していません。",
"permission": "アドレスバーでマイクを許可してから再試行してください。",
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
+4
View File
@@ -570,6 +570,9 @@
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "앱",
"mcpLabel": "연동",
"pluginLabel": "플러그인",
"pluginEnabled": "플러그인 활성화됨",
"pluginDisable": "비활성화",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "사용 가능",
@@ -1133,6 +1136,7 @@
"recordingStatus": "녹음 중 {{time}}"
},
"voiceErrors": {
"insecureContext": "보안을 위해 Chrome 및 기타 브라우저는 원격 HTTP 페이지의 마이크 접근을 차단합니다. 음성 입력을 사용하려면 HTTPS로 이 WebUI를 여세요.",
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
+4
View File
@@ -584,6 +584,9 @@
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
"cliLabel": "Aplicativo",
"mcpLabel": "Integração",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin ativado",
"pluginDisable": "Desativar",
"channelLabel": "Canal",
"featureLabel": "Recurso",
"filterAll": "Prontos",
@@ -1147,6 +1150,7 @@
"recordingStatus": "Gravando {{time}}"
},
"voiceErrors": {
"insecureContext": "Por segurança, o Chrome e outros navegadores bloqueiam o acesso ao microfone em páginas HTTP remotas. Abra esta WebUI por HTTPS para usar a entrada de voz.",
"unsupported": "A entrada de voz não é compatível com este navegador.",
"permission": "Permita o microfone na barra de endereço e tente novamente.",
"notConfigured": "Configure primeiro um provedor de transcrição.",
+4
View File
@@ -570,6 +570,9 @@
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"cliLabel": "Ứng dụng",
"mcpLabel": "Tích hợp",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin đã bật",
"pluginDisable": "Tắt",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Sẵn sàng",
@@ -1133,6 +1136,7 @@
"recordingStatus": "Đang ghi {{time}}"
},
"voiceErrors": {
"insecureContext": "Vì lý do bảo mật, Chrome và các trình duyệt khác chặn quyền truy cập micrô trên các trang HTTP từ xa. Mở WebUI này qua HTTPS để dùng tính năng nhập bằng giọng nói.",
"unsupported": "Trình duyệt này không hỗ trợ nhập bằng giọng nói.",
"permission": "Cho phép micrô trong thanh địa chỉ rồi thử lại.",
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
+4
View File
@@ -584,6 +584,9 @@
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
"cliLabel": "应用",
"mcpLabel": "集成",
"pluginLabel": "插件",
"pluginEnabled": "插件已启用",
"pluginDisable": "停用",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "可用",
@@ -1146,6 +1149,7 @@
"recordingStatus": "正在录音 {{time}}"
},
"voiceErrors": {
"insecureContext": "出于安全考虑,Chrome 等浏览器会阻止远程 HTTP 页面访问麦克风。请通过 HTTPS 打开此 WebUI 以使用语音输入。",
"unsupported": "当前浏览器不支持语音输入。",
"permission": "请在地址栏允许麦克风后重试。",
"notConfigured": "请先配置转写提供商。",
+4
View File
@@ -570,6 +570,9 @@
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
"cliLabel": "應用程式",
"mcpLabel": "整合",
"pluginLabel": "外掛",
"pluginEnabled": "外掛已啟用",
"pluginDisable": "停用",
"channelLabel": "通訊管道",
"featureLabel": "功能",
"filterAll": "就緒",
@@ -1133,6 +1136,7 @@
"recordingStatus": "正在錄音 {{time}}"
},
"voiceErrors": {
"insecureContext": "基於安全考量,Chrome 等瀏覽器會阻止遠端 HTTP 頁面存取麥克風。請透過 HTTPS 開啟此 WebUI 以使用語音輸入。",
"unsupported": "目前瀏覽器不支援語音輸入。",
"permission": "請在網址列允許麥克風後重試。",
"notConfigured": "請先設定轉寫供應商。",
+3 -2
View File
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -304,6 +304,7 @@ describe("App layout", () => {
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
@@ -2252,7 +2253,7 @@ describe("App layout", () => {
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
await user.click(within(sidebar).getByRole("button", { name: "Settings" }));
expect(
await screen.findByRole("navigation", { name: "Settings sections" }),
+66 -1
View File
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -399,9 +399,14 @@ describe("SettingsView Apps catalog", () => {
dispatchEvent: vi.fn(),
})),
);
vi.stubGlobal(
"fetch",
vi.fn(() => new Promise<Response>(() => {})),
);
});
afterEach(() => {
cleanup();
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers();
vi.unstubAllGlobals();
@@ -574,6 +579,66 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("sets up and enables an installed Agent Plugin explicitly", async () => {
const plugin = {
name: "plugin-computer-use",
display_name: "Computer Use",
description: "Control the desktop with a live preview.",
category: "Productivity",
docs_url: "https://github.com/nanobot-dev/computer-use",
transport: "stdio",
requires: "screen-recording, accessibility",
note: "",
install_supported: true,
installed: true,
configured: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#ff7a1a",
required_fields: [],
connection_summary: "computer-use",
source: "agent-plugin",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [plugin], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets/enable?name=plugin-computer-use") {
return jsonResponse({
presets: [{ ...plugin, configured: true, available: true, status: "configured" }],
installed_count: 1,
last_action: { ok: true, message: "Computer Use enabled." },
});
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView();
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
expect(screen.getByText("Plugin")).toBeInTheDocument();
expect(
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/mcp-presets/enable?name=plugin-computer-use",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plugin enabled" })).toBeInTheDocument();
});
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
+43
View File
@@ -651,6 +651,49 @@ describe("ThreadComposer", () => {
expect(onSend).not.toHaveBeenCalled();
});
it("explains the HTTPS requirement for voice input on an insecure origin", async () => {
const { getUserMedia } = mockVoiceRecorder();
vi.stubGlobal("isSecureContext", false);
render(
<ThreadComposer
onSend={vi.fn()}
onTranscribeAudio={vi.fn(async () => "unused")}
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
expect(
await screen.findByText(
"Chrome and other browsers block microphone access on remote HTTP pages for security. "
+ "Open this WebUI over HTTPS to use voice input.",
),
).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("max-h-24");
expect(getUserMedia).not.toHaveBeenCalled();
});
it("keeps the unsupported-browser error for secure origins without recording support", async () => {
const { getUserMedia } = mockVoiceRecorder();
vi.stubGlobal("isSecureContext", true);
vi.stubGlobal("MediaRecorder", undefined);
render(
<ThreadComposer
onSend={vi.fn()}
onTranscribeAudio={vi.fn(async () => "unused")}
placeholder="Type your message..."
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Voice input" }));
expect(
await screen.findByText("Voice input is not supported in this browser."),
).toBeInTheDocument();
expect(getUserMedia).not.toHaveBeenCalled();
});
it("converts voice recordings to wav for Xiaomi MiMo transcription", async () => {
mockVoiceRecorder(new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/webm" }));
const { decodeAudioData } = mockVoiceAudioInput(
+4 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -56,7 +56,9 @@ describe("ThreadMessages", () => {
});
it("preserves an answer's markdown tree across completion and the next prompt", async () => {
await preloadMarkdownText();
await act(async () => {
await preloadMarkdownText();
});
const turnId = "turn-1";
const streaming: UIMessage[] = [
{
+12
View File
@@ -161,6 +161,18 @@ export default defineConfig(({ mode }) => {
environment: "happy-dom",
globals: true,
setupFiles: ["./src/tests/setup.ts"],
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/tests/**", "src/**/*.d.ts"],
reporter: ["text", "json-summary"],
thresholds: {
statements: 85,
branches: 80,
functions: 80,
lines: 85,
},
},
},
};
});