mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2787b2630d | ||
|
|
976b2842bc | ||
|
|
8e77f3f8a4 | ||
|
|
82e50b0f1b | ||
|
|
5d0805d9d1 | ||
|
|
66ecc0e3ec | ||
|
|
7c44bd17b9 | ||
|
|
3700e1a9f7 | ||
|
|
839c13d46f | ||
|
|
d531d1cdf8 | ||
|
|
8b03beef27 | ||
|
|
3529bda1b2 | ||
|
|
11fe666f7b | ||
|
|
e4b040d9cb | ||
|
|
ff39344ad6 | ||
|
|
c4802344fa | ||
|
|
b3b0517611 | ||
|
|
c281e090d0 | ||
|
|
85a452e5c7 | ||
|
|
05d73803e7 | ||
|
|
5d733b1c7c | ||
|
|
71a99b0780 | ||
|
|
43511decc9 | ||
|
|
8dd2059be3 | ||
|
|
7b1646f58c | ||
|
|
e620944150 | ||
|
|
66316f21da | ||
|
|
55ecda275d | ||
|
|
411d6061ae |
@@ -173,7 +173,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Test WebUI
|
- name: Test WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun run test
|
run: bun run test:coverage
|
||||||
|
|
||||||
- name: Build WebUI
|
- name: Build WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
|||||||
|
|
||||||
## 🌐 WebUI
|
## 🌐 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">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<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:
|
Use it to:
|
||||||
|
|
||||||
- keep separate topics for different tasks and projects;
|
- 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;
|
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||||
- switch models and workspaces without leaving the conversation;
|
- 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).
|
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
|||||||
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
| 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 |
|
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||||
| MCP | Add `tools.mcpServers` config |
|
| 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.
|
Prefer existing registry/discovery patterns over ad hoc wiring.
|
||||||
|
|
||||||
|
|||||||
+62
-4
@@ -1971,15 +1971,52 @@ Add MCP servers to your `config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Two transport modes are supported:
|
MCP servers can run locally over stdio or connect remotely over HTTP:
|
||||||
|
|
||||||
| Mode | Config | Example |
|
| Connection | Config | Example |
|
||||||
|------|--------|---------|
|
|------|--------|---------|
|
||||||
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
|
||||||
| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
|
| **Streamable HTTP / SSE** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/mcp`) |
|
||||||
|
|
||||||
|
Remote HTTP servers may use browser OAuth instead of static headers. In the
|
||||||
|
WebUI, open **Apps → MCP → Add MCP server**, choose **Custom**, select HTTP or
|
||||||
|
SSE, and choose **OAuth** under **Authentication**. Save the server, then choose
|
||||||
|
**Connect**. For manual configuration, add `auth: "oauth"` and open
|
||||||
|
**Apps → MCP** to connect. Known presets such as Xmind, Notion, and Linear add
|
||||||
|
the config automatically on first click.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcpServers": {
|
||||||
|
"notion": {
|
||||||
|
"type": "streamableHttp",
|
||||||
|
"url": "https://mcp.notion.com/mcp",
|
||||||
|
"auth": "oauth"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
nanobot opens the server's authorization page and handles the callback through
|
||||||
|
the gateway. The tools become available immediately when hot reload succeeds;
|
||||||
|
otherwise the WebUI asks for a restart. OAuth tokens and dynamic client
|
||||||
|
registration data are stored in the nanobot data directory under
|
||||||
|
`auth/mcp.json`; they are not written to `config.json`. Removing the MCP server
|
||||||
|
from Apps also removes its saved OAuth credentials. Normal gateway startup never
|
||||||
|
opens a browser or registers a new OAuth client when credentials are
|
||||||
|
missing—interactive authorization starts only after a user clicks **Connect**.
|
||||||
|
|
||||||
|
For a remotely accessed WebUI, HTTPS is recommended. Configure
|
||||||
|
`channels.websocket.publicWsUrl` with the browser-facing `wss://` endpoint so
|
||||||
|
nanobot can register the matching HTTPS callback and finish automatically. A
|
||||||
|
loopback WebUI may use HTTP. When a remote WebUI is served over plain HTTP,
|
||||||
|
nanobot instead registers a localhost callback and asks you to paste the complete
|
||||||
|
callback URL from the browser address bar after authorization.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request is validated again before redirects are followed. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
> HTTP/SSE MCP URLs are validated before probing or connecting, and every outgoing MCP HTTP request—including OAuth metadata, client registration, token exchange, and redirects—is validated again. `localhost`, `127.0.0.1`, RFC1918/private IPs, CGNAT/Tailscale ranges, link-local addresses, and cloud metadata endpoints are blocked by default. This can break previously working local or private HTTP MCP configs until the endpoint is explicitly allowed with `tools.ssrfWhitelist`, preferably with a single-host CIDR such as `127.0.0.1/32`, `::1/128`, or `192.168.1.50/32`. Stdio MCP servers are not affected.
|
||||||
|
|
||||||
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
Use `toolTimeout` to override the default 30s per-call timeout for slow servers:
|
||||||
|
|
||||||
@@ -2306,6 +2343,27 @@ 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. |
|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
|
||||||
|
|
||||||
|
### Agent Plugins v1
|
||||||
|
|
||||||
|
nanobot discovers [Agent Plugins](https://agent-plugins.org/) in
|
||||||
|
`<workspace>/plugins/<plugin>/`. A v1 package has `plugin.json` and may add `mcp.json`,
|
||||||
|
`skills/<name>/SKILL.md`, or both.
|
||||||
|
|
||||||
|
Directory presence means installed; activation is an explicit trust decision in **Apps**.
|
||||||
|
Enabled skills use normal progressive loading and `$skill-name` invocation. Workspace skills
|
||||||
|
override plugin skills, which override built-ins. Enabled `stdio` servers from `mcp.json` receive
|
||||||
|
contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit `tools.mcpServers` entries win
|
||||||
|
name collisions. Invalid manifests, components, nested skills, and escaping paths are ignored.
|
||||||
|
|
||||||
|
Enabled plugins run as the nanobot user; declared permissions are descriptive, not an OS sandbox.
|
||||||
|
The optional `extensions.dev.nanobot.installCommand` is a shell-free argv run once per version
|
||||||
|
before local enable. Remote setup requires `tools.webuiAllowRemotePackageInstall`. The optional
|
||||||
|
`extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
||||||
|
|
||||||
|
WebUI-installed CLI Apps use the same package layout as skills-only plugins. Their external
|
||||||
|
executables remain managed by the CLI Apps installer; update refreshes the package and uninstall
|
||||||
|
removes it. Future catalogs can acquire and place packages before using this same activation path.
|
||||||
|
|
||||||
## Tool Hint Max Length
|
## Tool 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.
|
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
|
||||||
|
|||||||
@@ -30,10 +30,15 @@ remote HTTP endpoint.
|
|||||||
For local interactive setup:
|
For local interactive setup:
|
||||||
|
|
||||||
1. Run `nanobot webui` and open **Apps**.
|
1. Run `nanobot webui` and open **Apps**.
|
||||||
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
2. Choose a known MCP server preset, or add a custom stdio, HTTP, or SSE server.
|
||||||
|
For a custom OAuth server, choose **OAuth** under **Authentication**, save it,
|
||||||
|
and click **Connect**. Presets such as Xmind, Notion, and Linear go straight to
|
||||||
|
**Connect**. Approve access in the browser window. HTTPS and localhost WebUIs
|
||||||
|
return automatically. From a remote plain-HTTP WebUI, copy the complete
|
||||||
|
localhost callback URL from the browser address bar and paste it into nanobot.
|
||||||
3. Limit the enabled tools when the server exposes more than the task needs.
|
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||||
4. Save and restart when prompted.
|
4. Save and restart when prompted.
|
||||||
5. Mention the integration with `@` in the next message and ask for a small test action.
|
5. Mention the connected MCP server with `@` in the next message and ask for a small test action.
|
||||||
|
|
||||||
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||||
|
|
||||||
@@ -58,12 +63,16 @@ Restart nanobot and ask a question that requires the MCP tool.
|
|||||||
- Prefer `enabledTools` over exposing every tool by default.
|
- Prefer `enabledTools` over exposing every tool by default.
|
||||||
- Use `toolTimeout` for slow MCP operations.
|
- Use `toolTimeout` for slow MCP operations.
|
||||||
- Use HTTP MCP only for endpoints you trust.
|
- Use HTTP MCP only for endpoints you trust.
|
||||||
|
- For deployment-managed OAuth servers, set `auth` to `oauth` and complete the
|
||||||
|
browser connection from **Apps → MCP**.
|
||||||
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
- Keep MCP server commands stable and versioned in deployment docs or scripts.
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- Stdio MCP starts a local process; review the command before enabling it.
|
- Stdio MCP starts a local process; review the command before enabling it.
|
||||||
- HTTP/SSE MCP uses nanobot's SSRF guard.
|
- HTTP/SSE MCP uses nanobot's SSRF guard, including OAuth discovery, registration,
|
||||||
|
token exchange, and redirects.
|
||||||
|
- OAuth credentials live in the nanobot data directory, not in `config.json`.
|
||||||
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
|
||||||
- Do not place secrets in command arguments when environment variables or
|
- Do not place secrets in command arguments when environment variables or
|
||||||
headers can be used.
|
headers can be used.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
## Chat App Problems
|
||||||
|
|||||||
+77
-23
@@ -1,10 +1,10 @@
|
|||||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
# 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
|
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
||||||
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
||||||
one place.
|
settings, and Automations in one place.
|
||||||
|
|
||||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||||
the `webui/` source directory when you are changing the frontend itself.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
separate context; use fork when you want to continue from an existing point
|
||||||
without changing the original thread.
|
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
|
The message timeline shows both user-visible replies and agent activity. Long
|
||||||
tool or reasoning sections can be expanded when you need the details.
|
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
|
previews only files under the selected workspace. Full Access can preview files
|
||||||
outside the workspace when that access mode is allowed by the gateway.
|
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
|
## Workspace and Access
|
||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
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
|
The composer supports plain messages, image attachments, voice input when
|
||||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
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
|
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
|
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
|
is relevant and can link it in the response. The model badge shows the current
|
||||||
@@ -177,8 +204,13 @@ turn. The default **Ready** view shows only tools that can be used immediately:
|
|||||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||||
Installing an adapter does not modify the native desktop or web app it
|
Installing an adapter does not modify the native desktop or web app it
|
||||||
connects to.
|
connects to.
|
||||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
- **MCP** lists Model Context Protocol servers. Presets provide known
|
||||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
configurations, and the **Add MCP server** panel accepts stdio, HTTP, and SSE
|
||||||
|
servers. Custom HTTP/SSE servers can use no authentication, OAuth, or request
|
||||||
|
headers. After saving an OAuth server, choose **Connect** to open its sign-in
|
||||||
|
page. Presets such as Xmind, Notion, and Linear already use OAuth. HTTPS and
|
||||||
|
localhost WebUIs return automatically; a remote plain-HTTP WebUI shows one
|
||||||
|
field for pasting the complete localhost callback URL.
|
||||||
|
|
||||||
Apps intentionally does not list nanobot runtime support packages such as
|
Apps intentionally does not list nanobot runtime support packages such as
|
||||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||||
@@ -199,15 +231,25 @@ endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
|
|||||||
It is an optional integration and does not replace nanobot's built-in web search
|
It is an optional integration and does not replace nanobot's built-in web search
|
||||||
provider; mention `@parallel-search` when a turn should use it.
|
provider; mention `@parallel-search` when a turn should use it.
|
||||||
|
|
||||||
After an App or integration is available, mention it from the composer with
|
After an App or MCP server is available, mention it from the composer with `@`
|
||||||
`@` to attach that tool to the next message.
|
to attach that tool to the next message.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
The Skills view shows the skill instructions available to the agent, including
|
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
||||||
built-in skills and workspace-provided skills. Check this view when you want to
|
You can search and filter them, inspect their instructions and setup
|
||||||
know whether nanobot already has a focused workflow for a task before you ask it
|
requirements, enable or disable them, and delete workspace skills you no longer
|
||||||
to perform that task.
|
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
|
## Automations
|
||||||
|
|
||||||
@@ -288,10 +330,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
|
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||||
form.
|
form.
|
||||||
|
|
||||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
||||||
install missing nanobot support packages, such as adding a channel dependency,
|
capture only in secure contexts. Voice input works on same-machine localhost;
|
||||||
are blocked by default. To let trusted remote administrators change the Python
|
from another device, serve the WebUI over HTTPS with a certificate that device
|
||||||
environment through the WebUI, opt in explicitly:
|
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
|
```json
|
||||||
{
|
{
|
||||||
@@ -302,12 +351,13 @@ environment through the WebUI, opt in explicitly:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use this only for a private deployment where every authenticated WebUI user is
|
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
|
trusted to change nanobot's Python environment and install workspace skill
|
||||||
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
||||||
as remote access and leave package installs disabled unless that is intentional.
|
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
|
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
|
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||||
private, trusted network.
|
private, trusted network.
|
||||||
@@ -322,6 +372,10 @@ If the page does not open, check these in order:
|
|||||||
4. You are opening port `8765`, not the gateway health port.
|
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.
|
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
|
For detailed diagnostics, see
|
||||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||||
|
|||||||
+14
-4
@@ -36,6 +36,7 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
|
|||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.agent.turn_delivery import (
|
from nanobot.agent.turn_delivery import (
|
||||||
TurnDelivery,
|
TurnDelivery,
|
||||||
@@ -197,6 +198,11 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
return self.tools.tool_names
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_usage(self) -> Mapping[str, int]:
|
||||||
|
"""Latest aggregate usage exposed through the runtime-control snapshot."""
|
||||||
|
return self._last_usage
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> LLMProvider:
|
def provider(self) -> LLMProvider:
|
||||||
"""Provider selected for future turn admissions."""
|
"""Provider selected for future turn admissions."""
|
||||||
@@ -448,7 +454,6 @@ class AgentLoop:
|
|||||||
if model_preset:
|
if model_preset:
|
||||||
self.set_model_preset(model_preset, publish_update=False)
|
self.set_model_preset(model_preset, publish_update=False)
|
||||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||||
self._runtime_vars: dict[str, Any] = {}
|
|
||||||
self._current_iteration: int = 0
|
self._current_iteration: int = 0
|
||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
register_builtin_commands(self.commands)
|
register_builtin_commands(self.commands)
|
||||||
@@ -480,6 +485,8 @@ class AgentLoop:
|
|||||||
config,
|
config,
|
||||||
provider_snapshot_loader,
|
provider_snapshot_loader,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -494,7 +501,7 @@ class AgentLoop:
|
|||||||
provider_retry_mode=defaults.provider_retry_mode,
|
provider_retry_mode=defaults.provider_retry_mode,
|
||||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
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,
|
channels_config=config.channels,
|
||||||
timezone=defaults.timezone,
|
timezone=defaults.timezone,
|
||||||
unified_session=defaults.unified_session,
|
unified_session=defaults.unified_session,
|
||||||
@@ -623,10 +630,13 @@ class AgentLoop:
|
|||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
registered = loader.load(ctx, self.tools)
|
registered = loader.load(ctx, self.tools)
|
||||||
|
|
||||||
# MyTool needs runtime state reference — manual registration
|
# MyTool receives only the explicit runtime-control capability.
|
||||||
if self.tools_config.my.enable:
|
if self.tools_config.my.enable:
|
||||||
self.tools.register(
|
self.tools.register(
|
||||||
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
MyTool(
|
||||||
|
runtime_control=AgentRuntimeControl(self),
|
||||||
|
modify_allowed=self.tools_config.my.allow_set,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
registered.append("my")
|
registered.append("my")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""Load and activate locally installed Agent Plugin packages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
|
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||||
|
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
||||||
|
|
||||||
|
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||||
|
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
|
||||||
|
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
||||||
|
_SETUP_TIMEOUT_SECONDS = 600
|
||||||
|
_MAX_LOGO_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentPlugin:
|
||||||
|
"""A validated, locally installed Agent Plugins v1 package."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
root: Path
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
repository: str
|
||||||
|
display_name: str
|
||||||
|
category: str
|
||||||
|
accent_color: str | None
|
||||||
|
logo: Path | None
|
||||||
|
permissions: tuple[str, ...]
|
||||||
|
install_command: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentPluginState:
|
||||||
|
"""Runtime state for one discovered Agent Plugin."""
|
||||||
|
|
||||||
|
plugin: AgentPlugin
|
||||||
|
mcp_servers: tuple[str, ...]
|
||||||
|
enabled: bool
|
||||||
|
setup_required: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||||
|
"""Return installed packages found under ``<workspace>/plugins/*``."""
|
||||||
|
workspace = workspace.expanduser().resolve()
|
||||||
|
root = _contained_directory(workspace / "plugins", workspace)
|
||||||
|
if root is None:
|
||||||
|
return []
|
||||||
|
plugins: list[AgentPlugin] = []
|
||||||
|
for candidate in _children(root, "Agent Plugins directory"):
|
||||||
|
plugin_root = _contained_directory(candidate, root)
|
||||||
|
if plugin_root is None:
|
||||||
|
continue
|
||||||
|
plugin = _load_manifest(plugin_root)
|
||||||
|
if plugin is not None:
|
||||||
|
plugins.append(plugin)
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
|
||||||
|
"""Return skills from plugins the user has explicitly enabled."""
|
||||||
|
return [
|
||||||
|
skill
|
||||||
|
for plugin in _discover_agent_plugins(workspace)
|
||||||
|
if _enabled(workspace, plugin.name)
|
||||||
|
for skill in _discover_plugin_skills(plugin.name, plugin.root)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||||
|
payload = _read_object(plugin_root / "plugin.json", plugin_root)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
|
||||||
|
return None
|
||||||
|
name = payload.get("name")
|
||||||
|
if (
|
||||||
|
not isinstance(name, str)
|
||||||
|
or len(name) > 64
|
||||||
|
or _PLUGIN_NAME.fullmatch(name) is None
|
||||||
|
):
|
||||||
|
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
|
||||||
|
return None
|
||||||
|
extension = payload.get("extensions")
|
||||||
|
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
|
||||||
|
nanobot_value = extension_payload.get("dev.nanobot")
|
||||||
|
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
|
||||||
|
return AgentPlugin(
|
||||||
|
name=name,
|
||||||
|
root=plugin_root,
|
||||||
|
version=_string(payload.get("version")),
|
||||||
|
description=_string(payload.get("description")),
|
||||||
|
repository=_string(payload.get("repository")),
|
||||||
|
display_name=_string(nanobot.get("displayName")) or name,
|
||||||
|
category=_string(nanobot.get("category")) or "Plugin",
|
||||||
|
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||||
|
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
|
||||||
|
permissions=_string_tuple(nanobot.get("permissions")),
|
||||||
|
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_plugin_mcp_servers(
|
||||||
|
workspace: Path,
|
||||||
|
configured: dict[str, MCPServerConfig] | None = None,
|
||||||
|
) -> dict[str, MCPServerConfig]:
|
||||||
|
"""Merge explicitly enabled plugin MCP servers with user configuration.
|
||||||
|
|
||||||
|
User configuration wins on the unlikely event of a namespaced collision.
|
||||||
|
"""
|
||||||
|
servers: dict[str, MCPServerConfig] = {}
|
||||||
|
for plugin in _discover_agent_plugins(workspace):
|
||||||
|
if not _enabled(workspace, plugin.name):
|
||||||
|
continue
|
||||||
|
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||||
|
for name, server in plugin_servers.items():
|
||||||
|
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
|
||||||
|
servers[host_name] = server
|
||||||
|
configured = configured or {}
|
||||||
|
if collisions := servers.keys() & configured.keys():
|
||||||
|
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
|
||||||
|
return servers | configured
|
||||||
|
|
||||||
|
|
||||||
|
def discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
|
||||||
|
"""Return component and lifecycle state for discovered plugins."""
|
||||||
|
return [
|
||||||
|
AgentPluginState(
|
||||||
|
plugin=plugin,
|
||||||
|
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
|
||||||
|
enabled=_enabled(workspace, plugin.name),
|
||||||
|
setup_required=bool(plugin.install_command)
|
||||||
|
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
|
||||||
|
)
|
||||||
|
for plugin in _discover_agent_plugins(workspace)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> AgentPlugin:
|
||||||
|
"""Enable or disable one installed plugin."""
|
||||||
|
plugin = next((item for item in _discover_agent_plugins(workspace) if item.name == name), None)
|
||||||
|
if plugin is None:
|
||||||
|
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||||
|
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||||
|
version = plugin.version or "unknown"
|
||||||
|
with FileLock(str(data / ".state.lock"), timeout=_SETUP_TIMEOUT_SECONDS + 10):
|
||||||
|
if enabled:
|
||||||
|
if plugin.install_command and _setup_version(workspace, plugin.name) != version:
|
||||||
|
_run_install(plugin, data)
|
||||||
|
_write_state(data / "setup-version", version)
|
||||||
|
_write_state(data / "enabled", "1")
|
||||||
|
else:
|
||||||
|
(data / "enabled").unlink(missing_ok=True)
|
||||||
|
return plugin
|
||||||
|
|
||||||
|
|
||||||
|
def _string(value: object) -> str:
|
||||||
|
return value.strip() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||||
|
items = cast(list[object], value) if isinstance(value, list) else []
|
||||||
|
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _accent_color(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
|
||||||
|
"""Resolve nanobot's optional packaged logo extension."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value.startswith("./"):
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||||
|
return None
|
||||||
|
logo = _contained_file(plugin_root / value[2:], plugin_root)
|
||||||
|
try:
|
||||||
|
data = logo.read_bytes() if logo is not None else b""
|
||||||
|
suffix = logo.suffix.lower() if logo is not None else ""
|
||||||
|
if len(data) <= _MAX_LOGO_BYTES and (
|
||||||
|
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
|
||||||
|
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
|
||||||
|
):
|
||||||
|
return logo
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
|
||||||
|
"""Validate nanobot's optional, shell-free setup command extension."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
items = cast(list[object], value)
|
||||||
|
if not 1 <= len(items) <= 32 or not all(
|
||||||
|
isinstance(item, str) and 0 < len(item) <= 4096 for item in items
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
command = cast(str, items[0])
|
||||||
|
if not command.startswith("./"):
|
||||||
|
logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root)
|
||||||
|
return ()
|
||||||
|
executable = _contained_file(plugin_root / command[2:], plugin_root)
|
||||||
|
if executable is None:
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root)
|
||||||
|
return ()
|
||||||
|
return (str(executable), *(cast(str, item) for item in items[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
|
||||||
|
payload = _read_object(plugin.root / "mcp.json", plugin.root)
|
||||||
|
if payload is None:
|
||||||
|
return {}
|
||||||
|
raw_servers = payload.get("mcpServers")
|
||||||
|
if payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA or not isinstance(raw_servers, dict):
|
||||||
|
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||||
|
servers: dict[str, MCPServerConfig] = {}
|
||||||
|
for name, raw in cast(dict[str, object], raw_servers).items():
|
||||||
|
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
|
||||||
|
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
|
||||||
|
continue
|
||||||
|
server = _plugin_mcp_server(raw, plugin.root, data)
|
||||||
|
if server is None:
|
||||||
|
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
|
||||||
|
continue
|
||||||
|
servers[name] = server
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
payload = cast(dict[str, object], raw)
|
||||||
|
if payload.keys() - _MCP_SERVER_FIELDS:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
server = MCPServerConfig.model_validate(payload)
|
||||||
|
except ValidationError:
|
||||||
|
return None
|
||||||
|
command = _stdio_command(server.command, root)
|
||||||
|
cwd = _stdio_cwd(payload.get("cwd"), root, data)
|
||||||
|
if server.type != "stdio" or command is None or cwd is None:
|
||||||
|
return None
|
||||||
|
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
|
||||||
|
return None
|
||||||
|
return server.model_copy(
|
||||||
|
update={
|
||||||
|
"command": command,
|
||||||
|
"args": [_expand(item, root, data) for item in server.args],
|
||||||
|
"env": {
|
||||||
|
**{key: _expand(value, root, data) for key, value in server.env.items()},
|
||||||
|
"PLUGIN_ROOT": str(root),
|
||||||
|
"PLUGIN_DATA": str(data),
|
||||||
|
},
|
||||||
|
"cwd": str(cwd),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stdio_command(value: object, root: Path) -> str | None:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith("./"):
|
||||||
|
executable = _contained_file(root / value[2:], root)
|
||||||
|
return str(executable) if executable is not None else None
|
||||||
|
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
|
||||||
|
if value is None:
|
||||||
|
return root
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
if value.startswith("./"):
|
||||||
|
return _contained_directory(root / value[2:], root)
|
||||||
|
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
|
||||||
|
if value == placeholder or value.startswith(f"{placeholder}/"):
|
||||||
|
relative = value[len(placeholder):].lstrip("/")
|
||||||
|
candidate = (base / relative).resolve()
|
||||||
|
if not candidate.is_relative_to(base):
|
||||||
|
return None
|
||||||
|
if base == data:
|
||||||
|
candidate.mkdir(parents=True, exist_ok=True)
|
||||||
|
candidate.chmod(0o700)
|
||||||
|
return candidate if candidate.is_dir() else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _expand(value: str, root: Path, data: Path) -> str:
|
||||||
|
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
||||||
|
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||||
|
config_root = get_config_path().expanduser().resolve().parent
|
||||||
|
plugin_root = _private_directory(config_root / "plugin-data", config_root, create=create)
|
||||||
|
state_root = _private_directory(plugin_root / workspace_id, plugin_root, create=create)
|
||||||
|
data = state_root / name
|
||||||
|
return _private_directory(data, state_root, create=True) if create else data
|
||||||
|
|
||||||
|
|
||||||
|
def _private_directory(path: Path, root: Path, *, create: bool) -> Path:
|
||||||
|
if create:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=create)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
|
||||||
|
if not resolved.is_relative_to(root):
|
||||||
|
raise RuntimeError("Agent Plugin data directory escapes its parent")
|
||||||
|
if create:
|
||||||
|
resolved.chmod(0o700)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(workspace: Path, name: str) -> bool:
|
||||||
|
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_version(workspace: Path, name: str) -> str:
|
||||||
|
try:
|
||||||
|
return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
).strip()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(path: Path, value: str) -> None:
|
||||||
|
path.write_text(value, encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_install(plugin: AgentPlugin, data: Path) -> None:
|
||||||
|
env = {
|
||||||
|
**{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None},
|
||||||
|
"PLUGIN_ROOT": str(plugin.root),
|
||||||
|
"PLUGIN_DATA": str(data),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
plugin.install_command,
|
||||||
|
cwd=plugin.root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=_SETUP_TIMEOUT_SECONDS,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise RuntimeError(f"{plugin.display_name} setup timed out") from exc
|
||||||
|
if result.returncode:
|
||||||
|
output = (result.stderr or result.stdout).strip()[-2000:]
|
||||||
|
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||||
|
skills_root = _contained_directory(plugin_root / "skills", plugin_root)
|
||||||
|
if skills_root is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
skills: list[tuple[str, Path]] = []
|
||||||
|
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
|
||||||
|
skill_root = _contained_directory(candidate, skills_root)
|
||||||
|
if skill_root is None:
|
||||||
|
continue
|
||||||
|
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
|
||||||
|
if skill_file is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
metadata = None
|
||||||
|
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
|
||||||
|
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
|
||||||
|
continue
|
||||||
|
skills.append((candidate.name, skill_file))
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def _children(root: Path, label: str) -> list[Path]:
|
||||||
|
try:
|
||||||
|
return sorted(root.iterdir(), key=lambda path: path.name)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Could not inspect {}: {}", label, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _contained_directory(path: Path, root: Path) -> Path | None:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
|
||||||
|
contained = _contained_file(path, root)
|
||||||
|
if contained is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
|
||||||
|
return None
|
||||||
|
return cast(dict[str, object], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _contained_file(path: Path, root: Path) -> Path | None:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
|
||||||
+62
-28
@@ -17,9 +17,48 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
|||||||
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||||
|
_SKILL_NAME_LINE = re.compile(r"^name\s*:.*$", re.MULTILINE)
|
||||||
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_metadata(content: str) -> dict[str, object] | None:
|
||||||
|
"""Parse a skill document's YAML frontmatter."""
|
||||||
|
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = yaml.safe_load(match.group(1))
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return None
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return None
|
||||||
|
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
|
||||||
|
"""Return whether metadata satisfies the Agent Skills identity contract."""
|
||||||
|
description = metadata.get("description")
|
||||||
|
return (
|
||||||
|
metadata.get("name") == name
|
||||||
|
and len(name) <= 64
|
||||||
|
and _SKILL_NAME.fullmatch(name) is not None
|
||||||
|
and isinstance(description, str)
|
||||||
|
and 1 <= len(description.strip()) <= 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_skill_document(content: str, name: str) -> str | None:
|
||||||
|
"""Return a valid skill document with a canonical name."""
|
||||||
|
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||||
|
metadata = parse_skill_metadata(content)
|
||||||
|
if match is None or metadata is None or not valid_skill_metadata(metadata | {"name": name}, name):
|
||||||
|
return None
|
||||||
|
frontmatter, replaced = _SKILL_NAME_LINE.subn(f"name: {name}", match.group(1), count=1)
|
||||||
|
if not replaced:
|
||||||
|
frontmatter = f"name: {name}\n{frontmatter}"
|
||||||
|
return f"---\n{frontmatter.strip()}\n---\n\n{content[match.end():].lstrip()}"
|
||||||
|
|
||||||
|
|
||||||
class SkillsLoader:
|
class SkillsLoader:
|
||||||
"""
|
"""
|
||||||
Loader for agent skills.
|
Loader for agent skills.
|
||||||
@@ -60,11 +99,25 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
List of skill info dicts with 'name', 'path', 'source'.
|
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")
|
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 name, path in plugin_skills:
|
||||||
|
if name in seen_names:
|
||||||
|
continue
|
||||||
|
skills.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"path": str(path),
|
||||||
|
"source": "plugin",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
seen_names.add(name)
|
||||||
if self.builtin_skills and self.builtin_skills.exists():
|
if self.builtin_skills and self.builtin_skills.exists():
|
||||||
skills.extend(
|
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:
|
if self.disabled_skills:
|
||||||
@@ -84,14 +137,11 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Skill content or None if not found.
|
Skill content or None if not found.
|
||||||
"""
|
"""
|
||||||
roots = [self.workspace_skills]
|
entry = next(
|
||||||
if self.builtin_skills:
|
(skill for skill in self.list_skills(filter_unavailable=False) if skill["name"] == name),
|
||||||
roots.append(self.builtin_skills)
|
None,
|
||||||
for root in roots:
|
)
|
||||||
path = root / name / "SKILL.md"
|
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
|
||||||
if path.exists():
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -145,6 +195,7 @@ class SkillsLoader:
|
|||||||
sections: list[str] = []
|
sections: list[str] = []
|
||||||
groups = (
|
groups = (
|
||||||
("Workspace skills", "workspace", self.workspace_skills),
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
|
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
|
||||||
("Built-in skills", "builtin", self.builtin_skills),
|
("Built-in skills", "builtin", self.builtin_skills),
|
||||||
)
|
)
|
||||||
for label, source, root in groups:
|
for label, source, root in groups:
|
||||||
@@ -278,21 +329,4 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Metadata dict or None.
|
Metadata dict or None.
|
||||||
"""
|
"""
|
||||||
content = self.load_skill(name)
|
return parse_skill_metadata(self.load_skill(name) or "")
|
||||||
if not content or not content.startswith("---"):
|
|
||||||
return None
|
|
||||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = yaml.safe_load(match.group(1))
|
|
||||||
except yaml.YAMLError:
|
|
||||||
return None
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return None
|
|
||||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
|
||||||
# keep values as-is so downstream consumers get correct types.
|
|
||||||
metadata: dict[str, object] = {}
|
|
||||||
for key, value in cast(dict[object, object], parsed).items():
|
|
||||||
metadata[str(key)] = value
|
|
||||||
return metadata
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, TypedDict
|
from typing import Any, Callable, TypedDict
|
||||||
@@ -157,6 +158,10 @@ class SubagentManager:
|
|||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||||
|
|
||||||
|
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
|
||||||
|
"""Return the observable task statuses used by runtime-control snapshots."""
|
||||||
|
return self._task_statuses
|
||||||
|
|
||||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
|||||||
@@ -827,7 +827,8 @@ class EditFileTool(_FsTool):
|
|||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return (
|
return (
|
||||||
"Perform a small, exact replacement in one file by replacing "
|
"Perform a small, exact replacement in one file by replacing "
|
||||||
"old_text with new_text. Use this for narrow text substitutions "
|
"old_text with new_text. When replacing text in an existing file, "
|
||||||
|
"old_text and new_text must be different. Use this for narrow text substitutions "
|
||||||
"with old_text copied from read_file. For multi-file, structural, "
|
"with old_text copied from read_file. For multi-file, structural, "
|
||||||
"or generated code edits, prefer apply_patch. If old_text matches "
|
"or generated code edits, prefer apply_patch. If old_text matches "
|
||||||
"multiple times, provide more context or set occurrence, line_hint, "
|
"multiple times, provide more context or set occurrence, line_hint, "
|
||||||
@@ -862,9 +863,12 @@ class EditFileTool(_FsTool):
|
|||||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
||||||
|
|
||||||
fp = self._resolve_write(path)
|
fp = self._resolve_write(path)
|
||||||
|
file_exists = fp.exists()
|
||||||
|
if file_exists and old_text == new_text:
|
||||||
|
return ToolResult.error("Error: new_text must be different from old_text.")
|
||||||
|
|
||||||
# Create-file semantics: old_text='' + file doesn't exist → create
|
# Create-file semantics: old_text='' + file doesn't exist → create
|
||||||
if not fp.exists():
|
if not file_exists:
|
||||||
if old_text == "":
|
if old_text == "":
|
||||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
fp.write_text(new_text, encoding="utf-8")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_SKIP_MODULES = frozenset({
|
_SKIP_MODULES = frozenset({
|
||||||
"base", "schema", "registry", "context", "loader", "config",
|
"base", "schema", "registry", "context", "loader", "config",
|
||||||
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+100
-41
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
|
|||||||
from mcp.types import Prompt, Resource
|
from mcp.types import Prompt, Resource
|
||||||
from mcp.types import Tool as MCPToolDefinition
|
from mcp.types import Tool as MCPToolDefinition
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthHandlers
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
# Transient connection errors that warrant a single retry.
|
# Transient connection errors that warrant a single retry.
|
||||||
@@ -184,6 +185,25 @@ def _is_transient(exc: BaseException) -> bool:
|
|||||||
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
return type(exc).__name__ in _TRANSIENT_EXC_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def _is_transient_connection_failure(exc: BaseException) -> bool:
|
||||||
|
if isinstance(exc, BaseExceptionGroup):
|
||||||
|
group = cast(BaseExceptionGroup[BaseException], exc)
|
||||||
|
return bool(group.exceptions) and all(
|
||||||
|
_is_transient_connection_failure(nested) for nested in group.exceptions
|
||||||
|
)
|
||||||
|
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)) or _is_transient(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_mcp_connection_failure(name: str, exc: BaseException, hint: str = "") -> None:
|
||||||
|
if _is_transient_connection_failure(exc):
|
||||||
|
logger.warning("MCP server '{}': transient connection failure", name)
|
||||||
|
logger.opt(exception=exc).debug(
|
||||||
|
"MCP server '{}' transient connection failure details", name
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logger.opt(exception=exc).error("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
|
|
||||||
|
|
||||||
def _is_session_terminated(exc: BaseException) -> bool:
|
def _is_session_terminated(exc: BaseException) -> bool:
|
||||||
"""Return True when the MCP SDK reports a dead client session."""
|
"""Return True when the MCP SDK reports a dead client session."""
|
||||||
if _is_transient(exc):
|
if _is_transient(exc):
|
||||||
@@ -961,7 +981,10 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
|
mcp_servers: "dict[str, MCPServerConfig]",
|
||||||
|
registry: ToolRegistry,
|
||||||
|
*,
|
||||||
|
oauth_handlers: Mapping[str, "MCPOAuthHandlers"] | None = None,
|
||||||
) -> dict[str, MCPConnection]:
|
) -> dict[str, MCPConnection]:
|
||||||
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
"""Connect to configured MCP servers and register their tools, resources, prompts.
|
||||||
|
|
||||||
@@ -975,11 +998,8 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
||||||
) -> tuple[str, AsyncExitStack | None]:
|
) -> bool:
|
||||||
server_stack = AsyncExitStack()
|
|
||||||
await server_stack.__aenter__()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
@@ -991,8 +1011,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type in {"sse", "streamableHttp"}:
|
if transport_type in {"sse", "streamableHttp"}:
|
||||||
ok, error = validate_url_target(cfg.url)
|
ok, error = validate_url_target(cfg.url)
|
||||||
@@ -1003,8 +1022,30 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
oauth_auth: httpx.Auth | None = None
|
||||||
|
if cfg.auth == "oauth":
|
||||||
|
if transport_type not in {"sse", "streamableHttp"}:
|
||||||
|
logger.warning(
|
||||||
|
"MCP server '{}': OAuth requires an SSE or Streamable HTTP transport",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
|
MCPAuthorizationRequiredError,
|
||||||
|
create_mcp_oauth_auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
oauth_auth = await create_mcp_oauth_auth(
|
||||||
|
name,
|
||||||
|
cfg.url,
|
||||||
|
(oauth_handlers or {}).get(name),
|
||||||
|
)
|
||||||
|
except MCPAuthorizationRequiredError:
|
||||||
|
logger.info("MCP server '{}': waiting for browser authorization", name)
|
||||||
|
return False
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1022,8 +1063,7 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1044,31 +1084,37 @@ async def connect_mcp_servers(
|
|||||||
**_pinned_transport_kwargs(),
|
**_pinned_transport_kwargs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sse_kwargs: dict[str, Any] = {
|
||||||
|
"httpx_client_factory": httpx_client_factory,
|
||||||
|
}
|
||||||
|
if oauth_auth is not None:
|
||||||
|
sse_kwargs["auth"] = oauth_auth
|
||||||
read, write = await server_stack.enter_async_context(
|
read, write = await server_stack.enter_async_context(
|
||||||
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
sse_client(cfg.url, **sse_kwargs)
|
||||||
)
|
)
|
||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client_kwargs: dict[str, Any] = {
|
||||||
httpx.AsyncClient(
|
"headers": cfg.headers or None,
|
||||||
headers=cfg.headers or None,
|
"event_hooks": {"request": [_validate_mcp_request_url]},
|
||||||
event_hooks={"request": [_validate_mcp_request_url]},
|
"follow_redirects": True,
|
||||||
follow_redirects=True,
|
"timeout": httpx.Timeout(30.0, connect=10.0),
|
||||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
||||||
**_pinned_transport_kwargs(),
|
**_pinned_transport_kwargs(),
|
||||||
)
|
}
|
||||||
|
if oauth_auth is not None:
|
||||||
|
http_client_kwargs["auth"] = oauth_auth
|
||||||
|
http_client = await server_stack.enter_async_context(
|
||||||
|
httpx.AsyncClient(**http_client_kwargs)
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write, _ = await server_stack.enter_async_context(
|
||||||
streamable_http_client(cfg.url, http_client=http_client)
|
streamable_http_client(cfg.url, http_client=http_client)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||||
@@ -1171,7 +1217,7 @@ async def connect_mcp_servers(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||||
)
|
)
|
||||||
return name, server_stack
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1190,10 +1236,8 @@ async def connect_mcp_servers(
|
|||||||
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
" Hint: this looks like stdio protocol pollution. Make sure the MCP server writes "
|
||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
_log_mcp_connection_failure(name, e, hint)
|
||||||
with suppress(Exception):
|
return False
|
||||||
await server_stack.aclose()
|
|
||||||
return name, None
|
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig"
|
||||||
@@ -1203,30 +1247,30 @@ async def connect_mcp_servers(
|
|||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
async def own_connection() -> None:
|
async def own_connection() -> None:
|
||||||
stack: AsyncExitStack | None = None
|
|
||||||
try:
|
try:
|
||||||
_, stack = await open_single_server(name, cfg)
|
async with AsyncExitStack() as stack:
|
||||||
|
connected = await open_single_server(name, cfg, stack)
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_result(stack is not None)
|
ready.set_result(connected)
|
||||||
if stack is not None:
|
if connected:
|
||||||
await close_requested.wait()
|
await close_requested.wait()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
if stack is not None:
|
|
||||||
await stack.aclose()
|
|
||||||
|
|
||||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
connection = _OwnedMCPConnection(owner, close_requested)
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
try:
|
try:
|
||||||
connected = await ready
|
connected = await ready
|
||||||
except BaseException:
|
except BaseException as exc:
|
||||||
close_requested.set()
|
close_requested.set()
|
||||||
owner.cancel()
|
owner.cancel()
|
||||||
with suppress(BaseException):
|
with suppress(BaseException):
|
||||||
await asyncio.shield(owner)
|
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
|
raise
|
||||||
if not connected:
|
if not connected:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
@@ -1239,7 +1283,7 @@ async def connect_mcp_servers(
|
|||||||
try:
|
try:
|
||||||
result = await connect_single_server(name, cfg)
|
result = await connect_single_server(name, cfg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("MCP server '{}' connection failed: {}", name, e)
|
_log_mcp_connection_failure(name, e)
|
||||||
continue
|
continue
|
||||||
if result[1] is not None:
|
if result[1] is not None:
|
||||||
server_stacks[result[0]] = result[1]
|
server_stacks[result[0]] = result[1]
|
||||||
@@ -1296,10 +1340,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
from nanobot.agent.plugins import agent_plugin_mcp_servers
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
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:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
return {
|
return {
|
||||||
@@ -1312,6 +1360,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
current_servers = dict(state._mcp_servers)
|
current_servers = dict(state._mcp_servers)
|
||||||
current_names = set(current_servers)
|
current_names = set(current_servers)
|
||||||
next_names = set(next_servers)
|
next_names = set(next_servers)
|
||||||
|
from nanobot.agent.tools.mcp_oauth import mcp_oauth_has_credentials
|
||||||
|
|
||||||
|
authorization_pending = {
|
||||||
|
name
|
||||||
|
for name, cfg in next_servers.items()
|
||||||
|
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url)
|
||||||
|
}
|
||||||
removed = sorted(current_names - next_names)
|
removed = sorted(current_names - next_names)
|
||||||
added = sorted(next_names - current_names)
|
added = sorted(next_names - current_names)
|
||||||
changed = sorted(
|
changed = sorted(
|
||||||
@@ -1329,9 +1384,13 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
retry_missing = sorted(
|
retry_missing = sorted(
|
||||||
name
|
name
|
||||||
for name in next_names
|
for name in next_names
|
||||||
if name not in state._mcp_stacks and name not in set(added) | set(changed)
|
if name not in state._mcp_stacks
|
||||||
|
and name not in set(added) | set(changed)
|
||||||
|
and name not in authorization_pending
|
||||||
|
)
|
||||||
|
to_connect_names = sorted(
|
||||||
|
(set(added) | set(changed) | set(retry_missing)) - authorization_pending
|
||||||
)
|
)
|
||||||
to_connect_names = sorted(set(added) | set(changed) | set(retry_missing))
|
|
||||||
to_connect = {name: next_servers[name] for name in to_connect_names}
|
to_connect = {name: next_servers[name] for name in to_connect_names}
|
||||||
connected: dict[str, MCPConnection] = {}
|
connected: dict[str, MCPConnection] = {}
|
||||||
if to_connect:
|
if to_connect:
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""OAuth support for remote MCP servers.
|
||||||
|
|
||||||
|
This module intentionally owns MCP OAuth end to end. Provider OAuth has a
|
||||||
|
different lifecycle and storage contract, so sharing a higher-level workflow
|
||||||
|
would couple unrelated extension boundaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypedDict, cast
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
from loguru import logger
|
||||||
|
from mcp.client.auth import OAuthClientProvider
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||||
|
from pydantic import AnyHttpUrl, AnyUrl
|
||||||
|
|
||||||
|
from nanobot.config.paths import get_data_dir
|
||||||
|
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
|
MCP_OAUTH_CALLBACK_PATH = "/auth/mcp/callback"
|
||||||
|
_STORE_VERSION = 1
|
||||||
|
_STORE_LOCK_TIMEOUT_S = 15
|
||||||
|
_DEFAULT_REDIRECT_URI = f"http://127.0.0.1{MCP_OAUTH_CALLBACK_PATH}"
|
||||||
|
_CLIENT_URI = AnyHttpUrl("https://github.com/HKUDS/nanobot")
|
||||||
|
_LOGO_URI = AnyHttpUrl(
|
||||||
|
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||||
|
"webui/public/brand/nanobot_apple_touch.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StoredServer(TypedDict, total=False):
|
||||||
|
server_fingerprint: str
|
||||||
|
write_lease: str
|
||||||
|
tokens: dict[str, Any]
|
||||||
|
client_info: dict[str, Any]
|
||||||
|
redirect_uri: str
|
||||||
|
|
||||||
|
|
||||||
|
class _CredentialStore(TypedDict):
|
||||||
|
version: int
|
||||||
|
servers: dict[str, _StoredServer]
|
||||||
|
generations: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class MCPAuthorizationRequiredError(RuntimeError):
|
||||||
|
"""Raised when a background MCP connection needs interactive authorization."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MCPOAuthHandlers:
|
||||||
|
"""Browser callbacks supplied only for a user-initiated OAuth attempt."""
|
||||||
|
|
||||||
|
redirect_uri: str
|
||||||
|
redirect_handler: Callable[[str], Awaitable[None]]
|
||||||
|
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]]
|
||||||
|
reset_credentials: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _store_path() -> Path:
|
||||||
|
return get_data_dir() / "auth" / "mcp.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _server_fingerprint(server_url: str) -> str:
|
||||||
|
return hashlib.sha256(server_url.strip().encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_store() -> _CredentialStore:
|
||||||
|
return {"version": _STORE_VERSION, "servers": {}, "generations": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_server(value: object) -> _StoredServer | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
raw = cast(dict[object, object], value)
|
||||||
|
entry: _StoredServer = {}
|
||||||
|
fingerprint = raw.get("server_fingerprint")
|
||||||
|
if isinstance(fingerprint, str):
|
||||||
|
entry["server_fingerprint"] = fingerprint
|
||||||
|
write_lease = raw.get("write_lease")
|
||||||
|
if isinstance(write_lease, str) and write_lease:
|
||||||
|
entry["write_lease"] = write_lease
|
||||||
|
redirect_uri = raw.get("redirect_uri")
|
||||||
|
if isinstance(redirect_uri, str):
|
||||||
|
entry["redirect_uri"] = redirect_uri
|
||||||
|
tokens = raw.get("tokens")
|
||||||
|
if isinstance(tokens, dict):
|
||||||
|
token_values = cast(dict[object, object], tokens)
|
||||||
|
if all(isinstance(key, str) for key in token_values):
|
||||||
|
entry["tokens"] = cast(dict[str, Any], token_values)
|
||||||
|
client_info = raw.get("client_info")
|
||||||
|
if isinstance(client_info, dict):
|
||||||
|
client_values = cast(dict[object, object], client_info)
|
||||||
|
if all(isinstance(key, str) for key in client_values):
|
||||||
|
entry["client_info"] = cast(dict[str, Any], client_values)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _read_store_unlocked(path: Path) -> _CredentialStore:
|
||||||
|
try:
|
||||||
|
raw = cast(object, json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return _empty_store()
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
logger.warning("Could not read MCP OAuth credentials: {}", type(exc).__name__)
|
||||||
|
return _empty_store()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return _empty_store()
|
||||||
|
payload = cast(dict[object, object], raw)
|
||||||
|
raw_servers = payload.get("servers")
|
||||||
|
if not isinstance(raw_servers, dict):
|
||||||
|
return _empty_store()
|
||||||
|
servers: dict[str, _StoredServer] = {}
|
||||||
|
for name, value in cast(dict[object, object], raw_servers).items():
|
||||||
|
entry = _stored_server(value)
|
||||||
|
if isinstance(name, str) and entry is not None:
|
||||||
|
servers[name] = entry
|
||||||
|
generations: dict[str, str] = {}
|
||||||
|
raw_generations = payload.get("generations")
|
||||||
|
if isinstance(raw_generations, dict):
|
||||||
|
for name, value in cast(dict[object, object], raw_generations).items():
|
||||||
|
if isinstance(name, str) and isinstance(value, str) and value:
|
||||||
|
generations[name] = value
|
||||||
|
return {
|
||||||
|
"version": _STORE_VERSION,
|
||||||
|
"servers": servers,
|
||||||
|
"generations": generations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_store_lock(path: Path) -> FileLock:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return FileLock(str(path.with_suffix(".lock")), timeout=_STORE_LOCK_TIMEOUT_S)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_store_unlocked(path: Path, payload: _CredentialStore) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with suppress(OSError):
|
||||||
|
os.chmod(path.parent, 0o700)
|
||||||
|
_write_text_atomic(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
with suppress(OSError):
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
class MCPOAuthStorage:
|
||||||
|
"""Persistent MCP SDK token storage, isolated by config name and server URL."""
|
||||||
|
|
||||||
|
def __init__(self, server_name: str, server_url: str) -> None:
|
||||||
|
self.server_name = server_name
|
||||||
|
self.server_fingerprint = _server_fingerprint(server_url)
|
||||||
|
self._observed_generation = self._read_generation_sync()
|
||||||
|
self._write_lease: str | None = None
|
||||||
|
|
||||||
|
def _read_generation_sync(self) -> str | None:
|
||||||
|
path = _store_path()
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
# Writes replace the whole file atomically, so this observes either side
|
||||||
|
# of a concurrent deletion without blocking the async connection path.
|
||||||
|
return _read_store_unlocked(path)["generations"].get(self.server_name)
|
||||||
|
|
||||||
|
def _generation_is_current(self, payload: _CredentialStore) -> bool:
|
||||||
|
return payload["generations"].get(self.server_name) == self._observed_generation
|
||||||
|
|
||||||
|
def _entry_unlocked(self, payload: _CredentialStore) -> _StoredServer | None:
|
||||||
|
servers = payload["servers"]
|
||||||
|
entry = servers.get(self.server_name)
|
||||||
|
if entry is None or entry.get("server_fingerprint") != self.server_fingerprint:
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _bind_entry_unlocked(
|
||||||
|
self,
|
||||||
|
payload: _CredentialStore,
|
||||||
|
*,
|
||||||
|
create: bool,
|
||||||
|
) -> tuple[_StoredServer | None, bool]:
|
||||||
|
if not self._generation_is_current(payload):
|
||||||
|
return None, False
|
||||||
|
entry = self._entry_unlocked(payload)
|
||||||
|
if self._write_lease is not None:
|
||||||
|
if entry is None or entry.get("write_lease") != self._write_lease:
|
||||||
|
return None, False
|
||||||
|
return entry, False
|
||||||
|
if entry is None:
|
||||||
|
if not create:
|
||||||
|
return None, False
|
||||||
|
self._write_lease = secrets.token_urlsafe(24)
|
||||||
|
entry = _StoredServer(
|
||||||
|
server_fingerprint=self.server_fingerprint,
|
||||||
|
write_lease=self._write_lease,
|
||||||
|
)
|
||||||
|
payload["servers"][self.server_name] = entry
|
||||||
|
return entry, True
|
||||||
|
write_lease = entry.get("write_lease")
|
||||||
|
changed = not isinstance(write_lease, str) or not write_lease
|
||||||
|
if changed:
|
||||||
|
write_lease = secrets.token_urlsafe(24)
|
||||||
|
entry["write_lease"] = write_lease
|
||||||
|
self._write_lease = write_lease
|
||||||
|
return entry, changed
|
||||||
|
|
||||||
|
def _read_entry_sync(self) -> _StoredServer | None:
|
||||||
|
path = _store_path()
|
||||||
|
with _with_store_lock(path):
|
||||||
|
payload = _read_store_unlocked(path)
|
||||||
|
entry, changed = self._bind_entry_unlocked(payload, create=False)
|
||||||
|
if changed:
|
||||||
|
_write_store_unlocked(path, payload)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _update_entry_sync(
|
||||||
|
self,
|
||||||
|
update: Callable[[_StoredServer], None],
|
||||||
|
*,
|
||||||
|
create: bool = True,
|
||||||
|
claim: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
path = _store_path()
|
||||||
|
with _with_store_lock(path):
|
||||||
|
payload = _read_store_unlocked(path)
|
||||||
|
if claim:
|
||||||
|
# A browser flow owns subsequent SDK writes until another flow
|
||||||
|
# claims the entry or the configured server is removed.
|
||||||
|
if not self._generation_is_current(payload):
|
||||||
|
logger.info(
|
||||||
|
"Ignored stale MCP OAuth credential claim for '{}'",
|
||||||
|
self.server_name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
entry = self._entry_unlocked(payload)
|
||||||
|
if entry is None:
|
||||||
|
entry = _StoredServer(server_fingerprint=self.server_fingerprint)
|
||||||
|
payload["servers"][self.server_name] = entry
|
||||||
|
self._write_lease = secrets.token_urlsafe(24)
|
||||||
|
entry["write_lease"] = self._write_lease
|
||||||
|
else:
|
||||||
|
entry, _ = self._bind_entry_unlocked(payload, create=create)
|
||||||
|
if entry is None:
|
||||||
|
if self._write_lease is not None:
|
||||||
|
logger.info(
|
||||||
|
"Ignored stale MCP OAuth credential update for '{}'",
|
||||||
|
self.server_name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
update(entry)
|
||||||
|
payload["version"] = _STORE_VERSION
|
||||||
|
_write_store_unlocked(path, payload)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_tokens(self) -> OAuthToken | None:
|
||||||
|
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||||
|
raw = entry.get("tokens") if entry is not None else None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return OAuthToken.model_validate(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Ignoring invalid MCP OAuth tokens for '{}'", self.server_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||||
|
raw = tokens.model_dump(mode="json", exclude_none=True)
|
||||||
|
|
||||||
|
def update(entry: _StoredServer) -> None:
|
||||||
|
entry["tokens"] = raw
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._update_entry_sync, update)
|
||||||
|
|
||||||
|
async def clear_tokens(self) -> None:
|
||||||
|
def update(entry: _StoredServer) -> None:
|
||||||
|
entry.pop("tokens", None)
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._update_entry_sync, update, create=False)
|
||||||
|
|
||||||
|
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||||
|
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||||
|
raw = entry.get("client_info") if entry is not None else None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return OAuthClientInformationFull.model_validate(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
logger.warning("Ignoring invalid MCP OAuth client info for '{}'", self.server_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||||
|
raw = client_info.model_dump(mode="json", exclude_none=True)
|
||||||
|
|
||||||
|
def update(entry: _StoredServer) -> None:
|
||||||
|
entry["client_info"] = raw
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._update_entry_sync, update)
|
||||||
|
|
||||||
|
async def redirect_uri(self) -> str | None:
|
||||||
|
entry = await asyncio.to_thread(self._read_entry_sync)
|
||||||
|
value = entry.get("redirect_uri") if entry is not None else None
|
||||||
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
async def prepare_redirect_uri(self, redirect_uri: str, *, reset: bool = False) -> None:
|
||||||
|
def update(entry: _StoredServer) -> None:
|
||||||
|
changed = entry.get("redirect_uri") != redirect_uri
|
||||||
|
if reset:
|
||||||
|
entry.pop("tokens", None)
|
||||||
|
entry.pop("client_info", None)
|
||||||
|
elif changed:
|
||||||
|
# Dynamic registrations bind a client to its redirect URI.
|
||||||
|
entry.pop("client_info", None)
|
||||||
|
entry["redirect_uri"] = redirect_uri
|
||||||
|
|
||||||
|
claimed = await asyncio.to_thread(self._update_entry_sync, update, claim=True)
|
||||||
|
if not claimed:
|
||||||
|
raise MCPAuthorizationRequiredError("MCP authorization was cancelled")
|
||||||
|
|
||||||
|
def has_credentials(self) -> bool:
|
||||||
|
entry = self._read_entry_sync()
|
||||||
|
raw_tokens = entry.get("tokens") if entry is not None else None
|
||||||
|
if not isinstance(raw_tokens, dict):
|
||||||
|
return False
|
||||||
|
tokens = cast(dict[str, object], raw_tokens)
|
||||||
|
access_token = tokens.get("access_token")
|
||||||
|
return isinstance(access_token, str) and bool(access_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def _missing_callback() -> tuple[str, str | None]:
|
||||||
|
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_mcp_oauth_auth(
|
||||||
|
server_name: str,
|
||||||
|
server_url: str,
|
||||||
|
handlers: MCPOAuthHandlers | None = None,
|
||||||
|
) -> OAuthClientProvider:
|
||||||
|
"""Build the official MCP SDK OAuth provider for one configured server."""
|
||||||
|
storage = MCPOAuthStorage(server_name, server_url)
|
||||||
|
if handlers is not None:
|
||||||
|
await storage.prepare_redirect_uri(
|
||||||
|
handlers.redirect_uri,
|
||||||
|
reset=handlers.reset_credentials,
|
||||||
|
)
|
||||||
|
redirect_uri = handlers.redirect_uri
|
||||||
|
redirect_handler = handlers.redirect_handler
|
||||||
|
callback_handler = handlers.callback_handler
|
||||||
|
else:
|
||||||
|
if not await asyncio.to_thread(storage.has_credentials):
|
||||||
|
# Do not perform discovery or dynamic registration from a background
|
||||||
|
# startup. Interactive OAuth begins only after an explicit user action.
|
||||||
|
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||||
|
redirect_uri = await storage.redirect_uri() or _DEFAULT_REDIRECT_URI
|
||||||
|
|
||||||
|
async def authorization_required(_authorization_url: str) -> None:
|
||||||
|
await storage.clear_tokens()
|
||||||
|
raise MCPAuthorizationRequiredError("MCP server requires browser authorization")
|
||||||
|
|
||||||
|
redirect_handler = authorization_required
|
||||||
|
callback_handler = _missing_callback
|
||||||
|
|
||||||
|
metadata = OAuthClientMetadata(
|
||||||
|
redirect_uris=[AnyUrl(redirect_uri)],
|
||||||
|
token_endpoint_auth_method="none",
|
||||||
|
client_name="nanobot",
|
||||||
|
client_uri=_CLIENT_URI,
|
||||||
|
logo_uri=_LOGO_URI,
|
||||||
|
software_id="https://github.com/HKUDS/nanobot",
|
||||||
|
)
|
||||||
|
return OAuthClientProvider(
|
||||||
|
server_url,
|
||||||
|
metadata,
|
||||||
|
storage,
|
||||||
|
redirect_handler=redirect_handler,
|
||||||
|
callback_handler=callback_handler,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_oauth_has_credentials(server_name: str, server_url: str) -> bool:
|
||||||
|
"""Return whether this exact configured MCP instance has an access token."""
|
||||||
|
return MCPOAuthStorage(server_name, server_url).has_credentials()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_mcp_oauth_credentials(server_name: str) -> bool:
|
||||||
|
"""Delete credentials for one config name without touching other MCP instances."""
|
||||||
|
path = _store_path()
|
||||||
|
with _with_store_lock(path):
|
||||||
|
payload = _read_store_unlocked(path)
|
||||||
|
servers = payload["servers"]
|
||||||
|
removed = servers.pop(server_name, None) is not None
|
||||||
|
# Rotate even when no entry exists so a flow created before removal cannot
|
||||||
|
# claim the name later and resurrect credentials.
|
||||||
|
payload["generations"][server_name] = secrets.token_urlsafe(24)
|
||||||
|
_write_store_unlocked(path, payload)
|
||||||
|
return removed
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""Explicit runtime state boundary used by :class:`MyTool`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
from nanobot.agent.tools.web import WebToolsConfig
|
||||||
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
|
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||||
|
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||||
|
|
||||||
|
|
||||||
|
RUNTIME_SNAPSHOT_KEYS = frozenset({
|
||||||
|
"model",
|
||||||
|
"model_preset",
|
||||||
|
"model_presets",
|
||||||
|
"max_iterations",
|
||||||
|
"context_window_tokens",
|
||||||
|
"workspace",
|
||||||
|
"provider_retry_mode",
|
||||||
|
"max_tool_result_chars",
|
||||||
|
"current_iteration",
|
||||||
|
"_current_iteration",
|
||||||
|
"tool_names",
|
||||||
|
"web_config",
|
||||||
|
"exec_config",
|
||||||
|
"subagents",
|
||||||
|
"_last_usage",
|
||||||
|
})
|
||||||
|
|
||||||
|
RUNTIME_COMMAND_KEYS = frozenset({
|
||||||
|
"model",
|
||||||
|
"model_preset",
|
||||||
|
"max_iterations",
|
||||||
|
"context_window_tokens",
|
||||||
|
"provider_retry_mode",
|
||||||
|
"max_tool_result_chars",
|
||||||
|
"workspace",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSnapshot:
|
||||||
|
"""Detached, allowlisted values available to self-inspection."""
|
||||||
|
|
||||||
|
model: str
|
||||||
|
model_preset: str | None
|
||||||
|
model_presets: dict[str, dict[str, object]]
|
||||||
|
max_iterations: int
|
||||||
|
context_window_tokens: int
|
||||||
|
workspace: Path | str
|
||||||
|
provider_retry_mode: str
|
||||||
|
max_tool_result_chars: int
|
||||||
|
current_iteration: int
|
||||||
|
tool_names: list[str]
|
||||||
|
web_config: dict[str, object]
|
||||||
|
exec_config: dict[str, object]
|
||||||
|
subagent_statuses: dict[str, dict[str, object]]
|
||||||
|
last_usage: dict[str, int]
|
||||||
|
scratchpad: dict[str, JsonValue]
|
||||||
|
|
||||||
|
def as_mapping(self) -> Mapping[str, object]:
|
||||||
|
"""Return the fixed public names understood by ``MyTool``."""
|
||||||
|
values: dict[str, object] = {
|
||||||
|
"model": self.model,
|
||||||
|
"model_preset": self.model_preset,
|
||||||
|
"model_presets": self.model_presets,
|
||||||
|
"max_iterations": self.max_iterations,
|
||||||
|
"context_window_tokens": self.context_window_tokens,
|
||||||
|
"workspace": self.workspace,
|
||||||
|
"provider_retry_mode": self.provider_retry_mode,
|
||||||
|
"max_tool_result_chars": self.max_tool_result_chars,
|
||||||
|
"current_iteration": self.current_iteration,
|
||||||
|
"_current_iteration": self.current_iteration,
|
||||||
|
"tool_names": self.tool_names,
|
||||||
|
"web_config": self.web_config,
|
||||||
|
"exec_config": self.exec_config,
|
||||||
|
"subagents": {"_task_statuses": self.subagent_statuses},
|
||||||
|
"_last_usage": self.last_usage,
|
||||||
|
}
|
||||||
|
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeControl(Protocol):
|
||||||
|
"""The complete runtime capability exposed to ``MyTool``."""
|
||||||
|
|
||||||
|
def snapshot(self) -> RuntimeSnapshot: ...
|
||||||
|
|
||||||
|
def set_model(self, model: str) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_model_preset(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
session_key: str | None,
|
||||||
|
) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_max_iterations(self, value: int) -> None: ...
|
||||||
|
|
||||||
|
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_provider_retry_mode(self, value: str) -> None: ...
|
||||||
|
|
||||||
|
def set_max_tool_result_chars(self, value: int) -> None: ...
|
||||||
|
|
||||||
|
def set_workspace_display(self, value: str) -> None: ...
|
||||||
|
|
||||||
|
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _RuntimeControlTarget(Protocol):
|
||||||
|
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
|
||||||
|
|
||||||
|
max_iterations: int
|
||||||
|
provider_retry_mode: str
|
||||||
|
max_tool_result_chars: int
|
||||||
|
web_config: WebToolsConfig
|
||||||
|
exec_config: ExecToolConfig
|
||||||
|
subagents: SubagentManager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_preset(self) -> str | None: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_window_tokens(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workspace(self) -> Path: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_iteration(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tool_names(self) -> list[str]: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_usage(self) -> Mapping[str, int]: ...
|
||||||
|
|
||||||
|
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRuntimeControl:
|
||||||
|
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
|
||||||
|
|
||||||
|
def __init__(self, target: _RuntimeControlTarget) -> None:
|
||||||
|
self.__target = target
|
||||||
|
self.__scratchpad: dict[str, JsonValue] = {}
|
||||||
|
self.__workspace_display: str | None = None
|
||||||
|
|
||||||
|
def snapshot(self) -> RuntimeSnapshot:
|
||||||
|
target = self.__target
|
||||||
|
return RuntimeSnapshot(
|
||||||
|
model=target.model,
|
||||||
|
model_preset=target.model_preset,
|
||||||
|
model_presets=_snapshot_model_presets(target.model_presets),
|
||||||
|
max_iterations=target.max_iterations,
|
||||||
|
context_window_tokens=target.context_window_tokens,
|
||||||
|
workspace=(
|
||||||
|
self.__workspace_display
|
||||||
|
if self.__workspace_display is not None
|
||||||
|
else target.workspace
|
||||||
|
),
|
||||||
|
provider_retry_mode=target.provider_retry_mode,
|
||||||
|
max_tool_result_chars=target.max_tool_result_chars,
|
||||||
|
current_iteration=target.current_iteration,
|
||||||
|
tool_names=list(target.tool_names),
|
||||||
|
web_config=_snapshot_web_config(target.web_config),
|
||||||
|
exec_config=_snapshot_exec_config(target.exec_config),
|
||||||
|
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
|
||||||
|
last_usage=dict(target.last_usage),
|
||||||
|
scratchpad=_snapshot_json_mapping(self.__scratchpad),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_model(self, model: str) -> LLMRuntime:
|
||||||
|
return self.__target.set_runtime_model(model)
|
||||||
|
|
||||||
|
def set_model_preset(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
session_key: str | None,
|
||||||
|
) -> LLMRuntime:
|
||||||
|
if session_key is not None:
|
||||||
|
return self.__target.set_session_model_preset(session_key, name)
|
||||||
|
return self.__target.set_model_preset(name)
|
||||||
|
|
||||||
|
def set_max_iterations(self, value: int) -> None:
|
||||||
|
self.__target.max_iterations = value
|
||||||
|
self.__target.subagents.max_iterations = value
|
||||||
|
|
||||||
|
def set_context_window_tokens(self, value: int) -> LLMRuntime:
|
||||||
|
return self.__target.set_runtime_context_window(value)
|
||||||
|
|
||||||
|
def set_provider_retry_mode(self, value: str) -> None:
|
||||||
|
self.__target.provider_retry_mode = value
|
||||||
|
|
||||||
|
def set_max_tool_result_chars(self, value: int) -> None:
|
||||||
|
self.__target.max_tool_result_chars = value
|
||||||
|
|
||||||
|
def set_workspace_display(self, value: str) -> None:
|
||||||
|
"""Preserve MyTool display compatibility without changing path enforcement."""
|
||||||
|
self.__workspace_display = value
|
||||||
|
|
||||||
|
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
|
||||||
|
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
|
||||||
|
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
|
||||||
|
self.__scratchpad[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_model_presets(
|
||||||
|
presets: Mapping[str, ModelPresetConfig],
|
||||||
|
) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
"label": preset.label,
|
||||||
|
"model": preset.model,
|
||||||
|
"provider": preset.provider,
|
||||||
|
"max_tokens": preset.max_tokens,
|
||||||
|
"context_window_tokens": preset.context_window_tokens,
|
||||||
|
"temperature": preset.temperature,
|
||||||
|
"reasoning_effort": preset.reasoning_effort,
|
||||||
|
}
|
||||||
|
for name, preset in presets.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"enable": config.enable,
|
||||||
|
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
|
||||||
|
"proxy": "<configured>" if config.proxy else config.proxy,
|
||||||
|
"user_agent": config.user_agent,
|
||||||
|
"search": {
|
||||||
|
"provider": config.search.provider,
|
||||||
|
"base_url": config.search.base_url,
|
||||||
|
"max_results": config.search.max_results,
|
||||||
|
"timeout": config.search.timeout,
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"use_jina_reader": config.fetch.use_jina_reader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"enable": config.enable,
|
||||||
|
"timeout": config.timeout,
|
||||||
|
"path_prepend": config.path_prepend,
|
||||||
|
"path_append": config.path_append,
|
||||||
|
"sandbox": config.sandbox,
|
||||||
|
"sandbox_ro_binds": list(config.sandbox_ro_binds),
|
||||||
|
"sandbox_rw_binds": list(config.sandbox_rw_binds),
|
||||||
|
"allowed_env_keys": list(config.allowed_env_keys),
|
||||||
|
"allow_patterns": list(config.allow_patterns),
|
||||||
|
"deny_patterns": list(config.deny_patterns),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_subagent_statuses(
|
||||||
|
manager: SubagentManager,
|
||||||
|
) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
task_id: _snapshot_subagent_status(status)
|
||||||
|
for task_id, status in manager.runtime_statuses().items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"task_id": status.task_id,
|
||||||
|
"label": status.label,
|
||||||
|
"task_description": status.task_description,
|
||||||
|
"started_at": status.started_at,
|
||||||
|
"phase": status.phase,
|
||||||
|
"iteration": status.iteration,
|
||||||
|
"tool_events": [dict(event) for event in status.tool_events],
|
||||||
|
"usage": dict(status.usage),
|
||||||
|
"stop_reason": status.stop_reason,
|
||||||
|
"error": status.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||||
|
return {key: _snapshot_json_value(value) for key, value in values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_json_value(value: JsonValue) -> JsonValue:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_snapshot_json_value(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _snapshot_json_value(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
return value
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any, Protocol
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.agent.subagent import SubagentManager
|
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
|
||||||
from nanobot.agent.tools.web import WebToolsConfig
|
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeState(Protocol):
|
|
||||||
"""Minimum contract that MyTool requires from its runtime state provider.
|
|
||||||
|
|
||||||
In practice, this is always satisfied by ``AgentLoop``. MyTool also
|
|
||||||
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
|
|
||||||
for dot-path inspection and modification; those paths are validated at
|
|
||||||
runtime rather than by this protocol.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model(self) -> str: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_iterations(self) -> int: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_iteration(self) -> int: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_names(self) -> list[str]: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workspace(self) -> Path: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def provider_retry_mode(self) -> str: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_tool_result_chars(self) -> int: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def context_window_tokens(self) -> int: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def web_config(self) -> WebToolsConfig: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def exec_config(self) -> ExecToolConfig: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def subagents(self) -> SubagentManager: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _runtime_vars(self) -> dict[str, Any]: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _last_usage(self) -> dict[str, int]: ...
|
|
||||||
|
|
||||||
def _sync_subagent_runtime_limits(self) -> None: ...
|
|
||||||
|
|
||||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_session_model_preset(
|
|
||||||
self,
|
|
||||||
session_key: str,
|
|
||||||
name: str,
|
|
||||||
) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_preset(self) -> str | None: ...
|
|
||||||
+196
-165
@@ -1,8 +1,7 @@
|
|||||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||||
|
|
||||||
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
# Tool.execute accepts heterogeneous schemas.
|
||||||
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
# pyright: reportIncompatibleMethodOverride=false
|
||||||
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -14,7 +13,13 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
from nanobot.agent.tools.runtime_control import (
|
||||||
|
RUNTIME_COMMAND_KEYS,
|
||||||
|
RUNTIME_SNAPSHOT_KEYS,
|
||||||
|
JsonValue,
|
||||||
|
RuntimeControl,
|
||||||
|
RuntimeSnapshot,
|
||||||
|
)
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -28,25 +33,28 @@ class MyToolConfig(Base):
|
|||||||
allow_set: bool = False
|
allow_set: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _has_real_attr(obj: Any, key: str) -> bool:
|
|
||||||
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return key in obj
|
|
||||||
d = getattr(obj, "__dict__", None)
|
|
||||||
if d is not None and key in d:
|
|
||||||
return True
|
|
||||||
for cls in type(obj).__mro__:
|
|
||||||
if key in cls.__dict__:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
return isinstance(value, SubagentStatus)
|
return isinstance(value, SubagentStatus)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
field in value
|
||||||
|
for field in ("task_id", "label", "task_description", "started_at", "phase")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return False
|
||||||
|
mapping = cast(Mapping[object, object], value)
|
||||||
|
return all(isinstance(key, str) for key in mapping)
|
||||||
|
|
||||||
|
|
||||||
class MyTool(Tool):
|
class MyTool(Tool):
|
||||||
"""Check and set the agent loop's runtime configuration."""
|
"""Check and set the agent loop's runtime configuration."""
|
||||||
|
|
||||||
@@ -79,7 +87,10 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
READ_ONLY = frozenset({
|
READ_ONLY = frozenset({
|
||||||
"subagents", # observable but replacing it would break the system
|
"subagents", # observable but replacing it would break the system
|
||||||
|
"tool_names",
|
||||||
|
"current_iteration",
|
||||||
"_current_iteration", # updated by runner only
|
"_current_iteration", # updated by runner only
|
||||||
|
"_last_usage",
|
||||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||||
"model_presets", # config-derived catalog; changes require config reload
|
"model_presets", # config-derived catalog; changes require config reload
|
||||||
@@ -103,13 +114,6 @@ class MyTool(Tool):
|
|||||||
"private_key", "access_token", "refresh_token", "auth",
|
"private_key", "access_token", "refresh_token", "auth",
|
||||||
})
|
})
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_sensitive_field_name(cls, name: str) -> bool:
|
|
||||||
lowered = name.lower()
|
|
||||||
return lowered in cls._SENSITIVE_NAMES or any(
|
|
||||||
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
|
|
||||||
)
|
|
||||||
|
|
||||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||||
@@ -123,15 +127,15 @@ class MyTool(Tool):
|
|||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
||||||
self._runtime_state = runtime_state
|
self._runtime_control = runtime_control
|
||||||
self._modify_allowed = modify_allowed
|
self._modify_allowed = modify_allowed
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
result = cls.__new__(cls)
|
result = cls.__new__(cls)
|
||||||
memo[id(self)] = result
|
memo[id(self)] = result
|
||||||
result._runtime_state = self._runtime_state
|
result._runtime_control = self._runtime_control
|
||||||
result._modify_allowed = self._modify_allowed
|
result._modify_allowed = self._modify_allowed
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -208,9 +212,12 @@ class MyTool(Tool):
|
|||||||
# Path resolution
|
# Path resolution
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
def _resolve_path(
|
||||||
|
self,
|
||||||
|
snapshot: RuntimeSnapshot,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[object | None, str | None]:
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
obj: Any = self._runtime_state
|
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
@@ -218,17 +225,13 @@ class MyTool(Tool):
|
|||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
if part.lower() in self._SENSITIVE_NAMES:
|
if part.lower() in self._SENSITIVE_NAMES:
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
try:
|
obj: object = snapshot.as_mapping()
|
||||||
if isinstance(obj, Mapping):
|
for part in parts:
|
||||||
mapping = cast(Mapping[str, Any], obj)
|
if not _is_string_mapping(obj):
|
||||||
if part in mapping:
|
return None, f"'{part}' not found"
|
||||||
obj = mapping[part]
|
if part not in obj:
|
||||||
else:
|
|
||||||
return None, f"'{part}' not found in mapping"
|
return None, f"'{part}' not found in mapping"
|
||||||
else:
|
obj = obj[part]
|
||||||
obj = getattr(obj, part)
|
|
||||||
except (KeyError, AttributeError) as e:
|
|
||||||
return None, f"'{part}' not found: {e}"
|
|
||||||
return obj, None
|
return obj, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -242,20 +245,48 @@ class MyTool(Tool):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
def _format_status(
|
||||||
elapsed = time.monotonic() - st.started_at
|
st: "SubagentStatus | Mapping[str, object]",
|
||||||
tool_summary = ", ".join(
|
indent: str = " ",
|
||||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
) -> str:
|
||||||
) or "none"
|
if isinstance(st, Mapping):
|
||||||
|
started_at = st.get("started_at", time.monotonic())
|
||||||
|
raw_events = st.get("tool_events", [])
|
||||||
|
phase = st.get("phase", "unknown")
|
||||||
|
iteration = st.get("iteration", 0)
|
||||||
|
usage = st.get("usage", {})
|
||||||
|
error = st.get("error")
|
||||||
|
stop_reason = st.get("stop_reason")
|
||||||
|
else:
|
||||||
|
started_at = st.started_at
|
||||||
|
raw_events = st.tool_events
|
||||||
|
phase = st.phase
|
||||||
|
iteration = st.iteration
|
||||||
|
usage = st.usage
|
||||||
|
error = st.error
|
||||||
|
stop_reason = st.stop_reason
|
||||||
|
elapsed = time.monotonic() - (
|
||||||
|
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
|
||||||
|
)
|
||||||
|
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
|
||||||
|
tool_summaries: list[str] = []
|
||||||
|
for raw_event in tool_events[-5:]:
|
||||||
|
if not isinstance(raw_event, Mapping):
|
||||||
|
continue
|
||||||
|
event = cast(Mapping[str, object], raw_event)
|
||||||
|
tool_summaries.append(
|
||||||
|
f"{event.get('name', '?')}({event.get('status', '?')})"
|
||||||
|
)
|
||||||
|
tool_summary = ", ".join(tool_summaries) or "none"
|
||||||
lines = [
|
lines = [
|
||||||
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
||||||
f"{indent}tools: {tool_summary}",
|
f"{indent}tools: {tool_summary}",
|
||||||
f"{indent}usage: {st.usage or 'n/a'}",
|
f"{indent}usage: {usage or 'n/a'}",
|
||||||
]
|
]
|
||||||
if st.error:
|
if error:
|
||||||
lines.append(f"{indent}error: {st.error}")
|
lines.append(f"{indent}error: {error}")
|
||||||
if st.stop_reason:
|
if stop_reason:
|
||||||
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
lines.append(f"{indent}stop_reason: {stop_reason}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -264,29 +295,38 @@ class MyTool(Tool):
|
|||||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||||
detail = MyTool._format_status(val, " ")
|
detail = MyTool._format_status(val, " ")
|
||||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||||
# SubagentManager: delegate to its _task_statuses dict
|
if _is_subagent_status_snapshot(val):
|
||||||
task_statuses = getattr(val, "_task_statuses", None)
|
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
||||||
if isinstance(task_statuses, dict):
|
detail = MyTool._format_status(val, " ")
|
||||||
return MyTool._format_value(task_statuses, key)
|
return f"{header}\n task: {val['task_description']}\n{detail}"
|
||||||
if isinstance(val, Mapping):
|
if isinstance(val, Mapping):
|
||||||
mapping = cast(Mapping[object, object], val)
|
mapping = cast(Mapping[object, object], val)
|
||||||
else:
|
else:
|
||||||
mapping = None
|
mapping = None
|
||||||
|
if mapping and set(mapping) == {"_task_statuses"}:
|
||||||
|
task_statuses = mapping["_task_statuses"]
|
||||||
|
if isinstance(task_statuses, Mapping):
|
||||||
|
return MyTool._format_value(task_statuses, key)
|
||||||
if (
|
if (
|
||||||
mapping
|
mapping
|
||||||
and _is_subagent_status(next(iter(mapping.values())))
|
and (
|
||||||
|
_is_subagent_status(next(iter(mapping.values())))
|
||||||
|
or _is_subagent_status_snapshot(next(iter(mapping.values())))
|
||||||
|
)
|
||||||
):
|
):
|
||||||
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
|
|
||||||
prefix = f"{key}: " if key else ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
||||||
for tid, st in status_mapping.items():
|
for tid, st in mapping.items():
|
||||||
|
if _is_subagent_status(st):
|
||||||
detail = MyTool._format_status(st, " ")
|
detail = MyTool._format_status(st, " ")
|
||||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
label = st.label
|
||||||
|
elif _is_subagent_status_snapshot(st):
|
||||||
|
detail = MyTool._format_status(st, " ")
|
||||||
|
label = st.get("label", "?")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
lines.append(f" [{tid}] '{label}'\n{detail}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
dynamic_value = cast(Any, val)
|
|
||||||
if hasattr(dynamic_value, "tool_names"):
|
|
||||||
tool_names: Any = getattr(dynamic_value, "tool_names")
|
|
||||||
return f"tools: {len(tool_names)} registered — {tool_names}"
|
|
||||||
# Scalar types — repr is fine
|
# Scalar types — repr is fine
|
||||||
if isinstance(val, (str, int, float, bool, type(None))):
|
if isinstance(val, (str, int, float, bool, type(None))):
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
@@ -311,32 +351,6 @@ class MyTool(Tool):
|
|||||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||||
r = repr(sequence)
|
r = repr(sequence)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
|
||||||
value_type = type(cast(object, val))
|
|
||||||
cls_name = value_type.__name__
|
|
||||||
model_fields = cast(object, getattr(value_type, "model_fields", None))
|
|
||||||
if isinstance(model_fields, Mapping) and model_fields:
|
|
||||||
fields = list(cast(Mapping[str, object], model_fields).keys())
|
|
||||||
if len(fields) <= 8:
|
|
||||||
# Small config objects: show field=value pairs
|
|
||||||
pairs: list[str] = []
|
|
||||||
for f in fields:
|
|
||||||
fv = getattr(val, f, "?")
|
|
||||||
if MyTool._is_sensitive_field_name(f):
|
|
||||||
continue
|
|
||||||
if isinstance(fv, (str, int, float, bool, type(None))):
|
|
||||||
pairs.append(f"{f}={fv!r}")
|
|
||||||
else:
|
|
||||||
pairs.append(f"{f}=<{type(fv).__name__}>")
|
|
||||||
preview = ", ".join(pairs)
|
|
||||||
return f"{key}: {preview}" if key else preview
|
|
||||||
else:
|
|
||||||
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
|
|
||||||
fields = [name for name in attributes if not name.startswith("__")]
|
|
||||||
if fields:
|
|
||||||
preview = ", ".join(str(f) for f in fields[:20])
|
|
||||||
suffix = ", ..." if len(fields) > 20 else ""
|
|
||||||
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
|
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
|
|
||||||
@@ -366,7 +380,12 @@ class MyTool(Tool):
|
|||||||
runtime = request_ctx.runtime if request_ctx is not None else None
|
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||||
return False, None
|
return False, None
|
||||||
return True, getattr(runtime, key)
|
values: dict[str, object] = {
|
||||||
|
"model": runtime.model,
|
||||||
|
"model_preset": runtime.model_preset,
|
||||||
|
"context_window_tokens": runtime.context_window_tokens,
|
||||||
|
}
|
||||||
|
return True, values[key]
|
||||||
|
|
||||||
def _inspect(self, key: str | None) -> str:
|
def _inspect(self, key: str | None) -> str:
|
||||||
if not key:
|
if not key:
|
||||||
@@ -375,62 +394,64 @@ class MyTool(Tool):
|
|||||||
request_ctx = current_request_context()
|
request_ctx = current_request_context()
|
||||||
if request_ctx is None:
|
if request_ctx is None:
|
||||||
return ToolResult.error("Error: current request context is unavailable")
|
return ToolResult.error("Error: current request context is unavailable")
|
||||||
|
request_values: dict[str, str | None] = {
|
||||||
|
"channel": request_ctx.channel,
|
||||||
|
"chat_id": request_ctx.chat_id,
|
||||||
|
"sender_id": request_ctx.sender_id,
|
||||||
|
}
|
||||||
if key == "request":
|
if key == "request":
|
||||||
return self._format_value(
|
return self._format_value(request_values, key)
|
||||||
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
field = key.removeprefix("request.")
|
field = key.removeprefix("request.")
|
||||||
if field not in self._REQUEST_FIELDS:
|
if field not in self._REQUEST_FIELDS:
|
||||||
return ToolResult.error(f"Error: '{key}' not found")
|
return ToolResult.error(f"Error: '{key}' not found")
|
||||||
return self._format_value(getattr(request_ctx, field), key)
|
return self._format_value(request_values[field], key)
|
||||||
if "." not in key:
|
if "." not in key:
|
||||||
found, value = self._current_runtime_value(key)
|
found, value = self._current_runtime_value(key)
|
||||||
if found:
|
if found:
|
||||||
return self._format_value(value, key)
|
return self._format_value(value, key)
|
||||||
|
snapshot = self._runtime_control.snapshot()
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||||
obj, err = self._resolve_path(key)
|
obj, err = self._resolve_path(snapshot, key)
|
||||||
if err:
|
if err:
|
||||||
# "scratchpad" alias for _runtime_vars
|
|
||||||
if key == "scratchpad":
|
if key == "scratchpad":
|
||||||
rv = self._runtime_state._runtime_vars
|
return (
|
||||||
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
self._format_value(snapshot.scratchpad, "scratchpad")
|
||||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
if snapshot.scratchpad
|
||||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
else "scratchpad is empty"
|
||||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
)
|
||||||
|
if "." not in key and key in snapshot.scratchpad:
|
||||||
|
return self._format_value(snapshot.scratchpad[key], key)
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
# Guard against mock auto-generated attributes
|
|
||||||
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
|
||||||
if key in self._runtime_state._runtime_vars:
|
|
||||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
|
||||||
return ToolResult.error(f"Error: '{key}' not found")
|
|
||||||
return self._format_value(obj, key)
|
return self._format_value(obj, key)
|
||||||
|
|
||||||
def _inspect_all(self) -> str:
|
def _inspect_all(self) -> str:
|
||||||
state = self._runtime_state
|
snapshot = self._runtime_control.snapshot()
|
||||||
|
values = snapshot.as_mapping()
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
# RESTRICTED keys
|
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
found, value = self._current_runtime_value(k)
|
found, value = self._current_runtime_value(k)
|
||||||
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
parts.append(self._format_value(value if found else values[k], k))
|
||||||
found, value = self._current_runtime_value("model_preset")
|
found, value = self._current_runtime_value("model_preset")
|
||||||
parts.append(self._format_value(
|
parts.append(self._format_value(
|
||||||
value if found else state.model_preset,
|
value if found else snapshot.model_preset,
|
||||||
"model_preset",
|
"model_preset",
|
||||||
))
|
))
|
||||||
# Other useful top-level keys shown in description
|
for k in (
|
||||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
"workspace",
|
||||||
if _has_real_attr(state, k):
|
"provider_retry_mode",
|
||||||
parts.append(self._format_value(getattr(state, k, None), k))
|
"max_tool_result_chars",
|
||||||
# Token usage
|
"_current_iteration",
|
||||||
usage = state._last_usage
|
"web_config",
|
||||||
if usage:
|
"exec_config",
|
||||||
parts.append(self._format_value(usage, "_last_usage"))
|
"subagents",
|
||||||
rv = state._runtime_vars
|
):
|
||||||
if rv:
|
parts.append(self._format_value(values[k], k))
|
||||||
parts.append(self._format_value(rv, "scratchpad"))
|
if snapshot.last_usage:
|
||||||
|
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
|
||||||
|
if snapshot.scratchpad:
|
||||||
|
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
# -- modify --
|
# -- modify --
|
||||||
@@ -454,48 +475,49 @@ class MyTool(Tool):
|
|||||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||||
parent, err = self._resolve_path(parent_path)
|
snapshot = self._runtime_control.snapshot()
|
||||||
|
_parent, err = self._resolve_path(snapshot, parent_path)
|
||||||
if err:
|
if err:
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
if isinstance(parent, dict):
|
self._audit("modify", f"READ_ONLY {key}")
|
||||||
parent[leaf] = value
|
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||||
else:
|
|
||||||
setattr(parent, leaf, value)
|
|
||||||
self._audit("modify", f"{key} = {value!r}")
|
|
||||||
return f"Set {key} = {value!r}"
|
|
||||||
if key == "model_preset":
|
if key == "model_preset":
|
||||||
return self._modify_model_preset(value)
|
return self._modify_model_preset(value)
|
||||||
if key in self.RESTRICTED:
|
if key in self.RESTRICTED:
|
||||||
return self._modify_restricted(key, value)
|
return self._modify_restricted(key, value)
|
||||||
return self._modify_free(key, value)
|
if key in RUNTIME_COMMAND_KEYS:
|
||||||
|
return self._modify_runtime_setting(key, value)
|
||||||
|
if key in RUNTIME_SNAPSHOT_KEYS:
|
||||||
|
self._audit("modify", f"READ_ONLY {key}")
|
||||||
|
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
||||||
|
return self._modify_scratchpad(key, value)
|
||||||
|
|
||||||
def _modify_model_preset(self, value: Any) -> str:
|
def _modify_model_preset(self, value: Any) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||||
name = value.strip()
|
name = value.strip()
|
||||||
session_key = current_request_session_key()
|
session_key = current_request_session_key()
|
||||||
if session_key:
|
old = self._runtime_control.snapshot().model_preset
|
||||||
try:
|
try:
|
||||||
runtime = self._runtime_state.set_session_model_preset(
|
runtime = self._runtime_control.set_model_preset(
|
||||||
session_key,
|
|
||||||
name,
|
name,
|
||||||
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
message = str(exc.args[0]) if exc.args else str(exc)
|
message = str(exc.args[0]) if exc.args else str(exc)
|
||||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||||
|
if session_key:
|
||||||
self._audit("modify", f"model_preset = {name!r}")
|
self._audit("modify", f"model_preset = {name!r}")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} for the next turn; "
|
f"Set model_preset = {name!r} for the next turn; "
|
||||||
f"model will be {runtime.model!r}; "
|
f"model will be {runtime.model!r}; "
|
||||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
result = self._modify_free("model_preset", name)
|
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
||||||
if isinstance(result, ToolResult) and result.is_error:
|
|
||||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
|
||||||
return (
|
return (
|
||||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
||||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||||
@@ -508,7 +530,7 @@ class MyTool(Tool):
|
|||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||||
old = getattr(self._runtime_state, key)
|
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||||
if "min" in spec and value < spec["min"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||||
if "max" in spec and value > spec["max"]:
|
if "max" in spec and value > spec["max"]:
|
||||||
@@ -521,41 +543,46 @@ class MyTool(Tool):
|
|||||||
"during an active session; use a configured model_preset"
|
"during an active session; use a configured model_preset"
|
||||||
)
|
)
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_state.set_runtime_model(cast(str, value))
|
self._runtime_control.set_model(cast(str, value))
|
||||||
elif key == "context_window_tokens":
|
elif key == "context_window_tokens":
|
||||||
self._runtime_state.set_runtime_context_window(cast(int, value))
|
self._runtime_control.set_context_window_tokens(cast(int, value))
|
||||||
else:
|
else:
|
||||||
setattr(self._runtime_state, key, value)
|
self._runtime_control.set_max_iterations(cast(int, value))
|
||||||
if key == "max_iterations" and hasattr(
|
|
||||||
self._runtime_state,
|
|
||||||
"_sync_subagent_runtime_limits",
|
|
||||||
):
|
|
||||||
self._runtime_state._sync_subagent_runtime_limits()
|
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
def _modify_free(self, key: str, value: Any) -> str:
|
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
||||||
if _has_real_attr(self._runtime_state, key):
|
old = self._runtime_control.snapshot().as_mapping()[key]
|
||||||
old = getattr(self._runtime_state, key)
|
if key == "workspace":
|
||||||
if isinstance(old, (str, int, float, bool)):
|
if not isinstance(value, str):
|
||||||
old_t: type[Any] = type(old)
|
return ToolResult.error(
|
||||||
|
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
self._runtime_control.set_workspace_display(value)
|
||||||
|
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
||||||
|
return f"Set workspace = {value!r} (was {old!r})"
|
||||||
|
old_t = type(old)
|
||||||
new_t = cast(type[Any], type(value))
|
new_t = cast(type[Any], type(value))
|
||||||
if old_t is float and new_t is int:
|
if old_t is float and new_t is int:
|
||||||
pass # int → float coercion allowed
|
pass
|
||||||
elif old_t is not new_t:
|
elif old_t is not new_t:
|
||||||
self._audit(
|
self._audit(
|
||||||
"modify",
|
"modify",
|
||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
)
|
)
|
||||||
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
return ToolResult.error(
|
||||||
try:
|
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||||
setattr(self._runtime_state, key, value)
|
)
|
||||||
except (ValueError, KeyError) as e:
|
if key == "provider_retry_mode":
|
||||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
||||||
self._audit("modify", f"REJECTED {key}: {message}")
|
elif key == "max_tool_result_chars":
|
||||||
return ToolResult.error(f"Error: {message}")
|
self._runtime_control.set_max_tool_result_chars(cast(int, value))
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"Unhandled runtime command: {key}")
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
|
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
||||||
if callable(value):
|
if callable(value):
|
||||||
self._audit("modify", f"REJECTED callable {key}")
|
self._audit("modify", f"REJECTED callable {key}")
|
||||||
return ToolResult.error("Error: cannot store callable values")
|
return ToolResult.error("Error: cannot store callable values")
|
||||||
@@ -563,12 +590,16 @@ class MyTool(Tool):
|
|||||||
if err:
|
if err:
|
||||||
self._audit("modify", f"REJECTED {key}: {err}")
|
self._audit("modify", f"REJECTED {key}: {err}")
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
try:
|
||||||
|
self._runtime_control.set_scratchpad(
|
||||||
|
key,
|
||||||
|
cast(JsonValue, value),
|
||||||
|
max_keys=self._MAX_RUNTIME_KEYS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||||
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
||||||
old = self._runtime_state._runtime_vars.get(key)
|
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
||||||
self._runtime_state._runtime_vars[key] = value
|
|
||||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
|
||||||
return f"Set scratchpad.{key} = {value!r}"
|
return f"Set scratchpad.{key} = {value!r}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -453,9 +453,9 @@ class WebSearchTool(Tool):
|
|||||||
|
|
||||||
async def _search_olostep(self, query: str, n: int) -> str:
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
from olostep import ( # pyright: ignore[reportMissingImports]
|
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
||||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||||
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error(
|
return ToolResult.error(
|
||||||
|
|||||||
+51
-17
@@ -20,6 +20,7 @@ from urllib.parse import urlparse
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.skills import normalize_skill_document
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
from nanobot.security.workspace_policy import is_path_within
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
@@ -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_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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
|
||||||
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
|
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_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"
|
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
|
||||||
_CATALOG_SOURCES = (
|
_CATALOG_SOURCES = (
|
||||||
@@ -210,11 +212,27 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
|||||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
def _skill_name(name: str, *, legacy: bool = False) -> str:
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
|
if not legacy:
|
||||||
|
clean = clean.replace("_", "-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
return f"cli-app-{clean or 'app'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_skill_relative_path(name: str) -> str:
|
||||||
|
skill_name = _skill_name(name)
|
||||||
|
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
|
def cli_app_skill_relative_path(workspace: Path, name: str) -> str:
|
||||||
|
"""Return a CLI App's skill path, including the legacy location."""
|
||||||
|
canonical = _plugin_skill_relative_path(name)
|
||||||
|
legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md"
|
||||||
|
if not (workspace / canonical).is_file() and (workspace / legacy).is_file():
|
||||||
|
return legacy
|
||||||
|
return canonical
|
||||||
|
|
||||||
|
|
||||||
def _has_shell_meta(command: str) -> bool:
|
def _has_shell_meta(command: str) -> bool:
|
||||||
return any(char in command for char in _SHELL_META_CHARS)
|
return any(char in command for char in _SHELL_META_CHARS)
|
||||||
|
|
||||||
@@ -613,7 +631,7 @@ class CliAppManager:
|
|||||||
"name": installed_name,
|
"name": installed_name,
|
||||||
"entry_point": entry_point,
|
"entry_point": entry_point,
|
||||||
"source": str(data.get("source") or ""),
|
"source": str(data.get("source") or ""),
|
||||||
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
|
"skill": cli_app_skill_relative_path(self.workspace, installed_name),
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -639,9 +657,6 @@ class CliAppManager:
|
|||||||
install_cmd = str(app.get("install_cmd") or "")
|
install_cmd = str(app.get("install_cmd") or "")
|
||||||
return not _has_shell_meta(install_cmd)
|
return not _has_shell_meta(install_cmd)
|
||||||
|
|
||||||
def _skill_path(self, name: str) -> Path:
|
|
||||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
|
||||||
|
|
||||||
def _app_payload(
|
def _app_payload(
|
||||||
self,
|
self,
|
||||||
app: dict[str, Any],
|
app: dict[str, Any],
|
||||||
@@ -677,7 +692,7 @@ class CliAppManager:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": logo_url,
|
"logo_url": logo_url,
|
||||||
"brand_color": brand_color,
|
"brand_color": brand_color,
|
||||||
"skill_installed": self._skill_path(name).is_file(),
|
"skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(),
|
||||||
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +728,8 @@ class CliAppManager:
|
|||||||
name = str(app["name"])
|
name = str(app["name"])
|
||||||
entry_point = str(app.get("entry_point") or "")
|
entry_point = str(app.get("entry_point") or "")
|
||||||
strategy = self._strategy(app)
|
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/{_skill_name(name)}"
|
||||||
capabilities = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -726,13 +742,13 @@ class CliAppManager:
|
|||||||
install = compact_dict({
|
install = compact_dict({
|
||||||
"supported": install_supported,
|
"supported": install_supported,
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": ["entry_point_available"] if entry_point else [],
|
"verification": ["entry_point_available"] if entry_point else [],
|
||||||
})
|
})
|
||||||
remove = compact_dict({
|
remove = compact_dict({
|
||||||
"supported": strategy != "unsupported",
|
"supported": strategy != "unsupported",
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"managed_paths": [skill_path],
|
"managed_paths": [plugin_path],
|
||||||
"verification": (
|
"verification": (
|
||||||
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
|
||||||
if strategy not in {"bundled", "unsupported"}
|
if strategy not in {"bundled", "unsupported"}
|
||||||
@@ -1032,11 +1048,10 @@ class CliAppManager:
|
|||||||
name = str(app.get("name") or "unknown")
|
name = str(app.get("name") or "unknown")
|
||||||
display = str(app.get("display_name") or name)
|
display = str(app.get("display_name") or name)
|
||||||
entry = str(app.get("entry_point") or f"cli-anything-{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"""---
|
return f"""---
|
||||||
name: {_safe_skill_name(name)}
|
name: {_skill_name(name)}
|
||||||
description: >-
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
{description}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# {display}
|
# {display}
|
||||||
@@ -1073,24 +1088,43 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
return note + "\n" + content
|
return note + "\n" + content
|
||||||
|
|
||||||
def install_skill(self, app: dict[str, Any]) -> Path:
|
def install_skill(self, app: dict[str, Any]) -> Path:
|
||||||
path = self._skill_path(str(app["name"]))
|
name = str(app["name"])
|
||||||
|
path = self.workspace / _plugin_skill_relative_path(name)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
content = self._fetch_skill_content(app) or self._fallback_skill(app)
|
||||||
|
content = normalize_skill_document(content, _skill_name(name)) or self._fallback_skill(app)
|
||||||
content = self._with_nanobot_skill_note(content, app)
|
content = self._with_nanobot_skill_note(content, app)
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
|
plugin_root = path.parents[2]
|
||||||
|
manifest = compact_dict({
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": _skill_name(str(app["name"])),
|
||||||
|
"version": str(app.get("version") or ""),
|
||||||
|
"description": _catalog_description(app),
|
||||||
|
})
|
||||||
|
_write_json(plugin_root / "plugin.json", manifest)
|
||||||
|
legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True)
|
||||||
|
if legacy_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_dir)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def remove_skill(self, name: str) -> None:
|
def remove_skill(self, name: str) -> None:
|
||||||
skill_dir = self._skill_path(name).parent
|
plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
|
||||||
if skill_dir.is_dir():
|
if plugin_root.is_dir():
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(plugin_root)
|
||||||
|
legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True)
|
||||||
|
if legacy_dir.is_dir():
|
||||||
|
shutil.rmtree(legacy_dir)
|
||||||
|
|
||||||
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
from nanobot.agent.plugins import set_agent_plugin_enabled
|
||||||
|
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
entry = self._installed_entry(app)
|
entry = self._installed_entry(app)
|
||||||
installed[str(app["name"])] = entry
|
installed[str(app["name"])] = entry
|
||||||
self._save_installed(installed)
|
self._save_installed(installed)
|
||||||
self.install_skill(app)
|
self.install_skill(app)
|
||||||
|
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def install(self, name: str) -> dict[str, Any]:
|
def install(self, name: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ def runtime_lines_for_request(
|
|||||||
"""Return CLI App annotations from an immutable request snapshot."""
|
"""Return CLI App annotations from an immutable request snapshot."""
|
||||||
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
|
||||||
if isinstance(structured, list):
|
if isinstance(structured, list):
|
||||||
|
from nanobot.apps.cli.service import cli_app_skill_relative_path
|
||||||
|
|
||||||
structured_items = cast(list[Any], structured)
|
structured_items = cast(list[Any], structured)
|
||||||
mentions = [
|
mentions = [
|
||||||
cast(Mapping[str, Any], item) for item in structured_items
|
cast(Mapping[str, Any], item) for item in structured_items
|
||||||
@@ -32,7 +34,7 @@ def runtime_lines_for_request(
|
|||||||
f"@{str(item['name']).strip().lower()} "
|
f"@{str(item['name']).strip().lower()} "
|
||||||
f"(installed; tool=run_cli_app; "
|
f"(installed; tool=run_cli_app; "
|
||||||
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
|
||||||
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
|
f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). "
|
||||||
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
|
||||||
for item in mentions
|
for item in mentions
|
||||||
if str(item.get("name") or "").strip()
|
if str(item.get("name") or "").strip()
|
||||||
|
|||||||
@@ -238,20 +238,6 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_no_removal_when_not_stream_end(self):
|
async def test_no_removal_when_not_stream_end(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||||
|
|
||||||
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
token={token}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
|
|||||||
instance: NanobotChannelInstanceInfo;
|
instance: NanobotChannelInstanceInfo;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = channelTranslator(t, "feishu");
|
const tx = channelTranslator(t, "feishu");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
onFeaturesUpdate(
|
onFeaturesUpdate(
|
||||||
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ class ChannelManager:
|
|||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
):
|
):
|
||||||
|
if config_path is None:
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
|
||||||
|
config_path = get_config_path()
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self._config_path = config_path.expanduser().resolve(strict=False)
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._cron_service = cron_service
|
self._cron_service = cron_service
|
||||||
@@ -170,6 +176,7 @@ class ChannelManager:
|
|||||||
static_dist_path=static_path,
|
static_dist_path=static_path,
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
||||||
|
config_path=self._config_path,
|
||||||
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||||
runtime_model_name=self._webui_runtime_model_name,
|
runtime_model_name=self._webui_runtime_model_name,
|
||||||
runtime_surface=self._webui_runtime_surface,
|
runtime_surface=self._webui_runtime_surface,
|
||||||
|
|||||||
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default: dict[ServerConnection, str] = {}
|
self._conn_default: dict[ServerConnection, str] = {}
|
||||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||||
self._webui_connections: set[ServerConnection] = set()
|
self._webui_connections: set[ServerConnection] = set()
|
||||||
|
# Request/reply mutations aren't replayed across reconnects. Tasks may
|
||||||
|
# finish after a client-side deadline so an already-started mutation
|
||||||
|
# isn't ambiguously cancelled halfway through.
|
||||||
|
self._webui_request_tasks: dict[
|
||||||
|
tuple[ServerConnection, str],
|
||||||
|
asyncio.Task[None],
|
||||||
|
] = {}
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||||
t = envelope.get("type")
|
t = envelope.get("type")
|
||||||
|
if t == "webui_request":
|
||||||
|
await self._start_webui_request(connection, envelope)
|
||||||
|
return
|
||||||
if t == "new_chat":
|
if t == "new_chat":
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
@@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||||
|
|
||||||
|
async def _start_webui_request(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
envelope: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
request_id = envelope.get("request_id")
|
||||||
|
if not isinstance(request_id, str) or re.fullmatch(
|
||||||
|
r"[A-Za-z0-9._:-]{1,128}",
|
||||||
|
request_id,
|
||||||
|
) is None:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"error",
|
||||||
|
detail="invalid webui request_id",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if connection not in self._webui_connections:
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=403,
|
||||||
|
message="access_denied",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
action = envelope.get("action")
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(action, str) or re.fullmatch(
|
||||||
|
r"[a-z][a-z0-9_.]{0,127}",
|
||||||
|
action,
|
||||||
|
) is None:
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=400,
|
||||||
|
message="invalid WebUI mutation action",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=400,
|
||||||
|
message="WebUI mutation payload must be an object",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = (connection, request_id)
|
||||||
|
if key in self._webui_request_tasks:
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=409,
|
||||||
|
message="duplicate WebUI request_id",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._complete_webui_request(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
action,
|
||||||
|
cast(dict[str, Any], payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._webui_request_tasks[key] = task
|
||||||
|
|
||||||
|
async def _complete_webui_request(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
request_id: str,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
response = await self._http_router.dispatch_webui_mutation(
|
||||||
|
connection,
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
status = response.status_code
|
||||||
|
body = bytes(response.body).decode("utf-8", errors="replace").strip()
|
||||||
|
if 200 <= status < 300:
|
||||||
|
try:
|
||||||
|
result = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=502,
|
||||||
|
message="WebUI mutation returned an invalid response",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=status,
|
||||||
|
message=body or response.reason_phrase,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("WebUI mutation '{}' failed", action)
|
||||||
|
await self._send_webui_response(
|
||||||
|
connection,
|
||||||
|
request_id,
|
||||||
|
status=500,
|
||||||
|
message="WebUI mutation failed",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._webui_request_tasks.pop((connection, request_id), None)
|
||||||
|
|
||||||
|
async def _send_webui_response(
|
||||||
|
self,
|
||||||
|
connection: ServerConnection,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
result: Any = None,
|
||||||
|
status: int | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if status is None:
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"webui_response",
|
||||||
|
request_id=request_id,
|
||||||
|
ok=True,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._send_event(
|
||||||
|
connection,
|
||||||
|
"webui_response",
|
||||||
|
request_id=request_id,
|
||||||
|
ok=False,
|
||||||
|
error={
|
||||||
|
"status": status,
|
||||||
|
"message": message or "WebUI mutation failed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def _workspace_scope_or_error(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
self._server_task = None
|
||||||
|
mutation_tasks = tuple(self._webui_request_tasks.values())
|
||||||
|
for task in mutation_tasks:
|
||||||
|
task.cancel()
|
||||||
|
if mutation_tasks:
|
||||||
|
await asyncio.gather(*mutation_tasks, return_exceptions=True)
|
||||||
|
self._webui_request_tasks.clear()
|
||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
|
|||||||
@@ -3,12 +3,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
|
from websockets.datastructures import Headers
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.frames import Close
|
from websockets.frames import Close
|
||||||
|
|
||||||
@@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||||
|
from nanobot.webui.http_utils import (
|
||||||
|
http_error as _http_error,
|
||||||
|
)
|
||||||
|
from nanobot.webui.http_utils import (
|
||||||
|
http_json_response as _http_json_response,
|
||||||
|
)
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
issue_route_secret_matches as _issue_route_secret_matches,
|
issue_route_secret_matches as _issue_route_secret_matches,
|
||||||
)
|
)
|
||||||
@@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _webui_mutate(
|
||||||
|
client: Any,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
request_id = f"test-{uuid.uuid4().hex}"
|
||||||
|
await client.send(json.dumps({
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"action": action,
|
||||||
|
"payload": payload or {},
|
||||||
|
}))
|
||||||
|
while True:
|
||||||
|
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
|
||||||
|
if envelope.get("event") != "webui_response":
|
||||||
|
continue
|
||||||
|
if envelope.get("request_id") != request_id:
|
||||||
|
continue
|
||||||
|
if envelope.get("ok") is True:
|
||||||
|
status = 200
|
||||||
|
body = envelope.get("result")
|
||||||
|
else:
|
||||||
|
error = envelope.get("error") or {}
|
||||||
|
status = int(error.get("status") or 500)
|
||||||
|
body = {"error": str(error.get("message") or "WebUI mutation failed")}
|
||||||
|
return httpx.Response(
|
||||||
|
status,
|
||||||
|
json=body,
|
||||||
|
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||||
channel = _ch(MessageBus())
|
channel = _ch(MessageBus())
|
||||||
@@ -857,6 +899,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
|||||||
assert client_connection not in channel._webui_connections
|
assert client_connection not in channel._webui_connections
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = AsyncMock()
|
||||||
|
channel._webui_connections.add(conn)
|
||||||
|
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||||
|
return_value=_http_json_response({"saved": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"webui-client",
|
||||||
|
{
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": "request-1",
|
||||||
|
"action": "settings.provider.update",
|
||||||
|
"payload": {"provider": "openrouter", "apiKey": "secret"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||||
|
|
||||||
|
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
|
||||||
|
conn,
|
||||||
|
"settings.provider.update",
|
||||||
|
{"provider": "openrouter", "apiKey": "secret"},
|
||||||
|
)
|
||||||
|
assert json.loads(conn.send.await_args.args[0]) == {
|
||||||
|
"event": "webui_response",
|
||||||
|
"request_id": "request-1",
|
||||||
|
"ok": True,
|
||||||
|
"result": {"saved": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = AsyncMock()
|
||||||
|
channel._webui_connections.add(conn)
|
||||||
|
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
||||||
|
return_value=_http_error(400, "invalid settings payload")
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"webui-client",
|
||||||
|
{
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": "request-2",
|
||||||
|
"action": "settings.agent.update",
|
||||||
|
"payload": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||||
|
|
||||||
|
assert json.loads(conn.send.await_args.args[0]) == {
|
||||||
|
"event": "webui_response",
|
||||||
|
"request_id": "request-2",
|
||||||
|
"ok": False,
|
||||||
|
"error": {"status": 400, "message": "invalid settings payload"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webui_request_requires_bootstrap_authenticated_connection(
|
||||||
|
bus: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
conn = AsyncMock()
|
||||||
|
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
|
||||||
|
|
||||||
|
await channel._dispatch_envelope(
|
||||||
|
conn,
|
||||||
|
"static-token-client",
|
||||||
|
{
|
||||||
|
"type": "webui_request",
|
||||||
|
"request_id": "request-3",
|
||||||
|
"action": "settings.agent.update",
|
||||||
|
"payload": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
|
||||||
|
assert json.loads(conn.send.await_args.args[0]) == {
|
||||||
|
"event": "webui_response",
|
||||||
|
"request_id": "request-3",
|
||||||
|
"ok": False,
|
||||||
|
"error": {"status": 403, "message": "access_denied"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
@@ -866,23 +1000,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
channel = _ch(bus)
|
channel = _ch(bus)
|
||||||
conn = AsyncMock()
|
conn = AsyncMock()
|
||||||
|
conn.request = SimpleNamespace(headers=Headers())
|
||||||
channel._webui_connections.add(conn)
|
channel._webui_connections.add(conn)
|
||||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
||||||
|
request_id = "sidebar-large-state"
|
||||||
envelope = {
|
envelope = {
|
||||||
"type": "set_sidebar_state",
|
"type": "webui_request",
|
||||||
"state": {
|
"request_id": request_id,
|
||||||
|
"action": "sidebar.update",
|
||||||
|
"payload": {"state": {
|
||||||
"session_order": session_order,
|
"session_order": session_order,
|
||||||
"view": {"sort": "manual"},
|
"view": {"sort": "manual"},
|
||||||
},
|
}},
|
||||||
}
|
}
|
||||||
assert len(json.dumps(envelope).encode()) > 8_192
|
assert len(json.dumps(envelope).encode()) > 8_192
|
||||||
|
|
||||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
||||||
|
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
||||||
|
|
||||||
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
||||||
assert saved["session_order"] == session_order
|
assert saved["session_order"] == session_order
|
||||||
assert saved["view"]["sort"] == "manual"
|
assert saved["view"]["sort"] == "manual"
|
||||||
conn.send.assert_not_awaited()
|
assert json.loads(conn.send.await_args.args[0]) == {
|
||||||
|
"event": "webui_response",
|
||||||
|
"request_id": request_id,
|
||||||
|
"ok": True,
|
||||||
|
"result": saved,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -2887,7 +3031,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
webui_client = None
|
||||||
try:
|
try:
|
||||||
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
|
webui_client = await websockets.connect(
|
||||||
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
|
||||||
|
)
|
||||||
|
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
|
||||||
|
assert ready["event"] == "ready"
|
||||||
|
|
||||||
settings = await _http_get(
|
settings = await _http_get(
|
||||||
f"http://127.0.0.1:{port}/api/settings",
|
f"http://127.0.0.1:{port}/api/settings",
|
||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
@@ -2971,11 +3123,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert unknown_api.status_code == 404
|
assert unknown_api.status_code == 404
|
||||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||||
|
|
||||||
provider_updated = await _http_get(
|
provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
"settings.provider.update",
|
||||||
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "openrouter",
|
||||||
|
"apiKey": "sk-or-test",
|
||||||
|
"apiBase": "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert provider_updated.status_code == 200
|
assert provider_updated.status_code == 200
|
||||||
provider_body = provider_updated.json()
|
provider_body = provider_updated.json()
|
||||||
@@ -2985,11 +3140,9 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert provider_body["image_generation"]["provider_configured"] is True
|
assert provider_body["image_generation"]["provider_configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
assert "sk-or-test" not in provider_updated.text
|
||||||
|
|
||||||
custom_provider_created = await _http_get(
|
custom_provider_created = await _webui_mutate(
|
||||||
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
webui_client,
|
||||||
headers={
|
"settings.provider.create",
|
||||||
"Authorization": "Bearer tok",
|
|
||||||
"X-Nanobot-Provider-Values": json.dumps(
|
|
||||||
{
|
{
|
||||||
"name": "Company Gateway",
|
"name": "Company Gateway",
|
||||||
"apiBase": "https://gateway.example/v1",
|
"apiBase": "https://gateway.example/v1",
|
||||||
@@ -2999,8 +3152,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||||
"proxy": "http://127.0.0.1:7890",
|
"proxy": "http://127.0.0.1:7890",
|
||||||
"thinkingStyle": "enable_thinking",
|
"thinkingStyle": "enable_thinking",
|
||||||
}
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert custom_provider_created.status_code == 200
|
assert custom_provider_created.status_code == 200
|
||||||
@@ -3015,11 +3166,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert "sk-company" not in custom_provider_created.text
|
assert "sk-company" not in custom_provider_created.text
|
||||||
|
|
||||||
local_provider_updated = await _http_get(
|
local_provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
"settings.provider.update",
|
||||||
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
||||||
headers={"Authorization": "Bearer tok"},
|
|
||||||
)
|
)
|
||||||
assert local_provider_updated.status_code == 200
|
assert local_provider_updated.status_code == 200
|
||||||
local_provider_body = local_provider_updated.json()
|
local_provider_body = local_provider_updated.json()
|
||||||
@@ -3029,38 +3179,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||||
assert "localhost:1337" in local_provider_updated.text
|
assert "localhost:1337" in local_provider_updated.text
|
||||||
|
|
||||||
updated = await _http_get(
|
updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model=atomic_chat/test"
|
"settings.agent.update",
|
||||||
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
{
|
||||||
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
"model": "atomic_chat/test",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "atomic_chat",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"tool_hint_max_length": 120,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
updated_body = updated.json()
|
updated_body = updated.json()
|
||||||
assert updated_body["requires_restart"] is True
|
assert updated_body["requires_restart"] is True
|
||||||
assert updated_body["restart_required_sections"] == ["runtime"]
|
assert updated_body["restart_required_sections"] == ["runtime"]
|
||||||
|
|
||||||
preset_updated = await _http_get(
|
preset_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model_preset=deep",
|
"settings.agent.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"model_preset": "deep"},
|
||||||
)
|
)
|
||||||
assert preset_updated.status_code == 200
|
assert preset_updated.status_code == 200
|
||||||
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
|
|
||||||
bad_preset = await _http_get(
|
bad_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/update?model_preset=missing",
|
"settings.agent.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"model_preset": "missing"},
|
||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
assert bad_preset.status_code == 400
|
||||||
|
|
||||||
created_preset = await _http_get(
|
created_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/create"
|
"settings.model_configuration.create",
|
||||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"label": "Fast writing",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-4.1-mini",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert created_preset.status_code == 200
|
assert created_preset.status_code == 200
|
||||||
created_body = created_preset.json()
|
created_body = created_preset.json()
|
||||||
@@ -3074,11 +3230,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||||
|
|
||||||
updated_preset = await _http_get(
|
updated_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/update"
|
"settings.model_configuration.update",
|
||||||
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"name": "fast-writing",
|
||||||
|
"label": "Codex",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-5.5",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert updated_preset.status_code == 200
|
assert updated_preset.status_code == 200
|
||||||
updated_preset_body = updated_preset.json()
|
updated_preset_body = updated_preset.json()
|
||||||
@@ -3089,11 +3249,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||||
|
|
||||||
call_order_updated = await _http_get(
|
call_order_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-call-order/update"
|
"settings.model_call_order.update",
|
||||||
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
{"order": ["fast-writing", "deep"]},
|
||||||
headers={"Authorization": "Bearer tok"},
|
|
||||||
)
|
)
|
||||||
assert call_order_updated.status_code == 200
|
assert call_order_updated.status_code == 200
|
||||||
call_order_body = call_order_updated.json()
|
call_order_body = call_order_updated.json()
|
||||||
@@ -3101,20 +3260,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||||
|
|
||||||
duplicate_preset = await _http_get(
|
duplicate_preset = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/model-configurations/create"
|
"settings.model_configuration.create",
|
||||||
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"label": "Fast writing",
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "openai/gpt-4.1-mini",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert duplicate_preset.status_code == 409
|
assert duplicate_preset.status_code == 409
|
||||||
|
|
||||||
search_updated = await _http_get(
|
search_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/web-search/update?provider=searxng"
|
"settings.web_search.update",
|
||||||
"&base_url=https%3A%2F%2Fsearch.example.com"
|
{
|
||||||
"&max_results=8&timeout=45&use_jina_reader=false",
|
"provider": "searxng",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"base_url": "https://search.example.com",
|
||||||
|
"max_results": 8,
|
||||||
|
"timeout": 45,
|
||||||
|
"use_jina_reader": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert search_updated.status_code == 200
|
assert search_updated.status_code == 200
|
||||||
search_body = search_updated.json()
|
search_body = search_updated.json()
|
||||||
@@ -3126,10 +3292,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert search_body["web_search"]["max_results"] == 8
|
assert search_body["web_search"]["max_results"] == 8
|
||||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||||
|
|
||||||
network_safety_updated = await _http_get(
|
network_safety_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
"settings.network_safety.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{
|
||||||
|
"webui_allow_local_service_access": False,
|
||||||
|
"webui_default_access_mode": "full",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert network_safety_updated.status_code == 200
|
assert network_safety_updated.status_code == 200
|
||||||
network_safety_body = network_safety_updated.json()
|
network_safety_body = network_safety_updated.json()
|
||||||
@@ -3139,13 +3308,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
||||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||||
|
|
||||||
image_updated = await _http_get(
|
image_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/image-generation/update?enabled=true"
|
"settings.image_generation.update",
|
||||||
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
{
|
||||||
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
"enabled": True,
|
||||||
"&max_images_per_turn=3",
|
"provider": "openrouter",
|
||||||
headers={"Authorization": "Bearer tok"},
|
"model": "openai/gpt-image-1",
|
||||||
|
"default_aspect_ratio": "16:9",
|
||||||
|
"default_image_size": "2K",
|
||||||
|
"max_images_per_turn": 3,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert image_updated.status_code == 200
|
assert image_updated.status_code == 200
|
||||||
image_body = image_updated.json()
|
image_body = image_updated.json()
|
||||||
@@ -3157,11 +3330,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert image_body["image_generation"]["default_image_size"] == "2K"
|
assert image_body["image_generation"]["default_image_size"] == "2K"
|
||||||
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
||||||
|
|
||||||
image_provider_updated = await _http_get(
|
image_provider_updated = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/provider/update?provider=openrouter"
|
"settings.provider.update",
|
||||||
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
{
|
||||||
headers={"Authorization": "Bearer tok"},
|
"provider": "openrouter",
|
||||||
|
"apiKey": "sk-or-next",
|
||||||
|
"apiBase": "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert image_provider_updated.status_code == 200
|
assert image_provider_updated.status_code == 200
|
||||||
assert image_provider_updated.json()["requires_restart"] is True
|
assert image_provider_updated.json()["requires_restart"] is True
|
||||||
@@ -3169,17 +3345,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert "sk-or-next" not in image_provider_updated.text
|
assert "sk-or-next" not in image_provider_updated.text
|
||||||
assert image_reload.await_count == 2
|
assert image_reload.await_count == 2
|
||||||
|
|
||||||
bad_web = await _http_get(
|
bad_web = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
"settings.web_search.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"provider": "duckduckgo", "max_results": 99},
|
||||||
)
|
)
|
||||||
assert bad_web.status_code == 400
|
assert bad_web.status_code == 400
|
||||||
|
|
||||||
bad_image = await _http_get(
|
bad_image = await _webui_mutate(
|
||||||
"http://127.0.0.1:"
|
webui_client,
|
||||||
f"{port}/api/settings/image-generation/update?provider=missing",
|
"settings.image_generation.update",
|
||||||
headers={"Authorization": "Bearer tok"},
|
{"provider": "missing"},
|
||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
@@ -3216,6 +3392,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert saved.tools.image_generation.default_image_size == "2K"
|
assert saved.tools.image_generation.default_image_size == "2K"
|
||||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||||
finally:
|
finally:
|
||||||
|
if webui_client is not None:
|
||||||
|
await webui_client.close()
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3248,11 +3426,17 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
webui_client = None
|
||||||
try:
|
try:
|
||||||
response = await _http_get(
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
webui_client = await websockets.connect(
|
||||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
||||||
headers={"Authorization": "Bearer tok"},
|
)
|
||||||
|
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||||
|
response = await _webui_mutate(
|
||||||
|
webui_client,
|
||||||
|
"settings.image_generation.update",
|
||||||
|
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -3260,6 +3444,8 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
assert response.json()["restart_required_sections"] == []
|
assert response.json()["restart_required_sections"] == []
|
||||||
image_reload.assert_awaited_once_with(bus)
|
image_reload.assert_awaited_once_with(bus)
|
||||||
finally:
|
finally:
|
||||||
|
if webui_client is not None:
|
||||||
|
await webui_client.close()
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3291,17 +3477,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
webui_client = None
|
||||||
try:
|
try:
|
||||||
response = await _http_get(
|
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
||||||
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
webui_client = await websockets.connect(
|
||||||
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
||||||
headers={"Authorization": "Bearer tok"},
|
)
|
||||||
|
assert json.loads(await webui_client.recv())["event"] == "ready"
|
||||||
|
response = await _webui_mutate(
|
||||||
|
webui_client,
|
||||||
|
"settings.image_generation.update",
|
||||||
|
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["requires_restart"] is True
|
assert response.json()["requires_restart"] is True
|
||||||
assert response.json()["restart_required_sections"] == ["image"]
|
assert response.json()["restart_required_sections"] == ["image"]
|
||||||
finally:
|
finally:
|
||||||
|
if webui_client is not None:
|
||||||
|
await webui_client.close()
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ class WeixinConnectSession:
|
|||||||
channel: WeixinChannel
|
channel: WeixinChannel
|
||||||
current_poll_base_url: str
|
current_poll_base_url: str
|
||||||
refresh_count: int
|
refresh_count: int
|
||||||
|
force: bool
|
||||||
created_wall: float
|
created_wall: float
|
||||||
deadline: float
|
deadline: float
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
@@ -72,7 +73,7 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
channel.connect_open_client()
|
channel.connect_open_client()
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@@ -89,6 +90,7 @@ class WeixinConnectStore:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
current_poll_base_url=channel.connect_base_url,
|
current_poll_base_url=channel.connect_base_url,
|
||||||
refresh_count=0,
|
refresh_count=0,
|
||||||
|
force=force,
|
||||||
created_wall=now_wall,
|
created_wall=now_wall,
|
||||||
deadline=time.monotonic() + 600,
|
deadline=time.monotonic() + 600,
|
||||||
)
|
)
|
||||||
@@ -187,7 +189,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
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:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -204,6 +206,17 @@ class WeixinConnectStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if status == "binded_redirect":
|
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():
|
if not session.channel.connect_load_state():
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
await self._close_channel(session.channel)
|
await self._close_channel(session.channel)
|
||||||
@@ -234,7 +247,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
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:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
|
|||||||
@@ -726,9 +726,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
break
|
break
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
async def _fetch_qr_code(self) -> tuple[str, str]:
|
async def _fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||||
"""Fetch a fresh QR code. Returns (qrcode_id, scan_url)."""
|
"""Fetch a QR code without existing credentials when forced."""
|
||||||
local_tokens = self._local_token_list()
|
local_tokens = [] if force else self._local_token_list()
|
||||||
data = await self._api_post(
|
data = await self._api_post(
|
||||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
"ilink/bot/get_bot_qrcode?bot_type=3",
|
||||||
{"local_token_list": local_tokens},
|
{"local_token_list": local_tokens},
|
||||||
@@ -755,11 +755,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
|
||||||
return qrcode_id, (qrcode_img_content or qrcode_id)
|
return qrcode_id, (qrcode_img_content or qrcode_id)
|
||||||
|
|
||||||
async def _qr_login(self) -> bool:
|
async def _qr_login(self, *, force: bool = False) -> bool:
|
||||||
"""Perform QR code login flow. Returns True on success."""
|
"""Perform QR login; forced flows accept only newly confirmed credentials."""
|
||||||
try:
|
try:
|
||||||
refresh_count = 0
|
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)
|
self._print_qr_code(scan_url)
|
||||||
current_poll_base_url = self.config.base_url
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
@@ -825,11 +825,16 @@ class WeixinChannel(BaseChannel):
|
|||||||
if refresh_count > MAX_QR_REFRESH_COUNT:
|
if refresh_count > MAX_QR_REFRESH_COUNT:
|
||||||
self.logger.warning("WeChat verification failed too many times")
|
self.logger.warning("WeChat verification failed too many times")
|
||||||
return False
|
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
|
current_poll_base_url = self.config.base_url
|
||||||
self._print_qr_code(scan_url)
|
self._print_qr_code(scan_url)
|
||||||
continue
|
continue
|
||||||
elif status == "binded_redirect":
|
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():
|
if self._token or self._load_state():
|
||||||
self.logger.info("WeChat account is already connected")
|
self.logger.info("WeChat account is already connected")
|
||||||
return True
|
return True
|
||||||
@@ -846,7 +851,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
MAX_QR_REFRESH_COUNT,
|
MAX_QR_REFRESH_COUNT,
|
||||||
)
|
)
|
||||||
return False
|
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
|
current_poll_base_url = self.config.base_url
|
||||||
verify_code = ""
|
verify_code = ""
|
||||||
self._print_qr_code(scan_url)
|
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._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
async def connect_fetch_qr_code(self) -> tuple[str, str]:
|
async def connect_fetch_qr_code(self, *, force: bool = False) -> tuple[str, str]:
|
||||||
return await self._fetch_qr_code()
|
return await self._fetch_qr_code(force=force)
|
||||||
|
|
||||||
async def connect_poll_qr_code(
|
async def connect_poll_qr_code(
|
||||||
self,
|
self,
|
||||||
@@ -947,14 +952,14 @@ class WeixinChannel(BaseChannel):
|
|||||||
if force:
|
if force:
|
||||||
self._token = ""
|
self._token = ""
|
||||||
self._get_updates_buf = ""
|
self._get_updates_buf = ""
|
||||||
if self._token or self._load_state():
|
if self._token or (not force and self._load_state()):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Initialize HTTP client for the login flow
|
# Initialize HTTP client for the login flow
|
||||||
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
self._client = self._new_http_client(httpx.Timeout(60, connect=30))
|
||||||
self._running = True # Enable polling loop in _qr_login()
|
self._running = True # Enable polling loop in _qr_login()
|
||||||
try:
|
try:
|
||||||
return await self._qr_login()
|
return await self._qr_login(force=force)
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._client:
|
if self._client:
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
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"
|
return "qr-1", "https://qr.example/1"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
observed_force: list[bool] = []
|
||||||
return "qr-reconnect", "https://qr.example/reconnect"
|
|
||||||
|
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, "_fetch_qr_code", fake_fetch_qr_code)
|
||||||
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
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
|
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||||
cancelled = await store.cancel(started["session_id"])
|
cancelled = await store.cancel(started["session_id"])
|
||||||
assert cancelled["status"] == "cancelled"
|
assert cancelled["status"] == "cancelled"
|
||||||
@@ -116,7 +135,9 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
|||||||
poll_started = asyncio.Event()
|
poll_started = asyncio.Event()
|
||||||
release_poll = 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"
|
return "qr-cancel", "https://qr.example/cancel"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
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"
|
return "qr-verify", "https://qr.example/verify"
|
||||||
|
|
||||||
responses = [
|
responses = [
|
||||||
@@ -204,7 +227,7 @@ async def test_weixin_connect_store_handles_verification_code(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> 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)
|
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"
|
return "qr-existing", "https://qr.example/existing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
started = await store.start(force=True)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "succeeded"
|
assert completed["status"] == "failed"
|
||||||
assert "already connected" in completed["message"]
|
assert "new WeChat login" in completed["message"]
|
||||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
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)
|
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"
|
return "qr-missing", "https://qr.example/missing"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
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)
|
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
started = await store.start(force=False)
|
||||||
completed = await store.poll(started["session_id"])
|
completed = await store.poll(started["session_id"])
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||||
channel, bus = _make_channel()
|
channel, bus = _make_channel()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
||||||
@@ -64,6 +65,7 @@ export function WeixinPanel({
|
|||||||
onAction,
|
onAction,
|
||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
}: ChannelPluginPanelProps) {
|
}: ChannelPluginPanelProps) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const channelTx = channelTranslator(t, "weixin");
|
const channelTx = channelTranslator(t, "weixin");
|
||||||
@@ -150,7 +152,7 @@ export function WeixinPanel({
|
|||||||
setSaveState("idle");
|
setSaveState("idle");
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
context.token,
|
client,
|
||||||
"weixin",
|
"weixin",
|
||||||
channelValuesForSave(editableFieldsRef.current, values),
|
channelValuesForSave(editableFieldsRef.current, values),
|
||||||
{ enable: context.enabled },
|
{ enable: context.enabled },
|
||||||
@@ -168,7 +170,7 @@ export function WeixinPanel({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ def _run_gateway(
|
|||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ class MCPServerConfig(Base):
|
|||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
|
|
||||||
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
||||||
|
auth: Literal["oauth"] | None = None # Remote MCP OAuth; tokens are stored outside config
|
||||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
@@ -89,8 +90,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
|||||||
return values[0] if values else None
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
def _manager() -> CliAppManager:
|
def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
cli_cfg = config.tools.cli_apps
|
cli_cfg = config.tools.cli_apps
|
||||||
return CliAppManager(
|
return CliAppManager(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
@@ -102,8 +103,12 @@ def _manager() -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
async def cli_apps_payload(
|
||||||
manager = _manager()
|
*,
|
||||||
|
installed_only: bool = False,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
manager = _manager(config_path) if config_path is not None else _manager()
|
||||||
if installed_only:
|
if installed_only:
|
||||||
return manager.installed_payload()
|
return manager.installed_payload()
|
||||||
payload = manager.payload(cache_only=True)
|
payload = manager.payload(cache_only=True)
|
||||||
@@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def cli_apps_action(
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise CliAppError("missing CLI app name")
|
raise CliAppError("missing CLI app name")
|
||||||
manager = _manager()
|
manager = _manager(config_path) if config_path is not None else _manager()
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return manager.install(name)
|
return manager.install(name)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable
|
|||||||
|
|
||||||
from loguru import logger as default_logger
|
from loguru import logger as default_logger
|
||||||
|
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
@@ -29,6 +31,7 @@ class GatewayServices:
|
|||||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||||
|
|
||||||
http: GatewayHTTPHandler
|
http: GatewayHTTPHandler
|
||||||
|
settings: WebUISettingsServices
|
||||||
tokens: GatewayTokenStore
|
tokens: GatewayTokenStore
|
||||||
media: WebUIMediaGateway
|
media: WebUIMediaGateway
|
||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
@@ -50,6 +53,7 @@ def build_gateway_services(
|
|||||||
static_dist_path: Path | None,
|
static_dist_path: Path | None,
|
||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
|
config_path: Path | None = None,
|
||||||
runtime_model_name: Callable[[], str | None] | None,
|
runtime_model_name: Callable[[], str | None] | None,
|
||||||
runtime_surface: str,
|
runtime_surface: str,
|
||||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||||
@@ -63,6 +67,7 @@ def build_gateway_services(
|
|||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
|
settings = WebUISettingsServices.create(config_path or get_config_path())
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||||
@@ -102,6 +107,7 @@ def build_gateway_services(
|
|||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
|
settings=settings,
|
||||||
skills_workspace_path=workspace_path,
|
skills_workspace_path=workspace_path,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
@@ -115,6 +121,7 @@ def build_gateway_services(
|
|||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
http=http,
|
http=http,
|
||||||
|
settings=settings,
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
"""Gateway-owned browser authorization flows for remote MCP servers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import SplitResult, parse_qs, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp import MCPConnection, connect_mcp_servers
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH, MCPOAuthHandlers
|
||||||
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
from nanobot.security.network import validate_url_target
|
||||||
|
from nanobot.webui.http_utils import is_loopback_host
|
||||||
|
|
||||||
|
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
_FLOW_TTL_S = 300
|
||||||
|
_START_WAIT_S = 20
|
||||||
|
_OAUTH_ERROR_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,80}$")
|
||||||
|
|
||||||
|
|
||||||
|
class McpOAuthError(Exception):
|
||||||
|
"""Safe WebUI error for an MCP OAuth request."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class _OAuthCallbackError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _McpOAuthFlow:
|
||||||
|
flow_id: str
|
||||||
|
name: str
|
||||||
|
cfg: MCPServerConfig
|
||||||
|
redirect_uri: str
|
||||||
|
manual_callback: bool
|
||||||
|
expires_at: float
|
||||||
|
authorization_ready: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
callback_result: asyncio.Future[tuple[str, str | None]] | None = None
|
||||||
|
task: asyncio.Task[bool] | None = None
|
||||||
|
authorization_url: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
callback_received: bool = False
|
||||||
|
error: str | None = None
|
||||||
|
reload_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, SplitResult, int | None]:
|
||||||
|
cleaned = redirect_uri.strip()
|
||||||
|
parsed = urlsplit(cleaned)
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise McpOAuthError("Invalid MCP OAuth callback URL") from exc
|
||||||
|
if (
|
||||||
|
not parsed.netloc
|
||||||
|
or not parsed.hostname
|
||||||
|
or parsed.path != MCP_OAUTH_CALLBACK_PATH
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
):
|
||||||
|
raise McpOAuthError("Invalid MCP OAuth callback URL")
|
||||||
|
return cleaned, parsed, port
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mcp_oauth_redirect_uri(redirect_uri: str) -> str:
|
||||||
|
"""Allow HTTPS callbacks, plus loopback HTTP for a local gateway."""
|
||||||
|
cleaned, parsed, _port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||||
|
if parsed.scheme == "https":
|
||||||
|
return cleaned
|
||||||
|
if parsed.scheme == "http" and is_loopback_host(parsed.netloc):
|
||||||
|
return cleaned
|
||||||
|
raise McpOAuthError("MCP OAuth callbacks must use HTTPS or localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_mcp_oauth_redirect_uri(redirect_uri: str) -> tuple[str, bool]:
|
||||||
|
"""Use a pasteable loopback callback when a remote WebUI is served over HTTP."""
|
||||||
|
cleaned, parsed, port = _parse_mcp_oauth_redirect_uri(redirect_uri)
|
||||||
|
if parsed.scheme != "http" or is_loopback_host(parsed.netloc):
|
||||||
|
return validate_mcp_oauth_redirect_uri(cleaned), False
|
||||||
|
|
||||||
|
loopback = "127.0.0.1" if port is None else f"127.0.0.1:{port}"
|
||||||
|
manual_redirect_uri = urlunsplit(("http", loopback, parsed.path, "", ""))
|
||||||
|
return validate_mcp_oauth_redirect_uri(manual_redirect_uri), True
|
||||||
|
|
||||||
|
|
||||||
|
class McpOAuthManager:
|
||||||
|
"""Own short-lived browser flows while the gateway process is running."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._flows: dict[str, _McpOAuthFlow] = {}
|
||||||
|
self._states: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
cfg: MCPServerConfig,
|
||||||
|
redirect_uri: str,
|
||||||
|
*,
|
||||||
|
reload_mcp: McpReload,
|
||||||
|
reset_credentials: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
self._prune()
|
||||||
|
redirect_uri, manual_callback = prepare_mcp_oauth_redirect_uri(redirect_uri)
|
||||||
|
await self._cancel_name(name)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
now = time.monotonic()
|
||||||
|
flow = _McpOAuthFlow(
|
||||||
|
flow_id=secrets.token_urlsafe(24),
|
||||||
|
name=name,
|
||||||
|
cfg=cfg,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
manual_callback=manual_callback,
|
||||||
|
expires_at=now + _FLOW_TTL_S,
|
||||||
|
callback_result=loop.create_future(),
|
||||||
|
)
|
||||||
|
self._flows[flow.flow_id] = flow
|
||||||
|
handlers = MCPOAuthHandlers(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
redirect_handler=lambda url: self._receive_authorization_url(flow, url),
|
||||||
|
callback_handler=lambda: self._wait_for_callback(flow),
|
||||||
|
reset_credentials=reset_credentials,
|
||||||
|
)
|
||||||
|
flow.task = asyncio.create_task(
|
||||||
|
self._connect_and_reload(flow, handlers, reload_mcp),
|
||||||
|
name=f"mcp-oauth:{name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
ready_waiter = asyncio.create_task(flow.authorization_ready.wait())
|
||||||
|
try:
|
||||||
|
await asyncio.wait(
|
||||||
|
{ready_waiter, flow.task},
|
||||||
|
timeout=_START_WAIT_S,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
ready_waiter.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await ready_waiter
|
||||||
|
return self._payload(flow)
|
||||||
|
|
||||||
|
async def status(self, flow_id: str) -> dict[str, Any]:
|
||||||
|
self._prune()
|
||||||
|
flow = self._flow(flow_id)
|
||||||
|
return self._payload(flow)
|
||||||
|
|
||||||
|
def submit_callback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
state: str,
|
||||||
|
code: str | None,
|
||||||
|
error: str | None,
|
||||||
|
) -> str:
|
||||||
|
self._prune()
|
||||||
|
flow_id = self._states.pop(state, None)
|
||||||
|
if flow_id is None:
|
||||||
|
raise McpOAuthError("This MCP authorization request has expired", status=410)
|
||||||
|
flow = self._flow(flow_id)
|
||||||
|
callback_result = flow.callback_result
|
||||||
|
if callback_result is None or callback_result.done():
|
||||||
|
raise McpOAuthError("This MCP authorization callback was already used", status=409)
|
||||||
|
|
||||||
|
flow.callback_received = True
|
||||||
|
if error:
|
||||||
|
safe_error = error if _OAUTH_ERROR_RE.fullmatch(error) else "authorization_failed"
|
||||||
|
flow.error = f"Authorization was not completed ({safe_error})."
|
||||||
|
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||||
|
raise McpOAuthError(flow.error)
|
||||||
|
elif not code or len(code) > 8192:
|
||||||
|
flow.error = "The MCP server did not return an authorization code."
|
||||||
|
callback_result.set_exception(_OAuthCallbackError(flow.error))
|
||||||
|
raise McpOAuthError(flow.error)
|
||||||
|
else:
|
||||||
|
callback_result.set_result((code, state))
|
||||||
|
return flow.name
|
||||||
|
|
||||||
|
def submit_callback_url(self, *, flow_id: str, callback_url: str) -> dict[str, Any]:
|
||||||
|
"""Complete a flow from a full browser callback URL pasted into the WebUI."""
|
||||||
|
self._prune()
|
||||||
|
flow = self._flow(flow_id)
|
||||||
|
parsed = urlsplit(callback_url.strip())
|
||||||
|
expected = urlsplit(flow.redirect_uri)
|
||||||
|
if (
|
||||||
|
not parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or parsed.scheme != expected.scheme
|
||||||
|
or parsed.netloc != expected.netloc
|
||||||
|
or parsed.path != expected.path
|
||||||
|
):
|
||||||
|
raise McpOAuthError(
|
||||||
|
"Paste the complete callback URL from the browser address bar."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
query = parse_qs(parsed.query, keep_blank_values=True, max_num_fields=16)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise McpOAuthError(
|
||||||
|
"Paste the complete callback URL from the browser address bar."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
states = query.get("state", [])
|
||||||
|
state = states[0] if len(states) == 1 else ""
|
||||||
|
if not state or state != flow.state:
|
||||||
|
raise McpOAuthError(
|
||||||
|
"This callback belongs to a different or expired authorization request. "
|
||||||
|
"Start again.",
|
||||||
|
status=410,
|
||||||
|
)
|
||||||
|
|
||||||
|
codes = query.get("code", [])
|
||||||
|
errors = query.get("error", [])
|
||||||
|
if len(codes) > 1 or len(errors) > 1 or (codes and errors):
|
||||||
|
raise McpOAuthError(
|
||||||
|
"Paste the complete callback URL from the browser address bar."
|
||||||
|
)
|
||||||
|
code = codes[0] if len(codes) == 1 else None
|
||||||
|
error = errors[0] if len(errors) == 1 else None
|
||||||
|
if (not code and not error) or (code is not None and len(code) > 8192):
|
||||||
|
raise McpOAuthError(
|
||||||
|
"Paste the complete callback URL from the browser address bar."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.submit_callback(state=state, code=code, error=error)
|
||||||
|
return self._payload(flow)
|
||||||
|
|
||||||
|
async def cancel(self, flow_id: str) -> dict[str, Any]:
|
||||||
|
self._prune()
|
||||||
|
flow = self._flow(flow_id)
|
||||||
|
await self._cancel_flow(flow)
|
||||||
|
return self._payload(flow)
|
||||||
|
|
||||||
|
async def _receive_authorization_url(
|
||||||
|
self,
|
||||||
|
flow: _McpOAuthFlow,
|
||||||
|
authorization_url: str,
|
||||||
|
) -> None:
|
||||||
|
parsed = urlsplit(authorization_url)
|
||||||
|
if (
|
||||||
|
parsed.scheme != "https"
|
||||||
|
or not parsed.netloc
|
||||||
|
or parsed.username is not None
|
||||||
|
or parsed.password is not None
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||||
|
raise McpOAuthError(flow.error)
|
||||||
|
ok, _error = validate_url_target(authorization_url)
|
||||||
|
if not ok:
|
||||||
|
flow.error = "The MCP server returned an unsafe authorization URL."
|
||||||
|
raise McpOAuthError(flow.error)
|
||||||
|
states = parse_qs(parsed.query).get("state", [])
|
||||||
|
state = states[0] if len(states) == 1 else ""
|
||||||
|
if not state or len(state) > 512:
|
||||||
|
flow.error = "The MCP server returned an invalid authorization URL."
|
||||||
|
raise McpOAuthError(flow.error)
|
||||||
|
if state in self._states:
|
||||||
|
flow.error = "The MCP server reused an OAuth state value."
|
||||||
|
raise McpOAuthError(flow.error)
|
||||||
|
flow.authorization_url = authorization_url
|
||||||
|
flow.state = state
|
||||||
|
self._states[state] = flow.flow_id
|
||||||
|
flow.authorization_ready.set()
|
||||||
|
|
||||||
|
async def _wait_for_callback(self, flow: _McpOAuthFlow) -> tuple[str, str | None]:
|
||||||
|
callback_result = flow.callback_result
|
||||||
|
if callback_result is None:
|
||||||
|
raise _OAuthCallbackError("MCP OAuth callback is unavailable")
|
||||||
|
remaining = max(0.1, flow.expires_at - time.monotonic())
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(asyncio.shield(callback_result), timeout=remaining)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
flow.error = "MCP authorization timed out."
|
||||||
|
raise _OAuthCallbackError(flow.error) from exc
|
||||||
|
|
||||||
|
async def _connect(self, flow: _McpOAuthFlow, handlers: MCPOAuthHandlers) -> bool:
|
||||||
|
connections: dict[str, MCPConnection] = {}
|
||||||
|
try:
|
||||||
|
connections = await connect_mcp_servers(
|
||||||
|
{flow.name: flow.cfg},
|
||||||
|
ToolRegistry(),
|
||||||
|
oauth_handlers={flow.name: handlers},
|
||||||
|
)
|
||||||
|
succeeded = flow.name in connections
|
||||||
|
if not succeeded and flow.error is None:
|
||||||
|
flow.error = "Could not complete the MCP OAuth connection."
|
||||||
|
return succeeded
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
if flow.error is None:
|
||||||
|
flow.error = "Could not complete the MCP OAuth connection."
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
for connection in connections.values():
|
||||||
|
with suppress(Exception):
|
||||||
|
await connection.aclose()
|
||||||
|
|
||||||
|
async def _connect_and_reload(
|
||||||
|
self,
|
||||||
|
flow: _McpOAuthFlow,
|
||||||
|
handlers: MCPOAuthHandlers,
|
||||||
|
reload_mcp: McpReload,
|
||||||
|
) -> bool:
|
||||||
|
succeeded = await self._connect(flow, handlers)
|
||||||
|
if not succeeded:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
flow.reload_result = await reload_mcp()
|
||||||
|
failed = flow.reload_result.get("failed")
|
||||||
|
if (
|
||||||
|
not flow.reload_result.get("ok")
|
||||||
|
and not flow.reload_result.get("requires_restart")
|
||||||
|
and isinstance(failed, list)
|
||||||
|
and flow.name in failed
|
||||||
|
):
|
||||||
|
flow.reload_result = await reload_mcp()
|
||||||
|
except Exception:
|
||||||
|
flow.reload_result = {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Signed in, but nanobot could not activate the MCP tools.",
|
||||||
|
"requires_restart": True,
|
||||||
|
}
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _flow(self, flow_id: str) -> _McpOAuthFlow:
|
||||||
|
flow = self._flows.get(flow_id)
|
||||||
|
if flow is None:
|
||||||
|
raise McpOAuthError("Unknown or expired MCP OAuth flow", status=404)
|
||||||
|
return flow
|
||||||
|
|
||||||
|
def _payload(self, flow: _McpOAuthFlow) -> dict[str, Any]:
|
||||||
|
task = flow.task
|
||||||
|
connected = flow.reload_result.get("connected") if flow.reload_result is not None else None
|
||||||
|
if task is not None and task.cancelled():
|
||||||
|
status = "cancelled"
|
||||||
|
elif task is not None and task.done():
|
||||||
|
try:
|
||||||
|
succeeded = task.result()
|
||||||
|
except Exception:
|
||||||
|
succeeded = False
|
||||||
|
if not succeeded:
|
||||||
|
status = "failed"
|
||||||
|
elif flow.reload_result is None:
|
||||||
|
status = "authorized"
|
||||||
|
elif flow.reload_result.get("ok") or (
|
||||||
|
isinstance(connected, list) and flow.name in connected
|
||||||
|
):
|
||||||
|
status = "connected"
|
||||||
|
else:
|
||||||
|
status = "authorized"
|
||||||
|
elif flow.callback_received:
|
||||||
|
status = "connecting"
|
||||||
|
elif flow.authorization_url:
|
||||||
|
status = "authorization_required"
|
||||||
|
else:
|
||||||
|
status = "starting"
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"flow_id": flow.flow_id,
|
||||||
|
"name": flow.name,
|
||||||
|
"status": status,
|
||||||
|
"expires_in": max(0, int(flow.expires_at - time.monotonic())),
|
||||||
|
}
|
||||||
|
if flow.manual_callback:
|
||||||
|
payload["completion_input"] = "callback_url"
|
||||||
|
if flow.authorization_url and status == "authorization_required":
|
||||||
|
payload["authorization_url"] = flow.authorization_url
|
||||||
|
if flow.error:
|
||||||
|
payload["error"] = flow.error
|
||||||
|
if flow.reload_result is not None:
|
||||||
|
payload["hot_reload"] = flow.reload_result
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def _cancel_name(self, name: str) -> None:
|
||||||
|
for flow in list(self._flows.values()):
|
||||||
|
if flow.name == name and flow.task is not None and not flow.task.done():
|
||||||
|
await self._cancel_flow(flow)
|
||||||
|
|
||||||
|
async def _cancel_flow(self, flow: _McpOAuthFlow) -> None:
|
||||||
|
if flow.state:
|
||||||
|
self._states.pop(flow.state, None)
|
||||||
|
task = flow.task
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with suppress(BaseException):
|
||||||
|
await task
|
||||||
|
|
||||||
|
def _prune(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
for flow_id, flow in list(self._flows.items()):
|
||||||
|
if flow.expires_at > now:
|
||||||
|
continue
|
||||||
|
if flow.state:
|
||||||
|
self._states.pop(flow.state, None)
|
||||||
|
if flow.task is not None and not flow.task.done():
|
||||||
|
flow.task.cancel()
|
||||||
|
callback_result = flow.callback_result
|
||||||
|
if callback_result is not None and not callback_result.done():
|
||||||
|
callback_result.cancel()
|
||||||
|
self._flows.pop(flow_id, None)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -14,8 +15,17 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, Mapping, cast
|
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import (
|
||||||
|
AgentPluginState,
|
||||||
|
discover_agent_plugin_states,
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
)
|
||||||
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
|
delete_mcp_oauth_credentials,
|
||||||
|
mcp_oauth_has_credentials,
|
||||||
|
)
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||||
@@ -25,6 +35,9 @@ from nanobot.utils.helpers import ensure_dir
|
|||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsConfig
|
||||||
|
|
||||||
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||||
_SECRET_QUERY_RE = re.compile(
|
_SECRET_QUERY_RE = re.compile(
|
||||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||||
@@ -48,7 +61,6 @@ _MAX_TEST_TOOLS = 16
|
|||||||
_DEFAULT_TEST_TIMEOUT = 20
|
_DEFAULT_TEST_TIMEOUT = 20
|
||||||
_DEFAULT_CUSTOM_TIMEOUT = 30
|
_DEFAULT_CUSTOM_TIMEOUT = 30
|
||||||
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
|
||||||
|
|
||||||
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
McpReload = Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
@@ -334,6 +346,63 @@ MCP_PRESETS: tuple[McpPreset, ...] = (
|
|||||||
),
|
),
|
||||||
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
note="Requires Figma Desktop Dev Mode MCP to be running locally.",
|
||||||
),
|
),
|
||||||
|
McpPreset(
|
||||||
|
name="xmind",
|
||||||
|
display_name="Xmind",
|
||||||
|
category="productivity",
|
||||||
|
description="Create, read, and edit cloud mind maps through Xmind.",
|
||||||
|
docs_url="https://xmind.com/user-guide/xmind-mcp",
|
||||||
|
transport="streamableHttp",
|
||||||
|
install_supported=True,
|
||||||
|
brand_domain="xmind.com",
|
||||||
|
brand_color="#F4B41A",
|
||||||
|
requires="Xmind account",
|
||||||
|
server=MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://app.xmind.com/api/mcp",
|
||||||
|
tool_timeout=60,
|
||||||
|
),
|
||||||
|
note="Connects securely in your browser with Xmind OAuth.",
|
||||||
|
),
|
||||||
|
McpPreset(
|
||||||
|
name="notion",
|
||||||
|
display_name="Notion",
|
||||||
|
category="productivity",
|
||||||
|
description="Read and update your Notion workspace through Notion MCP.",
|
||||||
|
docs_url="https://developers.notion.com/guides/mcp/get-started-with-mcp",
|
||||||
|
transport="streamableHttp",
|
||||||
|
install_supported=True,
|
||||||
|
brand_domain="notion.so",
|
||||||
|
brand_color="#111111",
|
||||||
|
requires="Notion account",
|
||||||
|
server=MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://mcp.notion.com/mcp",
|
||||||
|
tool_timeout=60,
|
||||||
|
),
|
||||||
|
note="Connects securely in your browser with Notion OAuth.",
|
||||||
|
),
|
||||||
|
McpPreset(
|
||||||
|
name="linear",
|
||||||
|
display_name="Linear",
|
||||||
|
category="productivity",
|
||||||
|
description="Find and manage Linear issues, projects, and comments.",
|
||||||
|
docs_url="https://linear.app/docs/mcp",
|
||||||
|
transport="streamableHttp",
|
||||||
|
install_supported=True,
|
||||||
|
brand_domain="linear.app",
|
||||||
|
brand_color="#5E6AD2",
|
||||||
|
requires="Linear account",
|
||||||
|
server=MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://mcp.linear.app/mcp",
|
||||||
|
tool_timeout=60,
|
||||||
|
),
|
||||||
|
note="Connects securely in your browser with Linear OAuth.",
|
||||||
|
),
|
||||||
McpPreset(
|
McpPreset(
|
||||||
name="github",
|
name="github",
|
||||||
display_name="GitHub",
|
display_name="GitHub",
|
||||||
@@ -654,6 +723,8 @@ def _status_for(preset: McpPreset, cfg: MCPServerConfig | None) -> str:
|
|||||||
return "not_installed" if preset.install_supported else "coming_soon"
|
return "not_installed" if preset.install_supported else "coming_soon"
|
||||||
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
if any(field.required and not _field_configured(field, cfg) for field in preset.fields):
|
||||||
return "missing_credentials"
|
return "missing_credentials"
|
||||||
|
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(preset.name, cfg.url):
|
||||||
|
return "authorization_required"
|
||||||
if cfg.command and not _command_available(cfg.command):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
return "missing_dependency"
|
return "missing_dependency"
|
||||||
return "configured"
|
return "configured"
|
||||||
@@ -699,6 +770,7 @@ def _preset_manifest(preset: McpPreset, *, logo_url: str) -> dict[str, Any]:
|
|||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"transport": preset.transport,
|
"transport": preset.transport,
|
||||||
|
"auth": server.auth if server and server.auth else None,
|
||||||
"command": server.command if server and server.command else None,
|
"command": server.command if server and server.command else None,
|
||||||
"args": list(server.args) if server and server.command else None,
|
"args": list(server.args) if server and server.command else None,
|
||||||
"url": _connection_summary(server) if server and server.url else None,
|
"url": _connection_summary(server) if server and server.url else None,
|
||||||
@@ -749,6 +821,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
|||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"auth": cfg.auth,
|
||||||
"command": cfg.command or None,
|
"command": cfg.command or None,
|
||||||
"url": _connection_summary(cfg) if cfg.url else None,
|
"url": _connection_summary(cfg) if cfg.url else None,
|
||||||
})
|
})
|
||||||
@@ -776,7 +849,7 @@ def _custom_manifest(name: str, cfg: MCPServerConfig) -> dict[str, Any]:
|
|||||||
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerConfig]) -> dict[str, Any]:
|
||||||
cfg = configured_servers.get(preset.name)
|
cfg = configured_servers.get(preset.name)
|
||||||
status = _status_for(preset, cfg)
|
status = _status_for(preset, cfg)
|
||||||
configured = cfg is not None and status not in {"missing_credentials"}
|
configured = cfg is not None and status not in {"missing_credentials", "authorization_required"}
|
||||||
logo_url = _favicon_url(preset.brand_domain)
|
logo_url = _favicon_url(preset.brand_domain)
|
||||||
return {
|
return {
|
||||||
"name": preset.name,
|
"name": preset.name,
|
||||||
@@ -785,6 +858,7 @@ def _preset_payload(preset: McpPreset, configured_servers: dict[str, MCPServerCo
|
|||||||
"description": preset.description,
|
"description": preset.description,
|
||||||
"docs_url": preset.docs_url,
|
"docs_url": preset.docs_url,
|
||||||
"transport": preset.transport,
|
"transport": preset.transport,
|
||||||
|
"auth": (cfg.auth if cfg is not None else (preset.server.auth if preset.server else None)),
|
||||||
"requires": preset.requires,
|
"requires": preset.requires,
|
||||||
"note": preset.note,
|
"note": preset.note,
|
||||||
"install_supported": preset.install_supported,
|
"install_supported": preset.install_supported,
|
||||||
@@ -811,7 +885,11 @@ def _custom_payload(
|
|||||||
transport = cfg.type
|
transport = cfg.type
|
||||||
if not transport:
|
if not transport:
|
||||||
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
transport = "stdio" if cfg.command else ("sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp")
|
||||||
|
if cfg.auth == "oauth" and not mcp_oauth_has_credentials(name, cfg.url):
|
||||||
|
status = "authorization_required"
|
||||||
|
else:
|
||||||
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
status = "missing_dependency" if cfg.command and not _command_available(cfg.command) else "configured"
|
||||||
|
configured = status != "authorization_required"
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"display_name": name,
|
"display_name": name,
|
||||||
@@ -819,12 +897,13 @@ def _custom_payload(
|
|||||||
"description": "Custom MCP server from nanobot config.",
|
"description": "Custom MCP server from nanobot config.",
|
||||||
"docs_url": "",
|
"docs_url": "",
|
||||||
"transport": transport,
|
"transport": transport,
|
||||||
|
"auth": cfg.auth,
|
||||||
"requires": "",
|
"requires": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"install_supported": True,
|
"install_supported": True,
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"configured": True,
|
"configured": configured,
|
||||||
"available": _config_available(cfg),
|
"available": configured and _config_available(cfg),
|
||||||
"status": status,
|
"status": status,
|
||||||
"logo_url": None,
|
"logo_url": None,
|
||||||
"brand_color": "#64748B",
|
"brand_color": "#64748B",
|
||||||
@@ -837,12 +916,50 @@ def _custom_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_logo_data_url(path: Path | None) -> str | None:
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
encoded = base64.b64encode(data).decode("ascii")
|
||||||
|
image_format = path.suffix.lower().lstrip(".").replace("jpg", "jpeg")
|
||||||
|
return f"data:image/{image_format};base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
|
||||||
|
plugin = state.plugin
|
||||||
|
return {
|
||||||
|
"name": f"plugin-{plugin.name}",
|
||||||
|
"display_name": plugin.display_name,
|
||||||
|
"category": plugin.category,
|
||||||
|
"description": plugin.description or "Agent Plugin",
|
||||||
|
"docs_url": plugin.repository,
|
||||||
|
"transport": "stdio",
|
||||||
|
"requires": ", ".join(plugin.permissions),
|
||||||
|
"note": "",
|
||||||
|
"install_supported": False,
|
||||||
|
"installed": True,
|
||||||
|
"configured": not state.setup_required,
|
||||||
|
"enabled": state.enabled,
|
||||||
|
"available": state.enabled,
|
||||||
|
"status": "enabled" if state.enabled else "disabled",
|
||||||
|
"logo_url": _plugin_logo_data_url(plugin.logo),
|
||||||
|
"brand_color": plugin.accent_color,
|
||||||
|
"required_fields": [],
|
||||||
|
"connection_summary": ", ".join(state.mcp_servers),
|
||||||
|
"source": "agent-plugin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mcp_presets_payload(
|
def mcp_presets_payload(
|
||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
known = _known_preset_names()
|
known = _known_preset_names()
|
||||||
preset_rows = [
|
preset_rows = [
|
||||||
_preset_payload(preset, config.tools.mcp_servers)
|
_preset_payload(preset, config.tools.mcp_servers)
|
||||||
@@ -854,9 +971,17 @@ def mcp_presets_payload(
|
|||||||
for name, cfg in sorted(config.tools.mcp_servers.items())
|
for name, cfg in sorted(config.tools.mcp_servers.items())
|
||||||
if name not in known
|
if name not in known
|
||||||
]
|
]
|
||||||
|
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||||
|
plugin_rows = [
|
||||||
|
_agent_plugin_payload(state)
|
||||||
|
for state in discover_agent_plugin_states(config.workspace_path)
|
||||||
|
if (state.mcp_servers or state.plugin.install_command)
|
||||||
|
and f"plugin-{state.plugin.name}" not in existing_names
|
||||||
|
]
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"presets": [*preset_rows, *custom_rows],
|
"presets": [*preset_rows, *custom_rows, *plugin_rows],
|
||||||
"installed_count": len(config.tools.mcp_servers),
|
"installed_count": len(config.tools.mcp_servers)
|
||||||
|
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||||
}
|
}
|
||||||
if last_action is not None:
|
if last_action is not None:
|
||||||
payload["last_action"] = last_action
|
payload["last_action"] = last_action
|
||||||
@@ -928,7 +1053,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
|||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
async def mcp_presets_test_action(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Connect to an enabled MCP preset and report its tool surface."""
|
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
@@ -941,16 +1070,22 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
display_name = _display_name_for(name, preset)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = resolve_config_env_vars(load_config())
|
config = resolve_config_env_vars(
|
||||||
|
load_config(config_path) if config_path is not None else load_config(),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return mcp_presets_payload(last_action={
|
return mcp_presets_payload(
|
||||||
|
last_action={
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": _scrub_test_error(str(exc)),
|
"message": _scrub_test_error(str(exc)),
|
||||||
"error": _scrub_test_error(str(exc)),
|
"error": _scrub_test_error(str(exc)),
|
||||||
"tool_count": 0,
|
"tool_count": 0,
|
||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
})
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
cfg = config.tools.mcp_servers.get(name)
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
@@ -968,7 +1103,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action)
|
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||||
|
|
||||||
if cfg.command and not _command_available(cfg.command):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
last_action = {
|
last_action = {
|
||||||
@@ -979,7 +1114,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action)
|
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
stacks: dict[str, Any] = {}
|
||||||
@@ -1040,7 +1175,11 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
tool_names = last_action.get("tool_names", [])
|
tool_names = last_action.get("tool_names", [])
|
||||||
preview = {name: tool_names} if tool_names else None
|
preview = {name: tool_names} if tool_names else None
|
||||||
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
return mcp_presets_payload(
|
||||||
|
last_action=last_action,
|
||||||
|
tool_preview=preview,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||||
@@ -1109,6 +1248,32 @@ def _normalize_transport(value: str | None, *, command: str = "", url: str = "")
|
|||||||
return normalized # type: ignore[return-value]
|
return normalized # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_auth(
|
||||||
|
value: object,
|
||||||
|
*,
|
||||||
|
transport: Literal["stdio", "sse", "streamableHttp"],
|
||||||
|
url: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
) -> Literal["oauth"] | None:
|
||||||
|
raw = str(value or "").strip().lower()
|
||||||
|
if not raw and url and not headers:
|
||||||
|
normalized_url = url.rstrip("/")
|
||||||
|
if any(
|
||||||
|
preset.server is not None
|
||||||
|
and preset.server.auth == "oauth"
|
||||||
|
and preset.server.url.rstrip("/") == normalized_url
|
||||||
|
for preset in MCP_PRESETS
|
||||||
|
):
|
||||||
|
raw = "oauth"
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if raw != "oauth":
|
||||||
|
raise McpPresetError("unsupported MCP auth type")
|
||||||
|
if transport == "stdio":
|
||||||
|
raise McpPresetError("MCP OAuth requires a remote HTTP transport")
|
||||||
|
return "oauth"
|
||||||
|
|
||||||
|
|
||||||
def _validated_server_name(name: str) -> str:
|
def _validated_server_name(name: str) -> str:
|
||||||
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
if not name or _MCP_PRESET_NAME_RE.match(name) is None:
|
||||||
raise McpPresetError("invalid MCP server name")
|
raise McpPresetError("invalid MCP server name")
|
||||||
@@ -1124,6 +1289,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
|||||||
raise McpPresetError("stdio MCP servers require a command")
|
raise McpPresetError("stdio MCP servers require a command")
|
||||||
if transport in {"sse", "streamableHttp"} and not url:
|
if transport in {"sse", "streamableHttp"} and not url:
|
||||||
raise McpPresetError("remote MCP servers require a URL")
|
raise McpPresetError("remote MCP servers require a URL")
|
||||||
|
headers = _parse_string_map(_query_first(query, "headers"))
|
||||||
|
auth = _normalize_auth(
|
||||||
|
_query_first(query, "auth"),
|
||||||
|
transport=transport,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
raw_timeout = (_query_first(query, "tool_timeout") or "").strip()
|
||||||
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
tool_timeout = _DEFAULT_CUSTOM_TIMEOUT
|
||||||
if raw_timeout:
|
if raw_timeout:
|
||||||
@@ -1133,12 +1305,13 @@ def _custom_server_from_query(query: QueryParams) -> tuple[str, MCPServerConfig]
|
|||||||
raise McpPresetError("tool_timeout must be an integer") from exc
|
raise McpPresetError("tool_timeout must be an integer") from exc
|
||||||
cfg = MCPServerConfig(
|
cfg = MCPServerConfig(
|
||||||
type=transport,
|
type=transport,
|
||||||
|
auth=auth,
|
||||||
command=command if transport == "stdio" else "",
|
command=command if transport == "stdio" else "",
|
||||||
args=_parse_string_list(_query_first(query, "args")),
|
args=_parse_string_list(_query_first(query, "args")),
|
||||||
env=_parse_string_map(_query_first(query, "env")),
|
env=_parse_string_map(_query_first(query, "env")),
|
||||||
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
cwd=(_query_first(query, "cwd") or "").strip() if transport == "stdio" else "",
|
||||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||||
headers=_parse_string_map(_query_first(query, "headers")),
|
headers=headers,
|
||||||
tool_timeout=tool_timeout,
|
tool_timeout=tool_timeout,
|
||||||
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
enabled_tools=_parse_enabled_tools(_query_first(query, "enabled_tools")),
|
||||||
)
|
)
|
||||||
@@ -1183,6 +1356,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
|||||||
headers = cast(dict[object, object], headers_value)
|
headers = cast(dict[object, object], headers_value)
|
||||||
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
if not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
|
||||||
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
|
raise McpPresetError(f"MCP server '{server_name}' headers must be a string object")
|
||||||
|
typed_headers = cast(dict[str, str], headers)
|
||||||
|
auth = _normalize_auth(
|
||||||
|
server.get("auth"),
|
||||||
|
transport=transport,
|
||||||
|
url=url,
|
||||||
|
headers=typed_headers,
|
||||||
|
)
|
||||||
if not isinstance(enabled_tools_value, list):
|
if not isinstance(enabled_tools_value, list):
|
||||||
enabled_tools_value = ["*"]
|
enabled_tools_value = ["*"]
|
||||||
else:
|
else:
|
||||||
@@ -1191,12 +1371,13 @@ def _mcp_server_config(name: str, raw: Any) -> tuple[str, MCPServerConfig]:
|
|||||||
enabled_tools_value = ["*"]
|
enabled_tools_value = ["*"]
|
||||||
return server_name, MCPServerConfig(
|
return server_name, MCPServerConfig(
|
||||||
type=transport,
|
type=transport,
|
||||||
|
auth=auth,
|
||||||
command=command if transport == "stdio" else "",
|
command=command if transport == "stdio" else "",
|
||||||
args=cast(list[str], args),
|
args=cast(list[str], args),
|
||||||
env=cast(dict[str, str], env),
|
env=cast(dict[str, str], env),
|
||||||
cwd=cwd if transport == "stdio" else "",
|
cwd=cwd if transport == "stdio" else "",
|
||||||
url=url if transport in {"sse", "streamableHttp"} else "",
|
url=url if transport in {"sse", "streamableHttp"} else "",
|
||||||
headers=cast(dict[str, str], headers),
|
headers=typed_headers,
|
||||||
tool_timeout=timeout_int,
|
tool_timeout=timeout_int,
|
||||||
enabled_tools=cast(list[str], enabled_tools_value),
|
enabled_tools=cast(list[str], enabled_tools_value),
|
||||||
)
|
)
|
||||||
@@ -1221,24 +1402,54 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def _oauth_credentials_replaced(
|
||||||
config = load_config()
|
previous: MCPServerConfig | None,
|
||||||
|
replacement: MCPServerConfig,
|
||||||
|
) -> bool:
|
||||||
|
if previous is None or previous.auth != "oauth":
|
||||||
|
return False
|
||||||
|
return replacement.auth != "oauth" or replacement.url != previous.url
|
||||||
|
|
||||||
|
|
||||||
|
def custom_mcp_action(
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
if action == "custom":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
name, cfg = _custom_server_from_query(query)
|
||||||
|
delete_credentials = _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
if delete_credentials:
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_server_action_message(action, name),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||||
|
delete_credentials = [
|
||||||
|
name
|
||||||
|
for name, cfg in servers.items()
|
||||||
|
if _oauth_credentials_replaced(config.tools.mcp_servers.get(name), cfg)
|
||||||
|
]
|
||||||
config.tools.mcp_servers.update(servers)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action={
|
for name in delete_credentials:
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
|
payload = mcp_presets_payload(
|
||||||
|
last_action={
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"Imported {len(servers)} MCP server(s).",
|
"message": f"Imported {len(servers)} MCP server(s).",
|
||||||
})
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1249,29 +1460,61 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
raise McpPresetError("unknown MCP server", status=404)
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_server_action_message(action, name),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||||
|
|
||||||
|
|
||||||
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def ensure_mcp_oauth_server(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> tuple[str, MCPServerConfig]:
|
||||||
|
"""Materialize an OAuth preset on first click and return its saved config."""
|
||||||
|
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||||
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
|
if cfg is None:
|
||||||
|
preset = _preset_by_name(name)
|
||||||
|
if preset.server is None or preset.server.auth != "oauth":
|
||||||
|
raise McpPresetError("MCP server does not support browser authorization", status=409)
|
||||||
|
cfg = _materialize_server(preset, query, None)
|
||||||
|
config.tools.mcp_servers[name] = cfg
|
||||||
|
save_config(config, config_path)
|
||||||
|
if cfg.auth != "oauth" or cfg.type not in {"sse", "streamableHttp"} or not cfg.url:
|
||||||
|
raise McpPresetError("MCP server is not configured for OAuth", status=409)
|
||||||
|
return name, cfg
|
||||||
|
|
||||||
|
|
||||||
|
def mcp_presets_action(
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise McpPresetError("missing MCP preset name")
|
raise McpPresetError("missing MCP preset name")
|
||||||
preset = _preset_by_name_optional(name)
|
preset = _preset_by_name_optional(name)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
existing = config.tools.mcp_servers.get(name)
|
existing = config.tools.mcp_servers.get(name)
|
||||||
|
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise McpPresetError("unknown MCP preset", status=404)
|
raise McpPresetError("unknown MCP preset", status=404)
|
||||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_action_message(action, preset),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1287,7 +1530,8 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
del config.tools.mcp_servers[name]
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
|
delete_mcp_oauth_credentials(name)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
@@ -1303,7 +1547,10 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||||
)
|
)
|
||||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
last_action["verification_failed"] = ["managed_paths_absent"]
|
||||||
payload = mcp_presets_payload(last_action=last_action)
|
payload = mcp_presets_payload(
|
||||||
|
last_action=last_action,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1339,13 +1586,59 @@ async def mcp_presets_settings_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
reload_mcp: McpReload | None = None,
|
reload_mcp: McpReload | None = None,
|
||||||
|
config: WebUISettingsConfig | None = None,
|
||||||
|
remote: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||||
|
config_path = config.path if config is not None else None
|
||||||
if action is None:
|
if action is None:
|
||||||
return mcp_presets_payload()
|
return mcp_presets_payload(config_path=config_path)
|
||||||
|
name = (_query_first(query, "name") or "").strip()
|
||||||
|
if name.startswith("plugin-"):
|
||||||
|
plugin_config = load_config(config_path) if config_path is not None else load_config()
|
||||||
|
plugin_name = name.removeprefix("plugin-")
|
||||||
|
plugin_states = discover_agent_plugin_states(plugin_config.workspace_path)
|
||||||
|
plugin_state = next((state for state in plugin_states if state.plugin.name == plugin_name), None)
|
||||||
|
if (
|
||||||
|
name not in plugin_config.tools.mcp_servers
|
||||||
|
and plugin_state is not None
|
||||||
|
and (plugin_state.mcp_servers or plugin_state.plugin.install_command)
|
||||||
|
):
|
||||||
|
if action not in {"enable", "disable"}:
|
||||||
|
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||||
|
if (
|
||||||
|
action == "enable"
|
||||||
|
and plugin_state.setup_required
|
||||||
|
and remote
|
||||||
|
and not plugin_config.tools.webui_allow_remote_package_install
|
||||||
|
):
|
||||||
|
raise McpPresetError(
|
||||||
|
"Agent Plugin setup is restricted to the local WebUI",
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
plugin = await asyncio.to_thread(
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
plugin_config.workspace_path,
|
||||||
|
plugin_name,
|
||||||
|
action == "enable",
|
||||||
|
)
|
||||||
|
verb = "enabled" if action == "enable" else "disabled"
|
||||||
|
payload = mcp_presets_payload(
|
||||||
|
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
if reload_mcp is not None:
|
||||||
|
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
|
||||||
|
return payload
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query)
|
return await mcp_presets_test_action(query, config_path=config_path)
|
||||||
if action in _CUSTOM_ACTIONS:
|
if config is not None:
|
||||||
|
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
config.run_serialized,
|
||||||
|
lambda path: operation(action, query, config_path=path),
|
||||||
|
)
|
||||||
|
elif action in _CUSTOM_ACTIONS:
|
||||||
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
@@ -15,9 +16,14 @@ from nanobot.webui.http_utils import query_first
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
def nanobot_features_payload() -> dict[str, Any]:
|
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||||
|
if config_path is None:
|
||||||
return optional_features_payload()
|
return optional_features_payload()
|
||||||
|
|
||||||
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
|
return optional_features_payload(config=load_config(config_path))
|
||||||
|
|
||||||
|
|
||||||
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||||
"""Preserve the difference between a global action and an explicit instance."""
|
"""Preserve the difference between a global action and an explicit instance."""
|
||||||
@@ -32,13 +38,19 @@ def nanobot_features_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
allow_install: bool = True,
|
allow_install: bool = True,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = (query_first(query, "name") or "").strip()
|
name = (query_first(query, "name") or "").strip()
|
||||||
instance_id = nanobot_feature_instance_target(query)
|
instance_id = nanobot_feature_instance_target(query)
|
||||||
if not name:
|
if not name:
|
||||||
raise OptionalFeatureError("missing feature name")
|
raise OptionalFeatureError("missing feature name")
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
return enable_optional_feature(
|
||||||
|
name,
|
||||||
|
config_path=config_path,
|
||||||
|
allow_install=allow_install,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
if action == "disable":
|
if action == "disable":
|
||||||
try:
|
try:
|
||||||
plugin = load_channel_plugin(name)
|
plugin = load_channel_plugin(name)
|
||||||
@@ -50,5 +62,9 @@ def nanobot_features_action(
|
|||||||
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
return disable_optional_feature(name, instance_id=instance_id)
|
return disable_optional_feature(
|
||||||
|
name,
|
||||||
|
config_path=config_path,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||||
|
|||||||
+168
-135
@@ -14,11 +14,11 @@ import math
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any, Literal, cast
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -49,6 +49,9 @@ from nanobot.webui.workspaces import (
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
RuntimeSurface = Literal["browser", "native"]
|
RuntimeSurface = Literal["browser", "native"]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||||
|
|
||||||
|
|
||||||
def _version_payload() -> dict[str, Any]:
|
def _version_payload() -> dict[str, Any]:
|
||||||
"""Return version info for the settings payload."""
|
"""Return version info for the settings payload."""
|
||||||
@@ -133,9 +136,6 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||||
_WEBUI_OAUTH_TIMEOUT_S = 600
|
_WEBUI_OAUTH_TIMEOUT_S = 600
|
||||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
|
||||||
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
|
|
||||||
_webui_oauth_flows_lock = threading.Lock()
|
|
||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
@@ -148,6 +148,21 @@ class WebUISettingsError(ValueError):
|
|||||||
self.status = status
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings_config(config_path: Path | None) -> Config:
|
||||||
|
return load_config(config_path) if config_path is not None else load_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _save_settings_config(config: Config, config_path: Path | None) -> None:
|
||||||
|
if config_path is None:
|
||||||
|
save_config(config)
|
||||||
|
else:
|
||||||
|
save_config(config, config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_config_path(config_path: Path | None) -> Path:
|
||||||
|
return config_path if config_path is not None else get_config_path()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
||||||
return "native" if surface in {"native", "desktop"} else "browser"
|
return "native" if surface in {"native", "desktop"} else "browser"
|
||||||
|
|
||||||
@@ -764,7 +779,11 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
def provider_models_payload(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||||
|
|
||||||
The result is advisory only: users can always type a custom model id. This
|
The result is advisory only: users can always type a custom model id. This
|
||||||
@@ -775,7 +794,7 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1117,8 +1136,9 @@ def settings_payload(
|
|||||||
runtime_capability_overrides: dict[str, Any] | None = None,
|
runtime_capability_overrides: dict[str, Any] | None = None,
|
||||||
restart_required_sections: list[str] | None = None,
|
restart_required_sections: list[str] | None = None,
|
||||||
apply_state: dict[str, Any] | None = None,
|
apply_state: dict[str, Any] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset or "default"
|
||||||
effective_preset = config.resolve_preset()
|
effective_preset = config.resolve_preset()
|
||||||
@@ -1299,7 +1319,7 @@ def settings_payload(
|
|||||||
"providers": _transcription_provider_rows(config),
|
"providers": _transcription_provider_rows(config),
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"config_path": str(get_config_path().expanduser()),
|
"config_path": str(_settings_config_path(config_path).expanduser()),
|
||||||
"workspace_path": str(config.workspace_path),
|
"workspace_path": str(config.workspace_path),
|
||||||
"gateway_host": config.gateway.host,
|
"gateway_host": config.gateway.host,
|
||||||
"gateway_port": config.gateway.port,
|
"gateway_port": config.gateway.port,
|
||||||
@@ -1341,14 +1361,18 @@ def settings_payload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def settings_usage_payload() -> dict[str, Any]:
|
def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
def update_agent_settings(
|
||||||
config = load_config()
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = _load_settings_config(config_path)
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
changed = False
|
changed = False
|
||||||
restart_required = False
|
restart_required = False
|
||||||
@@ -1425,11 +1449,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload(requires_restart=restart_required)
|
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
def create_model_configuration(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||||
raw_name = (_query_first(query, "name") or label).strip()
|
raw_name = (_query_first(query, "name") or label).strip()
|
||||||
model = (_query_first(query, "model") or "").strip()
|
model = (_query_first(query, "model") or "").strip()
|
||||||
@@ -1443,7 +1471,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
name = _model_configuration_slug(raw_name or label)
|
name = _model_configuration_slug(raw_name or label)
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
if name in config.model_presets:
|
if name in config.model_presets:
|
||||||
raise WebUISettingsError("configuration already exists", status=409)
|
raise WebUISettingsError("configuration already exists", status=409)
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
@@ -1476,18 +1504,22 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
temperature=temperature if temperature is not None else base.temperature,
|
temperature=temperature if temperature is not None else base.temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
payload = settings_payload()
|
payload = settings_payload(config_path=config_path)
|
||||||
payload["created_model_preset"] = name
|
payload["created_model_preset"] = name
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
def update_model_configuration(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
preset = config.model_presets.get(name)
|
preset = config.model_presets.get(name)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
@@ -1554,11 +1586,15 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
def update_model_call_order(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
raw_order = _query_first_alias(query, "order", "presetNames")
|
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||||
if raw_order is None:
|
if raw_order is None:
|
||||||
raise WebUISettingsError("model call order is required")
|
raise WebUISettingsError("model call order is required")
|
||||||
@@ -1580,7 +1616,7 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
|||||||
cast(str, name).strip()
|
cast(str, name).strip()
|
||||||
for name in cast(list[object], order)
|
for name in cast(list[object], order)
|
||||||
]
|
]
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
_, editable = _model_call_order_state(config)
|
_, editable = _model_call_order_state(config)
|
||||||
if not editable:
|
if not editable:
|
||||||
raise WebUISettingsError(
|
raise WebUISettingsError(
|
||||||
@@ -1599,13 +1635,17 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
|||||||
):
|
):
|
||||||
defaults.model_preset = normalized_order[0]
|
defaults.model_preset = normalized_order[0]
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
def migrate_model_configurations(
|
||||||
|
_query: QueryParams | None = None,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Materialize legacy primary/inline model settings as named presets."""
|
"""Materialize legacy primary/inline model settings as named presets."""
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
primary = config.resolve_preset()
|
primary = config.resolve_preset()
|
||||||
created: list[str] = []
|
created: list[str] = []
|
||||||
@@ -1658,16 +1698,20 @@ def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str,
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
def delete_model_configuration(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
if name not in config.model_presets:
|
if name not in config.model_presets:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
@@ -1681,11 +1725,15 @@ def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
del config.model_presets[name]
|
del config.model_presets[name]
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
def create_provider_settings(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise WebUISettingsError("provider name is required")
|
raise WebUISettingsError("provider name is required")
|
||||||
@@ -1710,7 +1758,7 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not api_base:
|
if not api_base:
|
||||||
raise WebUISettingsError("API base is required")
|
raise WebUISettingsError("API base is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
if _provider_display_name_exists(config, display_name):
|
if _provider_display_name_exists(config, display_name):
|
||||||
raise WebUISettingsError("provider already exists", status=409)
|
raise WebUISettingsError("provider already exists", status=409)
|
||||||
|
|
||||||
@@ -1719,18 +1767,22 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
updates["api_type"] = "auto"
|
updates["api_type"] = "auto"
|
||||||
provider_config = _validated_provider_config(None, updates)
|
provider_config = _validated_provider_config(None, updates)
|
||||||
setattr(config.providers, provider_key, provider_config)
|
setattr(config.providers, provider_key, provider_config)
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
payload = settings_payload()
|
payload = settings_payload(config_path=config_path)
|
||||||
payload["created_provider"] = provider_key
|
payload["created_provider"] = provider_key
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
def update_provider_settings(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1772,7 +1824,7 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = updated_provider_config != provider_config
|
changed = updated_provider_config != provider_config
|
||||||
if changed:
|
if changed:
|
||||||
setattr(config.providers, provider_key, updated_provider_config)
|
setattr(config.providers, provider_key, updated_provider_config)
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
restart_required = (
|
restart_required = (
|
||||||
changed
|
changed
|
||||||
@@ -1780,10 +1832,15 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
and image_config.provider == provider_key
|
and image_config.provider == provider_key
|
||||||
and get_image_gen_provider(provider_key) is not None
|
and get_image_gen_provider(provider_key) is not None
|
||||||
)
|
)
|
||||||
return settings_payload(requires_restart=restart_required)
|
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
def login_oauth_provider(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
@@ -1798,7 +1855,10 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
proxy = resolve_config_env_vars(
|
||||||
|
_load_settings_config(config_path),
|
||||||
|
config_path=config_path,
|
||||||
|
).providers.openai_codex.proxy or None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
remote_browser_value = _query_first(query, "remote_browser")
|
remote_browser_value = _query_first(query, "remote_browser")
|
||||||
@@ -1816,7 +1876,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
oauth_flows.register(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1840,13 +1900,16 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
token = login_github_copilot(print_fn=lambda _message: None)
|
token = login_github_copilot(print_fn=lambda _message: None)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
if spec.name == "xai_grok":
|
if spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
proxy = resolve_config_env_vars(
|
||||||
|
_load_settings_config(config_path),
|
||||||
|
config_path=config_path,
|
||||||
|
).providers.xai_grok.proxy or None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
try:
|
try:
|
||||||
@@ -1857,7 +1920,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
oauth_flows.register(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1873,6 +1936,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
def complete_oauth_provider(
|
def complete_oauth_provider(
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
authorization_response: str | None = None,
|
authorization_response: str | None = None,
|
||||||
|
*,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||||
@@ -1882,7 +1948,7 @@ def complete_oauth_provider(
|
|||||||
if not flow_id:
|
if not flow_id:
|
||||||
raise WebUISettingsError("flow_id is required")
|
raise WebUISettingsError("flow_id is required")
|
||||||
|
|
||||||
flow = _get_webui_oauth_flow(spec.name, flow_id)
|
flow = oauth_flows.get(spec.name, flow_id)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||||
|
|
||||||
@@ -1904,7 +1970,7 @@ def complete_oauth_provider(
|
|||||||
except WebUISettingsError:
|
except WebUISettingsError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_remove_webui_oauth_flow(spec.name, flow_id, flow)
|
oauth_flows.remove(spec.name, flow_id, flow)
|
||||||
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||||
if token is None:
|
if token is None:
|
||||||
return {
|
return {
|
||||||
@@ -1912,13 +1978,18 @@ def complete_oauth_provider(
|
|||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
"flow_id": flow_id,
|
"flow_id": flow_id,
|
||||||
}
|
}
|
||||||
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
|
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
||||||
if not token.access:
|
if not token.access:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
def logout_oauth_provider(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
@@ -1932,7 +2003,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
from oauth_cli_kit.storage import FileTokenStorage
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||||
_clear_webui_oauth_flows(spec.name)
|
oauth_flows.clear(spec.name)
|
||||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||||
elif spec.name == "github_copilot":
|
elif spec.name == "github_copilot":
|
||||||
try:
|
try:
|
||||||
@@ -1943,77 +2014,23 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
elif spec.name == "xai_grok":
|
elif spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||||
|
|
||||||
_clear_webui_oauth_flows(spec.name)
|
oauth_flows.clear(spec.name)
|
||||||
logout_xai_oauth()
|
logout_xai_oauth()
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
else:
|
else:
|
||||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||||
|
|
||||||
for path in (token_path, token_path.with_suffix(".lock")):
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
path.unlink()
|
path.unlink()
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
|
def update_network_safety_settings(
|
||||||
discarded: list[Any] = []
|
query: QueryParams,
|
||||||
with _webui_oauth_flows_lock:
|
|
||||||
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
|
|
||||||
if existing.expired:
|
|
||||||
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
|
|
||||||
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
|
|
||||||
oldest_id = next(iter(_webui_oauth_flows))
|
|
||||||
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
|
|
||||||
_webui_oauth_flows[flow_id] = (provider_name, flow)
|
|
||||||
for existing in discarded:
|
|
||||||
existing.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
|
|
||||||
with _webui_oauth_flows_lock:
|
|
||||||
registered = _webui_oauth_flows.get(flow_id)
|
|
||||||
if registered is None or registered[0] != provider_name:
|
|
||||||
return None
|
|
||||||
flow = registered[1]
|
|
||||||
if not flow.expired:
|
|
||||||
return flow
|
|
||||||
_webui_oauth_flows.pop(flow_id, None)
|
|
||||||
flow.cancel()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_webui_oauth_flow(
|
|
||||||
provider_name: str,
|
|
||||||
flow_id: str,
|
|
||||||
flow: Any,
|
|
||||||
*,
|
*,
|
||||||
cancel: bool = True,
|
config_path: Path | None = None,
|
||||||
) -> None:
|
) -> dict[str, Any]:
|
||||||
with _webui_oauth_flows_lock:
|
|
||||||
registered = _webui_oauth_flows.get(flow_id)
|
|
||||||
if (
|
|
||||||
registered is not None
|
|
||||||
and registered[0] == provider_name
|
|
||||||
and registered[1] is flow
|
|
||||||
):
|
|
||||||
_webui_oauth_flows.pop(flow_id)
|
|
||||||
if cancel:
|
|
||||||
flow.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_webui_oauth_flows(provider_name: str) -> None:
|
|
||||||
with _webui_oauth_flows_lock:
|
|
||||||
flow_ids = [
|
|
||||||
flow_id
|
|
||||||
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
|
|
||||||
if registered_provider == provider_name
|
|
||||||
]
|
|
||||||
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
|
|
||||||
for flow in flows:
|
|
||||||
flow.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
|
||||||
raw_allow = (
|
raw_allow = (
|
||||||
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
||||||
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
||||||
@@ -2022,7 +2039,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if raw_allow is None and raw_default_access_mode is None:
|
if raw_allow is None and raw_default_access_mode is None:
|
||||||
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
changed = False
|
changed = False
|
||||||
if raw_allow is not None:
|
if raw_allow is not None:
|
||||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||||
@@ -2031,7 +2048,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
if raw_default_access_mode is not None:
|
if raw_default_access_mode is not None:
|
||||||
default_access_mode = raw_default_access_mode.strip().lower()
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
if default_access_mode == "restricted":
|
if default_access_mode == "restricted":
|
||||||
@@ -2042,16 +2059,20 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
write_webui_default_access_mode(default_access_mode)
|
write_webui_default_access_mode(default_access_mode)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise WebUISettingsError(str(exc)) from exc
|
raise WebUISettingsError(str(exc)) from exc
|
||||||
return settings_payload(requires_restart=changed)
|
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
def update_web_search_settings(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
||||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||||
if provider_option is None:
|
if provider_option is None:
|
||||||
raise WebUISettingsError("unknown web search provider")
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
search_config = config.tools.web.search
|
search_config = config.tools.web.search
|
||||||
web_config = config.tools.web
|
web_config = config.tools.web
|
||||||
previous_provider = search_config.provider
|
previous_provider = search_config.provider
|
||||||
@@ -2130,13 +2151,17 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload(requires_restart=restart_required)
|
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
def update_api_settings(
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Update the managed OpenAI-compatible API configuration."""
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
api = config.api
|
api = config.api
|
||||||
|
|
||||||
host = _query_first(query, "host")
|
host = _query_first(query, "host")
|
||||||
@@ -2173,12 +2198,16 @@ def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||||
raise WebUISettingsError("an API key is required when the API is available on the network")
|
raise WebUISettingsError("an API key is required when the API is available on the network")
|
||||||
|
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
def update_image_generation_settings(
|
||||||
config = load_config()
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = _load_settings_config(config_path)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2271,12 +2300,16 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError("image generation provider is not configured")
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload(requires_restart=changed)
|
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
def update_transcription_settings(
|
||||||
config = load_config()
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = _load_settings_config(config_path)
|
||||||
transcription = config.transcription
|
transcription = config.transcription
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2341,5 +2374,5 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|||||||
+392
-161
@@ -8,18 +8,19 @@ request mapping and response shaping.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import html
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels._setup import channel_setup_spec
|
from nanobot.channels._setup import channel_setup_spec
|
||||||
@@ -31,7 +32,7 @@ from nanobot.channels.contracts import (
|
|||||||
)
|
)
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.optional_features import (
|
from nanobot.optional_features import (
|
||||||
OptionalFeatureError,
|
OptionalFeatureError,
|
||||||
extra_installed,
|
extra_installed,
|
||||||
@@ -40,10 +41,11 @@ from nanobot.optional_features import (
|
|||||||
)
|
)
|
||||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||||
from nanobot.webui.http_utils import case_insensitive_header
|
from nanobot.webui.http_utils import http_response as _http_response
|
||||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||||
from nanobot.webui.http_utils import query_first as _query_first
|
from nanobot.webui.http_utils import query_first as _query_first
|
||||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
from nanobot.webui.mcp_oauth_api import McpOAuthManager
|
||||||
|
from nanobot.webui.mcp_presets_api import ensure_mcp_oauth_server, mcp_presets_settings_action
|
||||||
from nanobot.webui.nanobot_features_api import (
|
from nanobot.webui.nanobot_features_api import (
|
||||||
nanobot_feature_instance_target,
|
nanobot_feature_instance_target,
|
||||||
nanobot_features_action,
|
nanobot_features_action,
|
||||||
@@ -72,24 +74,17 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
from nanobot.webui.version_check import check_for_update
|
from nanobot.webui.version_check import check_for_update
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||||
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||||
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
|
||||||
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
|
||||||
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
|
||||||
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
|
||||||
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
|
||||||
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
|
||||||
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
|
||||||
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
|
||||||
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
|
||||||
|
|
||||||
_SKIP_FIELD = object()
|
_SKIP_FIELD = object()
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||||
|
_MCP_OAUTH_CALLBACK_URL_MAX_BYTES = 8 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
||||||
@@ -104,6 +99,7 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
|||||||
|
|
||||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||||
"/api/settings/mcp-presets/enable": "enable",
|
"/api/settings/mcp-presets/enable": "enable",
|
||||||
|
"/api/settings/mcp-presets/disable": "disable",
|
||||||
"/api/settings/mcp-presets/remove": "remove",
|
"/api/settings/mcp-presets/remove": "remove",
|
||||||
"/api/settings/mcp-presets/test": "test",
|
"/api/settings/mcp-presets/test": "test",
|
||||||
"/api/settings/mcp-presets/custom": "custom",
|
"/api/settings/mcp-presets/custom": "custom",
|
||||||
@@ -112,6 +108,66 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
|
|||||||
"/api/settings/mcp-presets/tools": "tools",
|
"/api/settings/mcp-presets/tools": "tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SETTINGS_MUTATION_PATHS = frozenset({
|
||||||
|
"/api/settings/update",
|
||||||
|
"/api/settings/model-configurations/create",
|
||||||
|
"/api/settings/model-configurations/update",
|
||||||
|
"/api/settings/model-configurations/delete",
|
||||||
|
"/api/settings/model-configurations/migrate",
|
||||||
|
"/api/settings/model-call-order/update",
|
||||||
|
"/api/settings/provider/update",
|
||||||
|
"/api/settings/provider/create",
|
||||||
|
"/api/settings/provider/oauth-login",
|
||||||
|
"/api/settings/provider/oauth-login/complete",
|
||||||
|
"/api/settings/provider/oauth-logout",
|
||||||
|
"/api/settings/web-search/update",
|
||||||
|
"/api/settings/api-service/start",
|
||||||
|
"/api/settings/api-service/stop",
|
||||||
|
"/api/settings/image-generation/update",
|
||||||
|
"/api/settings/transcription/update",
|
||||||
|
"/api/settings/network-safety/update",
|
||||||
|
"/api/settings/cli-apps/install",
|
||||||
|
"/api/settings/cli-apps/update",
|
||||||
|
"/api/settings/cli-apps/uninstall",
|
||||||
|
"/api/settings/cli-apps/test",
|
||||||
|
"/api/settings/nanobot-features/enable",
|
||||||
|
"/api/settings/nanobot-features/disable",
|
||||||
|
"/api/settings/channels/validate",
|
||||||
|
"/api/settings/channels/configure",
|
||||||
|
"/api/settings/pairing/approve",
|
||||||
|
"/api/settings/pairing/deny",
|
||||||
|
"/api/settings/mcp-oauth/start",
|
||||||
|
"/api/settings/mcp-oauth/complete",
|
||||||
|
"/api/settings/mcp-oauth/cancel",
|
||||||
|
*_MCP_PRESET_ACTIONS_BY_PATH,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||||
|
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
return cast(dict[str, Any], payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_value(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_query(payload: dict[str, Any]) -> QueryParams:
|
||||||
|
return {
|
||||||
|
key: [_query_value(value)]
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key
|
||||||
|
and key not in {"authorization_response", "channel", "values"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsRouter:
|
class WebUISettingsRouter:
|
||||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||||
@@ -119,6 +175,7 @@ class WebUISettingsRouter:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
settings: WebUISettingsServices,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
logger: Any,
|
logger: Any,
|
||||||
check_api_token: Callable[[WsRequest], bool],
|
check_api_token: Callable[[WsRequest], bool],
|
||||||
@@ -129,7 +186,9 @@ class WebUISettingsRouter:
|
|||||||
runtime_capabilities: dict[str, Any],
|
runtime_capabilities: dict[str, Any],
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
|
mcp_oauth_redirect_uri: Callable[[WsRequest], str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.settings = settings
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._check_api_token = check_api_token
|
self._check_api_token = check_api_token
|
||||||
@@ -140,10 +199,23 @@ class WebUISettingsRouter:
|
|||||||
self._runtime_capabilities = runtime_capabilities
|
self._runtime_capabilities = runtime_capabilities
|
||||||
self._channel_feature_action = channel_feature_action
|
self._channel_feature_action = channel_feature_action
|
||||||
self._channel_runtime_status = channel_runtime_status
|
self._channel_runtime_status = channel_runtime_status
|
||||||
|
self._mcp_oauth_redirect_uri = mcp_oauth_redirect_uri
|
||||||
|
self._mcp_oauth = McpOAuthManager()
|
||||||
self._restart_sections: set[str] = set()
|
self._restart_sections: set[str] = set()
|
||||||
self._channel_connectors: dict[str, Any] = {}
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||||
|
if self.is_mutation_path(path) and not getattr(
|
||||||
|
request,
|
||||||
|
_WEBUI_MUTATION_REQUEST_ATTR,
|
||||||
|
False,
|
||||||
|
):
|
||||||
|
return self._error_response(
|
||||||
|
405,
|
||||||
|
"WebUI mutations require an authenticated WebSocket",
|
||||||
|
)
|
||||||
|
if path == MCP_OAUTH_CALLBACK_PATH:
|
||||||
|
return self._handle_mcp_oauth_callback(request)
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return self._handle_settings(request)
|
return self._handle_settings(request)
|
||||||
if path == "/api/settings/usage":
|
if path == "/api/settings/usage":
|
||||||
@@ -222,15 +294,33 @@ class WebUISettingsRouter:
|
|||||||
if path == "/api/settings/pairing/deny":
|
if path == "/api/settings/pairing/deny":
|
||||||
return self._handle_settings_pairing_action(request, "deny")
|
return self._handle_settings_pairing_action(request, "deny")
|
||||||
if path == "/api/settings/mcp-presets":
|
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/mcp-oauth/start":
|
||||||
|
return await self._handle_mcp_oauth_start(request)
|
||||||
|
if path == "/api/settings/mcp-oauth/status":
|
||||||
|
return await self._handle_mcp_oauth_status(request)
|
||||||
|
if path == "/api/settings/mcp-oauth/complete":
|
||||||
|
return self._handle_mcp_oauth_complete(request)
|
||||||
|
if path == "/api/settings/mcp-oauth/cancel":
|
||||||
|
return await self._handle_mcp_oauth_cancel(request)
|
||||||
if path == "/api/settings/version-check":
|
if path == "/api/settings/version-check":
|
||||||
return await self._handle_settings_version_check(request)
|
return await self._handle_settings_version_check(request)
|
||||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||||
if mcp_action is not None:
|
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
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_mutation_path(path: str) -> bool:
|
||||||
|
return (
|
||||||
|
path in _SETTINGS_MUTATION_PATHS
|
||||||
|
or _channel_connect_route(path) is not None
|
||||||
|
)
|
||||||
|
|
||||||
def _query(self, request: WsRequest) -> QueryParams:
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
|
payload = _mutation_payload(request)
|
||||||
|
if payload is not None:
|
||||||
|
return _payload_query(payload)
|
||||||
return self._parse_query(request.path)
|
return self._parse_query(request.path)
|
||||||
|
|
||||||
def _authorized(self, request: WsRequest) -> bool:
|
def _authorized(self, request: WsRequest) -> bool:
|
||||||
@@ -260,70 +350,18 @@ class WebUISettingsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
query = self._query(request)
|
return self._query(request)
|
||||||
raw = request.headers.get(_MCP_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return query
|
|
||||||
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("MCP settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid MCP settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
|
||||||
payload = cast(dict[object, Any], payload)
|
|
||||||
merged = {key: list(values) for key, values in query.items()}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if not isinstance(key, str) or not key:
|
|
||||||
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, str):
|
|
||||||
text = value.strip()
|
|
||||||
else:
|
|
||||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
if text:
|
|
||||||
merged[key] = [text]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
query = self._query(request)
|
return self._query(request)
|
||||||
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return query
|
|
||||||
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("provider settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
try:
|
|
||||||
payload = json.loads(unquote(raw))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise WebUISettingsError("invalid provider settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("provider settings payload must be a JSON object")
|
|
||||||
payload = cast(dict[object, Any], payload)
|
|
||||||
|
|
||||||
merged = {key: list(values) for key, values in query.items()}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if not isinstance(key, str) or not key:
|
|
||||||
raise WebUISettingsError("provider settings payload contains an invalid key")
|
|
||||||
if isinstance(value, str):
|
|
||||||
text = value
|
|
||||||
elif value is None:
|
|
||||||
text = ""
|
|
||||||
else:
|
|
||||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
merged[key] = [text]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
def _handle_settings(self, request: WsRequest) -> Response:
|
def _handle_settings(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(
|
return self._json_response(
|
||||||
self._with_restart_state(
|
self._with_restart_state(
|
||||||
settings_payload(
|
self.settings.read(
|
||||||
|
settings_payload,
|
||||||
surface=self._runtime_surface,
|
surface=self._runtime_surface,
|
||||||
runtime_capability_overrides=self._runtime_capabilities,
|
runtime_capability_overrides=self._runtime_capabilities,
|
||||||
)
|
)
|
||||||
@@ -333,7 +371,7 @@ class WebUISettingsRouter:
|
|||||||
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(settings_usage_payload())
|
return self._json_response(self.settings.read(settings_usage_payload))
|
||||||
|
|
||||||
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -379,7 +417,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_agent_settings(self._query(request))
|
payload = self.settings.mutate(update_agent_settings, self._query(request))
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -388,7 +426,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = create_model_configuration(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
create_model_configuration,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -397,7 +438,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_model_configuration(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_model_configuration,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -406,7 +450,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = delete_model_configuration(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
delete_model_configuration,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -415,7 +462,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = migrate_model_configurations(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
migrate_model_configurations,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -424,7 +474,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_model_call_order(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_model_call_order,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -433,7 +486,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_provider_settings,
|
||||||
|
self._parse_provider_settings_query(request)
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -443,7 +499,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
payload = self.settings.mutate(
|
||||||
|
create_provider_settings,
|
||||||
|
self._parse_provider_settings_query(request)
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -452,7 +511,11 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
payload = await asyncio.to_thread(
|
||||||
|
self.settings.read,
|
||||||
|
provider_models_payload,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -470,27 +533,33 @@ class WebUISettingsRouter:
|
|||||||
query = self._query(request)
|
query = self._query(request)
|
||||||
try:
|
try:
|
||||||
if action == "login":
|
if action == "login":
|
||||||
payload = await asyncio.to_thread(login_oauth_provider, query)
|
|
||||||
elif action == "complete":
|
|
||||||
authorization_response = case_insensitive_header(
|
|
||||||
request.headers,
|
|
||||||
_OAUTH_CALLBACK_HEADER,
|
|
||||||
) or case_insensitive_header(
|
|
||||||
request.headers,
|
|
||||||
_OAUTH_CODE_HEADER,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
len(authorization_response.encode("utf-8"))
|
|
||||||
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
|
||||||
):
|
|
||||||
raise WebUISettingsError("OAuth authorization response is too large")
|
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
|
self.settings.read,
|
||||||
|
login_oauth_provider,
|
||||||
|
query,
|
||||||
|
oauth_flows=self.settings.oauth_flows,
|
||||||
|
)
|
||||||
|
elif action == "complete":
|
||||||
|
raw_response = (_mutation_payload(request) or {}).get(
|
||||||
|
"authorization_response"
|
||||||
|
)
|
||||||
|
if raw_response is not None and not isinstance(raw_response, str):
|
||||||
|
raise WebUISettingsError("OAuth authorization response must be a string")
|
||||||
|
authorization_response = raw_response
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
self.settings.read,
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
query,
|
query,
|
||||||
authorization_response or None,
|
authorization_response or None,
|
||||||
|
oauth_flows=self.settings.oauth_flows,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
payload = await asyncio.to_thread(
|
||||||
|
self.settings.read,
|
||||||
|
logout_oauth_provider,
|
||||||
|
query,
|
||||||
|
oauth_flows=self.settings.oauth_flows,
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
if payload.get("status") in {"authorization_required", "pending"}:
|
if payload.get("status") in {"authorization_required", "pending"}:
|
||||||
@@ -501,7 +570,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_web_search_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_web_search_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||||
@@ -520,19 +592,22 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
{"name": ["api"]},
|
{"name": ["api"]},
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
update_api_settings(self._parse_api_service_settings_query(request))
|
self.settings.mutate(
|
||||||
config = load_config()
|
update_api_settings,
|
||||||
|
self._parse_api_service_settings_query(request),
|
||||||
|
)
|
||||||
|
config = self.settings.config.load()
|
||||||
runtime = self._api_runtime()
|
runtime = self._api_runtime()
|
||||||
options = ApiStartOptions(
|
options = ApiStartOptions(
|
||||||
host=config.api.host,
|
host=config.api.host,
|
||||||
port=config.api.port,
|
port=config.api.port,
|
||||||
workspace=str(config.workspace_path),
|
workspace=str(config.workspace_path),
|
||||||
config_path=str(get_config_path().expanduser().resolve(strict=False)),
|
config_path=str(self.settings.config.path),
|
||||||
)
|
)
|
||||||
current = runtime.status()
|
current = runtime.status()
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
@@ -549,33 +624,12 @@ class WebUISettingsRouter:
|
|||||||
return self._json_response(self._api_service_payload(last_action="started"))
|
return self._json_response(self._api_service_payload(last_action="started"))
|
||||||
|
|
||||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
query = self._query(request)
|
payload = _mutation_payload(request)
|
||||||
if "api_key" in query or "apiKey" in query:
|
if payload is not None:
|
||||||
raise WebUISettingsError("API service API key must be provided in the private header")
|
|
||||||
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return query
|
|
||||||
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
|
|
||||||
raise WebUISettingsError("API service settings payload is too large")
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid API service settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("API service settings payload must be a JSON object")
|
|
||||||
payload = cast(dict[str, Any], payload)
|
|
||||||
|
|
||||||
unknown = set(payload) - {"api_key"}
|
|
||||||
if unknown:
|
|
||||||
raise WebUISettingsError("API service settings payload contains an invalid key")
|
|
||||||
api_key = payload.get("api_key")
|
api_key = payload.get("api_key")
|
||||||
if api_key is not None and not isinstance(api_key, str):
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
raise WebUISettingsError("API service API key must be a string")
|
raise WebUISettingsError("API service API key must be a string")
|
||||||
|
return self._query(request)
|
||||||
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
|
|
||||||
if api_key is not None:
|
|
||||||
merged["api_key"] = [api_key]
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -589,13 +643,11 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, self._api_runtime_message(result.message))
|
return self._error_response(500, self._api_runtime_message(result.message))
|
||||||
return self._json_response(self._api_service_payload(last_action="stopped"))
|
return self._json_response(self._api_service_payload(last_action="stopped"))
|
||||||
|
|
||||||
@staticmethod
|
def _api_runtime(self) -> ApiRuntime:
|
||||||
def _api_runtime() -> ApiRuntime:
|
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
||||||
config_path = get_config_path().expanduser().resolve(strict=False)
|
|
||||||
return ApiRuntime(paths=api_runtime_paths(config_path))
|
|
||||||
|
|
||||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = self.settings.config.load()
|
||||||
status = self._api_runtime().status()
|
status = self._api_runtime().status()
|
||||||
extras = optional_dependency_groups()
|
extras = optional_dependency_groups()
|
||||||
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||||
@@ -639,7 +691,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_image_generation_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_image_generation_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -674,7 +729,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_transcription_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_transcription_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -683,7 +741,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_network_safety_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_network_safety_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -697,7 +758,10 @@ class WebUISettingsRouter:
|
|||||||
"yes",
|
"yes",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
payload = await cli_apps_payload(installed_only=installed_only)
|
payload = await cli_apps_payload(
|
||||||
|
installed_only=installed_only,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
return self._error_response(500, "failed to load CLI Apps")
|
return self._error_response(500, "failed to load CLI Apps")
|
||||||
@@ -711,7 +775,12 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
payload = await asyncio.to_thread(
|
||||||
|
cli_apps_action,
|
||||||
|
action,
|
||||||
|
self._query(request),
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -726,12 +795,29 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(nanobot_features_payload)
|
payload = await asyncio.to_thread(self._nanobot_features_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load nanobot features")
|
self.logger.exception("failed to load nanobot features")
|
||||||
return self._error_response(500, "failed to load nanobot features")
|
return self._error_response(500, "failed to load nanobot features")
|
||||||
return self._json_response(self._with_channel_runtime_status(payload))
|
return self._json_response(self._with_channel_runtime_status(payload))
|
||||||
|
|
||||||
|
def _nanobot_features_payload(self) -> dict[str, Any]:
|
||||||
|
return nanobot_features_payload(config_path=self.settings.config.path)
|
||||||
|
|
||||||
|
def _nanobot_features_action(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
allow_install: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.settings.mutate(
|
||||||
|
nanobot_features_action,
|
||||||
|
action,
|
||||||
|
query,
|
||||||
|
allow_install=allow_install,
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_settings_nanobot_features_action(
|
async def _handle_settings_nanobot_features_action(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -742,7 +828,7 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
action,
|
action,
|
||||||
self._query(request),
|
self._query(request),
|
||||||
allow_install=action != "enable"
|
allow_install=action != "enable"
|
||||||
@@ -850,7 +936,7 @@ class WebUISettingsRouter:
|
|||||||
saved = await asyncio.to_thread(
|
saved = await asyncio.to_thread(
|
||||||
self._save_channel_config_values,
|
self._save_channel_config_values,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values_header(request),
|
self._parse_channel_values(request),
|
||||||
instance_id,
|
instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -865,7 +951,7 @@ class WebUISettingsRouter:
|
|||||||
"saved_keys": saved,
|
"saved_keys": saved,
|
||||||
}
|
}
|
||||||
if not enable:
|
if not enable:
|
||||||
features = await asyncio.to_thread(nanobot_features_payload)
|
features = await asyncio.to_thread(self._nanobot_features_payload)
|
||||||
features = self._with_channel_runtime_status(features)
|
features = self._with_channel_runtime_status(features)
|
||||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
@@ -876,7 +962,7 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
feature_query,
|
feature_query,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
@@ -906,7 +992,7 @@ class WebUISettingsRouter:
|
|||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
validate_channel_config,
|
validate_channel_config,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values_header(request),
|
self._parse_channel_values(request),
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -916,19 +1002,14 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, "failed to validate channel settings")
|
return self._error_response(500, "failed to validate channel settings")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
|
|
||||||
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
|
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
||||||
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
|
payload = _mutation_payload(request)
|
||||||
if not raw:
|
if payload is None or "values" not in payload:
|
||||||
return {}
|
return {}
|
||||||
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
|
values = payload.get("values")
|
||||||
raise WebUISettingsError("channel settings payload is too large")
|
if not isinstance(values, dict):
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise WebUISettingsError("invalid channel settings payload") from exc
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise WebUISettingsError("channel settings payload must be a JSON object")
|
raise WebUISettingsError("channel settings payload must be a JSON object")
|
||||||
return cast(dict[str, Any], payload)
|
return cast(dict[str, Any], values)
|
||||||
|
|
||||||
def _save_channel_config_values(
|
def _save_channel_config_values(
|
||||||
self,
|
self,
|
||||||
@@ -949,7 +1030,7 @@ class WebUISettingsRouter:
|
|||||||
if not raw_values:
|
if not raw_values:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
config = load_config()
|
def update(config: Config) -> list[str]:
|
||||||
section = getattr(config.channels, name, None)
|
section = getattr(config.channels, name, None)
|
||||||
channel_config = channel_instance_config(
|
channel_config = channel_instance_config(
|
||||||
plugin,
|
plugin,
|
||||||
@@ -961,7 +1042,9 @@ class WebUISettingsRouter:
|
|||||||
prefix = f"channels.{name}."
|
prefix = f"channels.{name}."
|
||||||
for raw_key, raw_value in raw_values.items():
|
for raw_key, raw_value in raw_values.items():
|
||||||
if not raw_key:
|
if not raw_key:
|
||||||
raise WebUISettingsError("channel settings payload contains an invalid key")
|
raise WebUISettingsError(
|
||||||
|
"channel settings payload contains an invalid key"
|
||||||
|
)
|
||||||
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
||||||
value_type = field_types.get(field)
|
value_type = field_types.get(field)
|
||||||
if value_type is None:
|
if value_type is None:
|
||||||
@@ -985,9 +1068,10 @@ class WebUISettingsRouter:
|
|||||||
status=400,
|
status=400,
|
||||||
) from exc
|
) from exc
|
||||||
setattr(config.channels, name, updated_section)
|
setattr(config.channels, name, updated_section)
|
||||||
save_config(config)
|
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
return self.settings.config.update(update)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_channel_value(
|
def _coerce_channel_value(
|
||||||
raw_key: str,
|
raw_key: str,
|
||||||
@@ -1109,14 +1193,14 @@ class WebUISettingsRouter:
|
|||||||
target["instance_id"] = [str(payload["instance_id"])]
|
target["instance_id"] = [str(payload["instance_id"])]
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
target,
|
target,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
except OptionalFeatureError as exc:
|
except OptionalFeatureError as exc:
|
||||||
features = self._feature_runtime_fallback(
|
features = self._feature_runtime_fallback(
|
||||||
nanobot_features_payload(),
|
self._nanobot_features_payload(),
|
||||||
message=(
|
message=(
|
||||||
f"{channel_name} connected, but enabling channel support failed: "
|
f"{channel_name} connected, but enabling channel support failed: "
|
||||||
f"{exc.message}"
|
f"{exc.message}"
|
||||||
@@ -1137,23 +1221,29 @@ class WebUISettingsRouter:
|
|||||||
if _is_local_browser_request(connection, request.headers):
|
if _is_local_browser_request(connection, request.headers):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
return bool(load_config().tools.webui_allow_remote_package_install)
|
return bool(
|
||||||
|
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load remote package install policy")
|
self.logger.exception("failed to load remote package install policy")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _handle_settings_mcp_presets(
|
async def _handle_settings_mcp_presets(
|
||||||
self,
|
self,
|
||||||
|
connection: Any,
|
||||||
request: WsRequest,
|
request: WsRequest,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
|
query = self._parse_mcp_settings_query(request)
|
||||||
payload = await mcp_presets_settings_action(
|
payload = await mcp_presets_settings_action(
|
||||||
action,
|
action,
|
||||||
self._parse_mcp_settings_query(request),
|
query,
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
|
config=self.settings.config,
|
||||||
|
remote=not _is_local_browser_request(connection, request.headers),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = getattr(e, "status", 500)
|
status = getattr(e, "status", 500)
|
||||||
@@ -1165,6 +1255,147 @@ class WebUISettingsRouter:
|
|||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
|
|
||||||
|
async def _handle_mcp_oauth_start(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
if self._mcp_oauth_redirect_uri is None:
|
||||||
|
return self._error_response(500, "MCP OAuth callback is not configured")
|
||||||
|
query = self._parse_mcp_settings_query(request)
|
||||||
|
try:
|
||||||
|
name, cfg = await asyncio.to_thread(
|
||||||
|
self.settings.mutate,
|
||||||
|
ensure_mcp_oauth_server,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
redirect_uri = self._mcp_oauth_redirect_uri(request)
|
||||||
|
reset = (_query_first(query, "reset") or "").lower() in {"1", "true", "yes"}
|
||||||
|
payload = await self._mcp_oauth.start(
|
||||||
|
name,
|
||||||
|
cfg,
|
||||||
|
redirect_uri,
|
||||||
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
|
reset_credentials=reset,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._mcp_oauth_error_response(exc, action="start")
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
async def _handle_mcp_oauth_status(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
flow_id = (_query_first(self._query(request), "flow_id") or "").strip()
|
||||||
|
if not flow_id:
|
||||||
|
return self._error_response(400, "missing MCP OAuth flow ID")
|
||||||
|
try:
|
||||||
|
payload = await self._mcp_oauth.status(flow_id)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._mcp_oauth_error_response(exc, action="status")
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
def _handle_mcp_oauth_complete(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
query = self._query(request)
|
||||||
|
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||||
|
if not flow_id:
|
||||||
|
return self._error_response(400, "missing MCP OAuth flow ID")
|
||||||
|
callback_url = (_query_first(query, "callback_url") or "").strip()
|
||||||
|
if not callback_url:
|
||||||
|
return self._error_response(400, "Paste the complete callback URL to continue")
|
||||||
|
if len(callback_url.encode("utf-8")) > _MCP_OAUTH_CALLBACK_URL_MAX_BYTES:
|
||||||
|
return self._error_response(400, "The MCP OAuth callback URL is too long")
|
||||||
|
try:
|
||||||
|
payload = self._mcp_oauth.submit_callback_url(
|
||||||
|
flow_id=flow_id,
|
||||||
|
callback_url=callback_url,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._mcp_oauth_error_response(exc, action="complete")
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
async def _handle_mcp_oauth_cancel(self, request: WsRequest) -> Response:
|
||||||
|
if not self._authorized(request):
|
||||||
|
return self._unauthorized()
|
||||||
|
flow_id = (_query_first(self._query(request), "flow_id") or "").strip()
|
||||||
|
if not flow_id:
|
||||||
|
return self._error_response(400, "missing MCP OAuth flow ID")
|
||||||
|
try:
|
||||||
|
payload = await self._mcp_oauth.cancel(flow_id)
|
||||||
|
except Exception as exc:
|
||||||
|
return self._mcp_oauth_error_response(exc, action="cancel")
|
||||||
|
return self._json_response(payload)
|
||||||
|
|
||||||
|
def _handle_mcp_oauth_callback(self, request: WsRequest) -> Response:
|
||||||
|
query = self._query(request)
|
||||||
|
state = (_query_first(query, "state") or "").strip()
|
||||||
|
if not state:
|
||||||
|
return self._mcp_oauth_callback_page(
|
||||||
|
ok=False,
|
||||||
|
message="This authorization request is missing its security state.",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
name = self._mcp_oauth.submit_callback(
|
||||||
|
state=state,
|
||||||
|
code=_query_first(query, "code"),
|
||||||
|
error=_query_first(query, "error"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status = int(getattr(exc, "status", 400))
|
||||||
|
message = str(getattr(exc, "message", "Could not complete MCP authorization"))
|
||||||
|
return self._mcp_oauth_callback_page(ok=False, message=message, status=status)
|
||||||
|
return self._mcp_oauth_callback_page(
|
||||||
|
ok=True,
|
||||||
|
message=f"Authorization received for {name}. Return to nanobot to finish connecting.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mcp_oauth_error_response(self, exc: Exception, *, action: str) -> Response:
|
||||||
|
raw_status = getattr(exc, "status", 500)
|
||||||
|
status = raw_status if isinstance(raw_status, int) and 400 <= raw_status <= 599 else 500
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception("MCP OAuth '{}' failed", action)
|
||||||
|
message = f"MCP OAuth {action} failed"
|
||||||
|
else:
|
||||||
|
raw_message = getattr(exc, "message", None)
|
||||||
|
message = raw_message if isinstance(raw_message, str) else "MCP OAuth request failed"
|
||||||
|
return self._error_response(status, message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mcp_oauth_callback_page(
|
||||||
|
*,
|
||||||
|
ok: bool,
|
||||||
|
message: str,
|
||||||
|
status: int = 200,
|
||||||
|
) -> Response:
|
||||||
|
title = "Authorization received" if ok else "Connection failed"
|
||||||
|
safe_title = html.escape(title)
|
||||||
|
safe_message = html.escape(message)
|
||||||
|
close_script = "<script>setTimeout(() => window.close(), 700)</script>" if ok else ""
|
||||||
|
body = (
|
||||||
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||||
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
|
f"<title>{safe_title}</title><style>"
|
||||||
|
"body{font:16px system-ui;margin:0;min-height:100vh;display:grid;place-items:center;"
|
||||||
|
"background:#f7f7f6;color:#171717}.card{max-width:34rem;margin:2rem;padding:2rem;"
|
||||||
|
"border:1px solid #ddd;border-radius:16px;background:white}h1{font-size:1.35rem}"
|
||||||
|
"p{line-height:1.55;color:#555}</style></head><body><main class='card'>"
|
||||||
|
f"<h1>{safe_title}</h1><p>{safe_message}</p></main>{close_script}</body></html>"
|
||||||
|
).encode("utf-8")
|
||||||
|
return _http_response(
|
||||||
|
body,
|
||||||
|
status=status,
|
||||||
|
content_type="text/html; charset=utf-8",
|
||||||
|
extra_headers=[
|
||||||
|
("Cache-Control", "no-store"),
|
||||||
|
("Referrer-Policy", "no-referrer"),
|
||||||
|
(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'none'; base-uri 'none'; form-action 'none'; "
|
||||||
|
"frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
|
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Gateway-owned state for the WebUI settings surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||||
|
|
||||||
|
|
||||||
|
class WebUISettingsConfig:
|
||||||
|
"""Instance-scoped config access with serialized read-modify-write operations."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: Path) -> None:
|
||||||
|
self.path = config_path.expanduser().resolve(strict=False)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def load(self) -> Config:
|
||||||
|
"""Load this gateway's config without consulting the process-global path."""
|
||||||
|
with self._lock:
|
||||||
|
return load_config(self.path)
|
||||||
|
|
||||||
|
def update(self, mutation: Callable[[Config], _T]) -> _T:
|
||||||
|
"""Apply and atomically persist one in-process read-modify-write operation."""
|
||||||
|
with self._lock:
|
||||||
|
config = load_config(self.path)
|
||||||
|
result = mutation(config)
|
||||||
|
save_config(config, self.path)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
|
||||||
|
"""Run a path-aware read-modify-write operation under the instance lock."""
|
||||||
|
with self._lock:
|
||||||
|
return operation(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class WebUIOAuthFlowRegistry:
|
||||||
|
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
|
||||||
|
|
||||||
|
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
|
||||||
|
if max_flows < 1:
|
||||||
|
raise ValueError("max_flows must be at least one")
|
||||||
|
self._max_flows = max_flows
|
||||||
|
self._flows: dict[str, tuple[str, Any]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
|
||||||
|
discarded: list[Any] = []
|
||||||
|
with self._lock:
|
||||||
|
for existing_id, (_provider_name, existing) in list(self._flows.items()):
|
||||||
|
if existing.expired:
|
||||||
|
discarded.append(self._flows.pop(existing_id)[1])
|
||||||
|
while len(self._flows) >= self._max_flows:
|
||||||
|
oldest_id = next(iter(self._flows))
|
||||||
|
discarded.append(self._flows.pop(oldest_id)[1])
|
||||||
|
self._flows[flow_id] = (provider_name, flow)
|
||||||
|
for existing in discarded:
|
||||||
|
existing.cancel()
|
||||||
|
|
||||||
|
def get(self, provider_name: str, flow_id: str) -> Any | None:
|
||||||
|
with self._lock:
|
||||||
|
registered = self._flows.get(flow_id)
|
||||||
|
if registered is None or registered[0] != provider_name:
|
||||||
|
return None
|
||||||
|
flow = registered[1]
|
||||||
|
if not flow.expired:
|
||||||
|
return flow
|
||||||
|
self._flows.pop(flow_id, None)
|
||||||
|
flow.cancel()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def remove(
|
||||||
|
self,
|
||||||
|
provider_name: str,
|
||||||
|
flow_id: str,
|
||||||
|
flow: Any,
|
||||||
|
*,
|
||||||
|
cancel: bool = True,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
registered = self._flows.get(flow_id)
|
||||||
|
if (
|
||||||
|
registered is not None
|
||||||
|
and registered[0] == provider_name
|
||||||
|
and registered[1] is flow
|
||||||
|
):
|
||||||
|
self._flows.pop(flow_id)
|
||||||
|
if cancel:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
def clear(self, provider_name: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
flow_ids = [
|
||||||
|
flow_id
|
||||||
|
for flow_id, (registered_provider, _flow) in self._flows.items()
|
||||||
|
if registered_provider == provider_name
|
||||||
|
]
|
||||||
|
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||||
|
for flow in flows:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WebUISettingsServices:
|
||||||
|
"""Settings dependencies composed once for a gateway instance."""
|
||||||
|
|
||||||
|
config: WebUISettingsConfig
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, config_path: Path) -> WebUISettingsServices:
|
||||||
|
return cls(
|
||||||
|
config=WebUISettingsConfig(config_path),
|
||||||
|
oauth_flows=WebUIOAuthFlowRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(
|
||||||
|
self,
|
||||||
|
operation: Callable[..., _T],
|
||||||
|
/,
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> _T:
|
||||||
|
"""Run a settings read against this gateway's explicit config path."""
|
||||||
|
return operation(*args, config_path=self.config.path, **kwargs)
|
||||||
|
|
||||||
|
def mutate(
|
||||||
|
self,
|
||||||
|
operation: Callable[..., _T],
|
||||||
|
/,
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> _T:
|
||||||
|
"""Serialize a path-aware settings read-modify-write operation."""
|
||||||
|
return self.config.run_serialized(
|
||||||
|
lambda config_path: operation(
|
||||||
|
*args,
|
||||||
|
config_path=config_path,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
|
|
||||||
@@ -42,7 +43,13 @@ def check_for_update() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
_cache = (now, latest)
|
_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 None
|
||||||
return {
|
return {
|
||||||
"currentVersion": __version__,
|
"currentVersion": __version__,
|
||||||
|
|||||||
+188
-25
@@ -17,9 +17,10 @@ import time
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from urllib.parse import unquote
|
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from websockets.datastructures import Headers
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
@@ -118,7 +119,64 @@ from nanobot.webui.transcript import build_webui_thread_response
|
|||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
|
|
||||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||||
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
||||||
|
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
||||||
|
|
||||||
|
_WEBUI_MUTATION_PATHS = {
|
||||||
|
"automation.enable": "/api/webui/automations/enable",
|
||||||
|
"automation.disable": "/api/webui/automations/disable",
|
||||||
|
"automation.delete": "/api/webui/automations/delete",
|
||||||
|
"automation.run": "/api/webui/automations/run",
|
||||||
|
"automation.update": "/api/webui/automations/update",
|
||||||
|
"skill.install": "/api/webui/skills/install",
|
||||||
|
"skill.update": "/api/webui/skills/update",
|
||||||
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
|
"settings.agent.update": "/api/settings/update",
|
||||||
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
|
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
|
||||||
|
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
|
||||||
|
"settings.model_call_order.update": "/api/settings/model-call-order/update",
|
||||||
|
"settings.provider.update": "/api/settings/provider/update",
|
||||||
|
"settings.provider.create": "/api/settings/provider/create",
|
||||||
|
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
|
||||||
|
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
|
||||||
|
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
|
||||||
|
"settings.web_search.update": "/api/settings/web-search/update",
|
||||||
|
"settings.api_service.start": "/api/settings/api-service/start",
|
||||||
|
"settings.api_service.stop": "/api/settings/api-service/stop",
|
||||||
|
"settings.image_generation.update": "/api/settings/image-generation/update",
|
||||||
|
"settings.transcription.update": "/api/settings/transcription/update",
|
||||||
|
"settings.network_safety.update": "/api/settings/network-safety/update",
|
||||||
|
"settings.cli_app.install": "/api/settings/cli-apps/install",
|
||||||
|
"settings.cli_app.update": "/api/settings/cli-apps/update",
|
||||||
|
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
|
||||||
|
"settings.cli_app.test": "/api/settings/cli-apps/test",
|
||||||
|
"settings.feature.enable": "/api/settings/nanobot-features/enable",
|
||||||
|
"settings.feature.disable": "/api/settings/nanobot-features/disable",
|
||||||
|
"settings.channel.validate": "/api/settings/channels/validate",
|
||||||
|
"settings.channel.configure": "/api/settings/channels/configure",
|
||||||
|
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||||
|
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||||
|
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||||
|
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||||
|
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||||
|
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||||
|
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||||
|
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
||||||
|
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
||||||
|
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
||||||
|
"settings.mcp.oauth_start": "/api/settings/mcp-oauth/start",
|
||||||
|
"settings.mcp.oauth_complete": "/api/settings/mcp-oauth/complete",
|
||||||
|
"settings.mcp.oauth_cancel": "/api/settings/mcp-oauth/cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
||||||
|
"settings.channel.connect.start": "start",
|
||||||
|
"settings.channel.connect.poll": "poll",
|
||||||
|
"settings.channel.connect.cancel": "cancel",
|
||||||
|
}
|
||||||
|
|
||||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
||||||
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||||
@@ -150,6 +208,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
key = unquote(raw_key)
|
key = unquote(raw_key)
|
||||||
@@ -159,6 +218,33 @@ def _decode_api_key(raw_key: str) -> str | None:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
||||||
|
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
return cast(dict[str, Any], payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_query(request: WsRequest) -> dict[str, list[str]]:
|
||||||
|
payload = _mutation_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return _parse_query(request.path)
|
||||||
|
query: dict[str, list[str]] = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool):
|
||||||
|
text = "true" if value else "false"
|
||||||
|
elif value is None:
|
||||||
|
text = ""
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
else:
|
||||||
|
text = str(value)
|
||||||
|
query[key] = [text]
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
def _default_model_name_from_config() -> str | None:
|
def _default_model_name_from_config() -> str | None:
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
@@ -211,6 +297,7 @@ class GatewayHTTPHandler:
|
|||||||
media: WebUIMediaGateway,
|
media: WebUIMediaGateway,
|
||||||
ingress: WebUIIngressPolicy,
|
ingress: WebUIIngressPolicy,
|
||||||
workspaces: WebUIWorkspaceController,
|
workspaces: WebUIWorkspaceController,
|
||||||
|
settings: WebUISettingsServices,
|
||||||
skills_workspace_path: Path,
|
skills_workspace_path: Path,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -231,6 +318,7 @@ class GatewayHTTPHandler:
|
|||||||
self.media = media
|
self.media = media
|
||||||
self.ingress = ingress
|
self.ingress = ingress
|
||||||
self.workspaces = workspaces
|
self.workspaces = workspaces
|
||||||
|
self.settings = settings
|
||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills: set[str] = (
|
self.disabled_skills: set[str] = (
|
||||||
disabled_skills if disabled_skills is not None else set()
|
disabled_skills if disabled_skills is not None else set()
|
||||||
@@ -249,6 +337,7 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||||
self.settings_routes = WebUISettingsRouter(
|
self.settings_routes = WebUISettingsRouter(
|
||||||
|
settings=settings,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
logger=self._log,
|
logger=self._log,
|
||||||
check_api_token=self.check_api_token,
|
check_api_token=self.check_api_token,
|
||||||
@@ -259,6 +348,7 @@ class GatewayHTTPHandler:
|
|||||||
runtime_capabilities=self._capabilities,
|
runtime_capabilities=self._capabilities,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
|
mcp_oauth_redirect_uri=self._mcp_oauth_redirect_uri,
|
||||||
)
|
)
|
||||||
|
|
||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
@@ -285,11 +375,86 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if self._is_webui_mutation_path(got):
|
||||||
|
return _http_error(
|
||||||
|
405,
|
||||||
|
"WebUI mutations require an authenticated WebSocket",
|
||||||
|
)
|
||||||
response = await self._dispatch_resolved(connection, request, got)
|
response = await self._dispatch_resolved(connection, request, got)
|
||||||
return response
|
return response
|
||||||
finally:
|
finally:
|
||||||
self._log_slow_http(got, response, started)
|
self._log_slow_http(got, response, started)
|
||||||
|
|
||||||
|
async def dispatch_webui_mutation(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> Response:
|
||||||
|
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
|
||||||
|
path = self._webui_mutation_path(action, payload)
|
||||||
|
if isinstance(path, Response):
|
||||||
|
return path
|
||||||
|
|
||||||
|
source_request = getattr(connection, "request", None)
|
||||||
|
source_headers = getattr(source_request, "headers", None)
|
||||||
|
if source_headers is None:
|
||||||
|
headers = Headers()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
headers = Headers(source_headers.raw_items())
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
try:
|
||||||
|
headers = Headers(source_headers)
|
||||||
|
except TypeError:
|
||||||
|
headers = Headers()
|
||||||
|
request = WsRequest(path, headers)
|
||||||
|
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
|
||||||
|
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
|
||||||
|
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
|
||||||
|
response = await self._dispatch_resolved(connection, request, path)
|
||||||
|
if isinstance(response, Response):
|
||||||
|
return response
|
||||||
|
return _http_error(404, "WebUI mutation action not found")
|
||||||
|
|
||||||
|
def _is_webui_mutation_path(self, path: str) -> bool:
|
||||||
|
if self.settings_routes.is_mutation_path(path):
|
||||||
|
return True
|
||||||
|
if re.match(r"^/api/sessions/[^/]+/delete$", path):
|
||||||
|
return True
|
||||||
|
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
||||||
|
return True
|
||||||
|
return path in {
|
||||||
|
"/api/webui/skills/install",
|
||||||
|
"/api/webui/skills/update",
|
||||||
|
"/api/webui/skills/delete",
|
||||||
|
"/api/webui/sidebar-state/update",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _webui_mutation_path(
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> str | Response:
|
||||||
|
path = _WEBUI_MUTATION_PATHS.get(action)
|
||||||
|
if path is not None:
|
||||||
|
return path
|
||||||
|
if action == "session.delete":
|
||||||
|
key = payload.get("key")
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
return _http_error(400, "missing session key")
|
||||||
|
return f"/api/sessions/{quote(key, safe='')}/delete"
|
||||||
|
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
|
||||||
|
if connect_action is not None:
|
||||||
|
channel = payload.get("channel")
|
||||||
|
if not isinstance(channel, str) or re.fullmatch(
|
||||||
|
r"[A-Za-z0-9_-]{1,64}",
|
||||||
|
channel,
|
||||||
|
) is None:
|
||||||
|
return _http_error(400, "invalid channel name")
|
||||||
|
return f"/api/settings/channels/{channel}/connect/{connect_action}"
|
||||||
|
return _http_error(404, "unknown WebUI mutation action")
|
||||||
|
|
||||||
async def _dispatch_resolved(
|
async def _dispatch_resolved(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -457,6 +622,14 @@ class GatewayHTTPHandler:
|
|||||||
expected_path = _normalize_config_path(self.config.path)
|
expected_path = _normalize_config_path(self.config.path)
|
||||||
return f"{scheme}://{host}{expected_path}"
|
return f"{scheme}://{host}{expected_path}"
|
||||||
|
|
||||||
|
def _mcp_oauth_redirect_uri(self, request: WsRequest) -> str:
|
||||||
|
"""Derive the browser callback from the same public origin as WebSocket bootstrap."""
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH
|
||||||
|
|
||||||
|
public_ws_url = urlsplit(self._bootstrap_ws_url(request))
|
||||||
|
scheme = "https" if public_ws_url.scheme == "wss" else "http"
|
||||||
|
return urlunsplit((scheme, public_ws_url.netloc, MCP_OAUTH_CALLBACK_PATH, "", ""))
|
||||||
|
|
||||||
# -- Session routes -----------------------------------------------------
|
# -- Session routes -----------------------------------------------------
|
||||||
|
|
||||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||||
@@ -646,7 +819,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
if not _is_websocket_channel_session_key(decoded_key):
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
query = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||||
automation_jobs = session_automation_jobs(
|
automation_jobs = session_automation_jobs(
|
||||||
self.cron_service,
|
self.cron_service,
|
||||||
@@ -742,7 +915,7 @@ class GatewayHTTPHandler:
|
|||||||
if self.cron_service is None and self.local_trigger_store is None:
|
if self.cron_service is None and self.local_trigger_store is None:
|
||||||
return _http_error(503, "automation service unavailable")
|
return _http_error(503, "automation service unavailable")
|
||||||
|
|
||||||
query = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return _http_error(400, "missing automation id")
|
return _http_error(400, "missing automation id")
|
||||||
@@ -974,7 +1147,7 @@ class GatewayHTTPHandler:
|
|||||||
if self._skill_install_lock.locked():
|
if self._skill_install_lock.locked():
|
||||||
return _http_error(409, "another skill installation is already in progress")
|
return _http_error(409, "another skill installation is already in progress")
|
||||||
|
|
||||||
query = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
provider = _query_first(query, "provider") or "skills_sh"
|
provider = _query_first(query, "provider") or "skills_sh"
|
||||||
source = _query_first(query, "source") or ""
|
source = _query_first(query, "source") or ""
|
||||||
skill_id = _query_first(query, "skill") or ""
|
skill_id = _query_first(query, "skill") or ""
|
||||||
@@ -1015,7 +1188,7 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _parse_query(request.path)
|
query = _request_query(request)
|
||||||
name = _query_first(query, "name") or ""
|
name = _query_first(query, "name") or ""
|
||||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||||
if raw_enabled not in {"true", "false"}:
|
if raw_enabled not in {"true", "false"}:
|
||||||
@@ -1047,7 +1220,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if not _is_local_browser_request(connection, request.headers):
|
if not _is_local_browser_request(connection, request.headers):
|
||||||
return _http_error(403, "remote skill deletion is disabled")
|
return _http_error(403, "remote skill deletion is disabled")
|
||||||
name = _query_first(_parse_query(request.path), "name") or ""
|
name = _query_first(_request_query(request), "name") or ""
|
||||||
try:
|
try:
|
||||||
action = delete_webui_skill(
|
action = delete_webui_skill(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@@ -1094,18 +1267,14 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _parse_query(request.path)
|
payload = _mutation_payload(request)
|
||||||
raw_state = _query_first(query, "state")
|
state_value = payload.get("state") if payload is not None else None
|
||||||
if raw_state is None:
|
if state_value is None:
|
||||||
return _http_error(400, "missing state")
|
return _http_error(400, "missing state")
|
||||||
try:
|
if not isinstance(state_value, dict):
|
||||||
decoded = json.loads(raw_state)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return _http_error(400, "state must be JSON")
|
|
||||||
if not isinstance(decoded, dict):
|
|
||||||
return _http_error(400, "state must be an object")
|
return _http_error(400, "state must be an object")
|
||||||
try:
|
try:
|
||||||
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return _http_error(400, str(e))
|
return _http_error(400, str(e))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1174,16 +1343,10 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||||
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
payload = _mutation_payload(request)
|
||||||
if not raw:
|
if payload is None or "values" not in payload:
|
||||||
return {}
|
return {}
|
||||||
try:
|
values = payload.get("values")
|
||||||
values = json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
values = json.loads(unquote(raw))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies = [
|
|||||||
"readability-lxml>=0.8.4,<1.0.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
|
"qrcode[pil]>=8.0",
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Barrier
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import plugins as agent_plugins
|
||||||
|
from nanobot.agent.plugins import (
|
||||||
|
AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
AGENT_PLUGIN_SCHEMA,
|
||||||
|
agent_plugin_mcp_servers,
|
||||||
|
discover_agent_plugin_states,
|
||||||
|
enabled_agent_plugin_skills,
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
)
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: tmp_path / "config" / "config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
|
||||||
|
skill = root / "skills" / name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
(skill / "SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(name: str, **fields: object) -> dict[str, object]:
|
||||||
|
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(
|
||||||
|
workspace: Path,
|
||||||
|
directory: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
manifest: dict[str, object] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
root = workspace / "plugins" / directory
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
payload = manifest or _manifest(name or directory)
|
||||||
|
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _write_mcp(root: Path, servers: dict[str, object], **fields: object) -> None:
|
||||||
|
payload = {"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers, **fields}
|
||||||
|
(root / "mcp.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_setup_plugin(workspace: Path) -> tuple[Path, Path]:
|
||||||
|
plugin = _write_plugin(
|
||||||
|
workspace,
|
||||||
|
"desktop",
|
||||||
|
manifest=_manifest(
|
||||||
|
"desktop",
|
||||||
|
version="1.2.3",
|
||||||
|
extensions={"dev.nanobot": {"installCommand": ["./bin/install"]}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
executable = plugin / "bin" / "install"
|
||||||
|
executable.parent.mkdir()
|
||||||
|
executable.write_text("setup", encoding="utf-8")
|
||||||
|
return plugin, executable
|
||||||
|
|
||||||
|
|
||||||
|
def _loaded_plugin_skills(workspace: Path) -> list[str]:
|
||||||
|
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||||
|
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||||
|
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||||
|
|
||||||
|
assert loader.list_skills() == [
|
||||||
|
{
|
||||||
|
"name": "release-notes",
|
||||||
|
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
|
||||||
|
"source": "plugin",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
|
||||||
|
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
|
||||||
|
assert "### Agent Plugin skills" in loader.build_skills_summary()
|
||||||
|
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||||
|
assert loader.list_skills() == []
|
||||||
|
|
||||||
|
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||||
|
_write_skill(plugin, "release-notes")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||||
|
|
||||||
|
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
|
||||||
|
|
||||||
|
shutil.rmtree(plugin)
|
||||||
|
|
||||||
|
assert loader.list_skills() == []
|
||||||
|
assert loader.build_skills_summary() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||||
|
_write_skill(plugin, "direct")
|
||||||
|
nested = plugin / "skills" / "group" / "nested"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
(nested / "SKILL.md").write_text(
|
||||||
|
"---\nname: nested\ndescription: Nested skill.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "acme-tools", True)
|
||||||
|
|
||||||
|
assert _loaded_plugin_skills(tmp_path) == ["direct"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"manifest",
|
||||||
|
[
|
||||||
|
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
|
||||||
|
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_agent_plugin_manifest_is_skipped(
|
||||||
|
tmp_path: Path,
|
||||||
|
manifest: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
|
||||||
|
_write_skill(plugin, "example")
|
||||||
|
|
||||||
|
assert discover_agent_plugin_states(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"demo",
|
||||||
|
manifest=_manifest(
|
||||||
|
"demo",
|
||||||
|
futureField=True,
|
||||||
|
author=None,
|
||||||
|
keywords=None,
|
||||||
|
extensions="invalid but non-fatal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_write_skill(plugin, "example")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
|
||||||
|
assert _loaded_plugin_skills(tmp_path) == ["example"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_discovers_contained_raster_logo(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"demo",
|
||||||
|
manifest=_manifest(
|
||||||
|
"demo",
|
||||||
|
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assets = plugin / "assets"
|
||||||
|
assets.mkdir()
|
||||||
|
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||||
|
assert discover_agent_plugin_states(tmp_path)[0].plugin.logo == assets / "icon.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None:
|
||||||
|
outside = tmp_path / "outside.png"
|
||||||
|
outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"demo",
|
||||||
|
manifest=_manifest(
|
||||||
|
"demo",
|
||||||
|
extensions={"dev.nanobot": {"logo": "./assets/icon.png"}},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assets = plugin / "assets"
|
||||||
|
assets.mkdir()
|
||||||
|
try:
|
||||||
|
(assets / "icon.png").symlink_to(outside)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"file symlink unavailable: {exc}")
|
||||||
|
|
||||||
|
assert discover_agent_plugin_states(tmp_path)[0].plugin.logo is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("skill_name", "frontmatter"),
|
||||||
|
[
|
||||||
|
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||||
|
("missing-description", "name: missing-description"),
|
||||||
|
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_agent_skill_is_skipped(
|
||||||
|
tmp_path: Path,
|
||||||
|
skill_name: str,
|
||||||
|
frontmatter: str,
|
||||||
|
) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
skill = plugin / "skills" / skill_name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
|
||||||
|
assert _loaded_plugin_skills(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
_write_skill(plugin, "shared", description="Plugin version.")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
workspace_skill = tmp_path / "skills" / "shared"
|
||||||
|
workspace_skill.mkdir(parents=True)
|
||||||
|
(workspace_skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: shared\ndescription: Workspace version.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||||
|
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
|
||||||
|
assert "Workspace version" in (loader.load_skill("shared") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_plugin_skill_cannot_shadow_or_inject_builtin_skill(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
skill = _write_skill(plugin, "shared", description="Plugin version.")
|
||||||
|
(skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: shared\ndescription: Plugin version.\nalways: true\n---\n\nPlugin body.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
builtin = tmp_path / "builtin"
|
||||||
|
builtin_skill = builtin / "shared"
|
||||||
|
builtin_skill.mkdir(parents=True)
|
||||||
|
(builtin_skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: shared\ndescription: Built-in version.\n---\n\nBuilt-in body.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=builtin)
|
||||||
|
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||||
|
assert "Built-in version" in (loader.load_skill("shared") or "")
|
||||||
|
assert loader.get_always_skills() == []
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
|
||||||
|
assert "Plugin body" in (loader.load_skill("shared") or "")
|
||||||
|
assert loader.get_always_skills() == ["shared"]
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", False)
|
||||||
|
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
|
||||||
|
assert "Built-in version" in (loader.load_skill("shared") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
_write_skill(outside, "escaped")
|
||||||
|
skills_root = plugin / "skills"
|
||||||
|
skills_root.mkdir()
|
||||||
|
try:
|
||||||
|
(skills_root / "escaped").symlink_to(
|
||||||
|
outside / "skills" / "escaped",
|
||||||
|
target_is_directory=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
|
||||||
|
assert _loaded_plugin_skills(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "desktop")
|
||||||
|
executable = plugin / "bin" / "server"
|
||||||
|
executable.parent.mkdir()
|
||||||
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
_write_mcp(
|
||||||
|
plugin,
|
||||||
|
{
|
||||||
|
"desktop": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "./bin/server",
|
||||||
|
"args": ["--data", "${PLUGIN_DATA}/state"],
|
||||||
|
"cwd": "${PLUGIN_ROOT}",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
futureField=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
|
||||||
|
servers = agent_plugin_mcp_servers(tmp_path)
|
||||||
|
server = servers["desktop"]
|
||||||
|
assert server.command == str(executable)
|
||||||
|
assert server.cwd == str(plugin)
|
||||||
|
assert server.env["PLUGIN_ROOT"] == str(plugin)
|
||||||
|
assert server.args[0] == "--data"
|
||||||
|
assert server.args[1].endswith("/state")
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_setup_command_runs_once_per_version(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit")
|
||||||
|
plugin, executable = _write_setup_plugin(tmp_path)
|
||||||
|
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
calls.append((command, cast(dict[str, str], kwargs["env"])))
|
||||||
|
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0][0] == (str(executable),)
|
||||||
|
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
|
||||||
|
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
|
||||||
|
assert discover_agent_plugin_states(tmp_path)[0].setup_required is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_plugin_enable_runs_setup_once(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_, executable = _write_setup_plugin(tmp_path)
|
||||||
|
calls: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], **_: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
calls.append(command)
|
||||||
|
time.sleep(0.1)
|
||||||
|
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||||
|
ready = Barrier(2)
|
||||||
|
|
||||||
|
def enable() -> None:
|
||||||
|
ready.wait()
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
futures = [executor.submit(enable) for _ in range(2)]
|
||||||
|
for future in futures:
|
||||||
|
future.result()
|
||||||
|
|
||||||
|
assert calls == [(str(executable),)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "network")
|
||||||
|
executable = plugin / "bin" / "server"
|
||||||
|
executable.parent.mkdir()
|
||||||
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
_write_mcp(
|
||||||
|
plugin,
|
||||||
|
{
|
||||||
|
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
|
||||||
|
"local": {"type": "stdio", "command": "./bin/server"},
|
||||||
|
"escape": {"type": "stdio", "command": "../outside"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "network", True)
|
||||||
|
|
||||||
|
assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config = tmp_path / "config"
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
config.mkdir()
|
||||||
|
outside.mkdir()
|
||||||
|
try:
|
||||||
|
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: config / "config.json",
|
||||||
|
)
|
||||||
|
_write_plugin(tmp_path, "desktop")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="escapes its parent"):
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
@@ -406,6 +406,52 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
|||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reload_mcp_servers_skips_oauth_server_waiting_for_authorization(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
config = load_config()
|
||||||
|
notion = MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://mcp.notion.test/mcp",
|
||||||
|
)
|
||||||
|
linear = MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://mcp.linear.test/mcp",
|
||||||
|
)
|
||||||
|
config.tools.mcp_servers.update({"notion": notion, "linear": linear})
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
attempted: list[str] = []
|
||||||
|
|
||||||
|
async def _fake_connect(servers, _registry):
|
||||||
|
attempted.extend(servers)
|
||||||
|
stack = AsyncExitStack()
|
||||||
|
await stack.__aenter__()
|
||||||
|
return {"linear": stack}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.agent.tools.mcp.connect_mcp_servers", _fake_connect)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.agent.tools.mcp_oauth.mcp_oauth_has_credentials",
|
||||||
|
lambda name, _url: name == "linear",
|
||||||
|
)
|
||||||
|
loop = _make_loop(tmp_path, mcp_servers={"notion": notion})
|
||||||
|
|
||||||
|
result = await mcp_runtime.reload_servers(loop, loop.tools)
|
||||||
|
|
||||||
|
assert attempted == ["linear"]
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["failed"] == []
|
||||||
|
assert result["retried"] == []
|
||||||
|
assert result["connected"] == ["linear"]
|
||||||
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mcp_tool_reconnects_after_session_terminated(
|
async def test_mcp_tool_reconnects_after_session_terminated(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
|
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
@@ -34,6 +35,13 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||||
|
return MyTool(
|
||||||
|
runtime_control=AgentRuntimeControl(loop),
|
||||||
|
modify_allowed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
@@ -240,7 +248,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
output = tool._inspect_all()
|
output = tool._inspect_all()
|
||||||
assert "model_preset: 'fast'" in output
|
assert "model_preset: 'fast'" in output
|
||||||
|
|
||||||
@@ -250,7 +258,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
result = tool._modify("model_preset", "fast")
|
result = tool._modify("model_preset", "fast")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset == "fast"
|
assert loop.model_preset == "fast"
|
||||||
@@ -263,7 +271,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "default")
|
result = tool._modify("model_preset", "default")
|
||||||
|
|
||||||
@@ -280,7 +288,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "missing")
|
result = tool._modify("model_preset", "missing")
|
||||||
|
|
||||||
@@ -295,7 +303,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -318,7 +326,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
|||||||
loop.set_session_model_preset = MagicMock(
|
loop.set_session_model_preset = MagicMock(
|
||||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
side_effect=ValueError("No API key configured for provider 'openai'.")
|
||||||
)
|
)
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -343,7 +351,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
|||||||
value: object,
|
value: object,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
session = loop.sessions.get_or_create("cli:one")
|
session = loop.sessions.get_or_create("cli:one")
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
@@ -366,7 +374,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
tool = _my_tool(loop)
|
||||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert entries[0]["name"] == "beta"
|
assert entries[0]["name"] == "beta"
|
||||||
assert entries[0]["path"] == str(beta_path)
|
assert entries[0]["path"] == str(beta_path)
|
||||||
|
assert loader.load_skill("alpha") is None
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -84,10 +84,6 @@ class TestToolHintKnownTools:
|
|||||||
assert '"C:/Program Files/Git/project"' not in result
|
assert '"C:/Program Files/Git/project"' not in result
|
||||||
assert '"' 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):
|
def test_exec_chained_commands_truncated_not_mid_path(self):
|
||||||
"""Long chained commands should truncate preserving abbreviated paths."""
|
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||||
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Contract and security regressions for the MyTool runtime boundary."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.tools.runtime_control import (
|
||||||
|
RUNTIME_COMMAND_KEYS,
|
||||||
|
RUNTIME_SNAPSHOT_KEYS,
|
||||||
|
AgentRuntimeControl,
|
||||||
|
RuntimeControl,
|
||||||
|
)
|
||||||
|
from nanobot.agent.tools.self import MyTool, MyToolConfig
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_default_model.return_value = "test-model"
|
||||||
|
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
|
||||||
|
return AgentLoop(
|
||||||
|
bus=MessageBus(),
|
||||||
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
|
model="test-model",
|
||||||
|
tools_config=tools_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||||
|
tool = loop.tools.get("my")
|
||||||
|
assert isinstance(tool, MyTool)
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
|
assert isinstance(tool._runtime_control, RuntimeControl)
|
||||||
|
assert isinstance(tool._runtime_control, AgentRuntimeControl)
|
||||||
|
assert tool._runtime_control is not loop
|
||||||
|
assert not hasattr(tool, "_runtime_state")
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
loop.web_config.search.api_key = "search-secret"
|
||||||
|
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
|
||||||
|
loop.unlisted_secret = "loop-secret"
|
||||||
|
|
||||||
|
snapshot = _my_tool(loop)._runtime_control.snapshot()
|
||||||
|
values = snapshot.as_mapping()
|
||||||
|
|
||||||
|
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
|
||||||
|
assert RUNTIME_COMMAND_KEYS == frozenset({
|
||||||
|
"model",
|
||||||
|
"model_preset",
|
||||||
|
"max_iterations",
|
||||||
|
"context_window_tokens",
|
||||||
|
"provider_retry_mode",
|
||||||
|
"max_tool_result_chars",
|
||||||
|
"workspace",
|
||||||
|
})
|
||||||
|
assert "provider" not in values
|
||||||
|
assert "sessions" not in values
|
||||||
|
assert "restrict_to_workspace" not in values
|
||||||
|
assert "unlisted_secret" not in values
|
||||||
|
rendered = repr(values)
|
||||||
|
assert "search-secret" not in rendered
|
||||||
|
assert "proxy-secret" not in rendered
|
||||||
|
assert "loop-secret" not in rendered
|
||||||
|
assert snapshot.web_config["proxy"] == "<configured>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
control = _my_tool(loop)._runtime_control
|
||||||
|
snapshot = control.snapshot()
|
||||||
|
search = snapshot.web_config["search"]
|
||||||
|
assert isinstance(search, dict)
|
||||||
|
|
||||||
|
search["provider"] = "mutated"
|
||||||
|
snapshot.exec_config["allow_patterns"] = ["mutated"]
|
||||||
|
snapshot.tool_names.append("mutated")
|
||||||
|
|
||||||
|
refreshed = control.snapshot()
|
||||||
|
refreshed_search = refreshed.web_config["search"]
|
||||||
|
assert isinstance(refreshed_search, dict)
|
||||||
|
assert refreshed_search["provider"] == loop.web_config.search.provider
|
||||||
|
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
|
||||||
|
assert "mutated" not in refreshed.tool_names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path, allow_set=True)
|
||||||
|
loop.unlisted_control_plane = "internal-secret"
|
||||||
|
original_workspace_root = loop.workspace_scopes.default_workspace
|
||||||
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
|
inspected = await tool.execute(action="check", key="unlisted_control_plane")
|
||||||
|
modified = await tool.execute(
|
||||||
|
action="set",
|
||||||
|
key="unlisted_control_plane",
|
||||||
|
value="scratch-value",
|
||||||
|
)
|
||||||
|
nested = await tool.execute(
|
||||||
|
action="set",
|
||||||
|
key="workspace_scopes.default_workspace",
|
||||||
|
value="elsewhere",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "internal-secret" not in inspected
|
||||||
|
assert "not found" in inspected
|
||||||
|
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
|
||||||
|
assert loop.unlisted_control_plane == "internal-secret"
|
||||||
|
assert "Error" in nested
|
||||||
|
assert loop.workspace_scopes.default_workspace == original_workspace_root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
loop = _make_loop(tmp_path)
|
||||||
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
|
assert ToolsConfig().my.allow_set is False
|
||||||
|
assert tool.parameters == {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["check", "set"],
|
||||||
|
"description": "Action to perform",
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
|
||||||
|
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
|
||||||
|
"'request.sender_id' for current routing metadata. Use 'model_preset' "
|
||||||
|
"to switch named model presets. For check without key, shows all "
|
||||||
|
"config values."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"description": (
|
||||||
|
"New value (for set). Type must match target (int for "
|
||||||
|
"max_iterations/context_window_tokens, str for model/model_preset)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action"],
|
||||||
|
}
|
||||||
|
assert "READ-ONLY MODE" in tool.description
|
||||||
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
|
assert result == "Error: set is disabled (tools.my.allow_set is false)"
|
||||||
|
assert loop.max_iterations != 80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path, allow_set=True)
|
||||||
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
|
max_iterations = await tool.execute(
|
||||||
|
action="set",
|
||||||
|
key="max_iterations",
|
||||||
|
value=80,
|
||||||
|
)
|
||||||
|
retry_mode = await tool.execute(
|
||||||
|
action="set",
|
||||||
|
key="provider_retry_mode",
|
||||||
|
value="persistent",
|
||||||
|
)
|
||||||
|
scratchpad = await tool.execute(
|
||||||
|
action="set",
|
||||||
|
key="preference",
|
||||||
|
value={"concise": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert max_iterations == "Set max_iterations = 80 (was 200)"
|
||||||
|
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
|
||||||
|
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
|
||||||
|
assert loop.max_iterations == 80
|
||||||
|
assert loop.subagents.max_iterations == 80
|
||||||
|
assert loop.provider_retry_mode == "persistent"
|
||||||
|
assert tool._runtime_control.snapshot().scratchpad == {
|
||||||
|
"preference": {"concise": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path, allow_set=True)
|
||||||
|
|
||||||
|
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
|
||||||
|
changed = await loop.tools.execute(
|
||||||
|
"my",
|
||||||
|
{"action": "set", "key": "max_iterations", "value": 80},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert checked == "model: 'test-model'"
|
||||||
|
assert changed == "Set max_iterations = 80 (was 200)"
|
||||||
|
assert loop.max_iterations == 80
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
|
||||||
|
loop = _make_loop(tmp_path, allow_set=True)
|
||||||
|
tool = _my_tool(loop)
|
||||||
|
|
||||||
|
result = await tool.execute(action="set", key="workspace", value="elsewhere")
|
||||||
|
|
||||||
|
assert "Set workspace" in result
|
||||||
|
assert tool._runtime_control.snapshot().workspace == "elsewhere"
|
||||||
|
assert loop.workspace == tmp_path
|
||||||
|
assert loop.workspace_scopes.default_workspace == tmp_path
|
||||||
@@ -8,10 +8,12 @@ from types import MappingProxyType
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
|
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -27,13 +29,16 @@ def _make_mock_loop(**overrides):
|
|||||||
loop.workspace = Path("/tmp/workspace")
|
loop.workspace = Path("/tmp/workspace")
|
||||||
loop.restrict_to_workspace = False
|
loop.restrict_to_workspace = False
|
||||||
loop._start_time = 1000.0
|
loop._start_time = 1000.0
|
||||||
loop.exec_config = MagicMock()
|
loop.exec_config = ExecToolConfig()
|
||||||
loop.channels_config = MagicMock()
|
loop.channels_config = MagicMock()
|
||||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||||
loop._runtime_vars = {}
|
loop.last_usage = loop._last_usage
|
||||||
loop._current_iteration = 0
|
loop._current_iteration = 0
|
||||||
|
loop.current_iteration = loop._current_iteration
|
||||||
loop.provider_retry_mode = "standard"
|
loop.provider_retry_mode = "standard"
|
||||||
loop.max_tool_result_chars = 16000
|
loop.max_tool_result_chars = 16000
|
||||||
|
loop.model_preset = None
|
||||||
|
loop.model_presets = {}
|
||||||
loop._concurrency_gate = None
|
loop._concurrency_gate = None
|
||||||
loop._unified_session = False
|
loop._unified_session = False
|
||||||
loop._extra_hooks = []
|
loop._extra_hooks = []
|
||||||
@@ -45,9 +50,7 @@ def _make_mock_loop(**overrides):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# web_config mock — needed for check tests
|
# web_config mock — needed for check tests
|
||||||
loop.web_config = MagicMock()
|
loop.web_config = WebToolsConfig()
|
||||||
loop.web_config.enable = True
|
|
||||||
loop.web_config.search = MagicMock()
|
|
||||||
loop.web_config.search.api_key = "sk-secret-key-12345"
|
loop.web_config.search.api_key = "sk-secret-key-12345"
|
||||||
|
|
||||||
# Tools registry mock
|
# Tools registry mock
|
||||||
@@ -55,10 +58,13 @@ def _make_mock_loop(**overrides):
|
|||||||
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
|
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
|
||||||
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
|
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
|
||||||
loop.tools.get.return_value = None
|
loop.tools.get.return_value = None
|
||||||
|
loop.tool_names = loop.tools.tool_names
|
||||||
|
|
||||||
# SubagentManager mock
|
# SubagentManager mock
|
||||||
loop.subagents = MagicMock()
|
loop.subagents = MagicMock()
|
||||||
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
||||||
|
loop.subagents._task_statuses = {}
|
||||||
|
loop.subagents.runtime_statuses.side_effect = lambda: loop.subagents._task_statuses
|
||||||
loop.subagents.get_running_count = MagicMock(return_value=1)
|
loop.subagents.get_running_count = MagicMock(return_value=1)
|
||||||
|
|
||||||
for k, v in overrides.items():
|
for k, v in overrides.items():
|
||||||
@@ -67,10 +73,10 @@ def _make_mock_loop(**overrides):
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _make_tool(runtime_state=None):
|
def _make_tool(loop=None):
|
||||||
if runtime_state is None:
|
if loop is None:
|
||||||
runtime_state = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
return MyTool(runtime_state=runtime_state)
|
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -87,10 +93,10 @@ class TestInspectSummary:
|
|||||||
assert "context_window_tokens: 65536" in result
|
assert "context_window_tokens: 65536" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_includes_runtime_vars(self):
|
async def test_inspect_includes_scratchpad(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._runtime_vars = {"task": "review"}
|
tool = _make_tool(loop=loop)
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "task" in result
|
assert "task" in result
|
||||||
|
|
||||||
@@ -150,9 +156,7 @@ class TestInspectPathNavigation:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_config_subfield(self):
|
async def test_inspect_config_subfield(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.web_config = MagicMock()
|
tool = _make_tool(loop=loop)
|
||||||
loop.web_config.enable = True
|
|
||||||
tool = _make_tool(runtime_state=loop)
|
|
||||||
result = await tool.execute(action="check", key="web_config.enable")
|
result = await tool.execute(action="check", key="web_config.enable")
|
||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@@ -160,7 +164,7 @@ class TestInspectPathNavigation:
|
|||||||
async def test_inspect_dict_key_via_dotpath(self):
|
async def test_inspect_dict_key_via_dotpath(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||||
assert "100" in result
|
assert "100" in result
|
||||||
|
|
||||||
@@ -179,20 +183,16 @@ class TestInspectPathNavigation:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
||||||
class SearchConfig(BaseModel):
|
|
||||||
provider: str = "tavily"
|
|
||||||
api_key: str = "sk-test-secret"
|
|
||||||
base_url: str = ""
|
|
||||||
max_results: int = 5
|
|
||||||
|
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.web_config = MagicMock()
|
loop.web_config.search = WebSearchConfig(
|
||||||
loop.web_config.search = SearchConfig()
|
provider="tavily",
|
||||||
|
api_key="sk-test-secret",
|
||||||
|
)
|
||||||
tool = _make_tool(loop)
|
tool = _make_tool(loop)
|
||||||
|
|
||||||
result = await tool.execute(action="check", key="web_config.search")
|
result = await tool.execute(action="check", key="web_config.search")
|
||||||
|
|
||||||
assert "provider='tavily'" in result
|
assert "tavily" in result
|
||||||
assert "sk-test-secret" not in result
|
assert "sk-test-secret" not in result
|
||||||
assert "api_key" not in result.lower()
|
assert "api_key" not in result.lower()
|
||||||
|
|
||||||
@@ -209,14 +209,14 @@ class TestModifyRestricted:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
assert "Set max_iterations = 80" in result
|
assert "Set max_iterations = 80" in result
|
||||||
assert tool._runtime_state.max_iterations == 80
|
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_restricted_out_of_range(self):
|
async def test_modify_restricted_out_of_range(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._runtime_state.max_iterations == 40
|
assert tool._runtime_control.snapshot().max_iterations == 40
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_restricted_max_exceeded(self):
|
async def test_modify_restricted_max_exceeded(self):
|
||||||
@@ -241,12 +241,12 @@ class TestModifyRestricted:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||||
assert "Set max_iterations" in result
|
assert "Set max_iterations" in result
|
||||||
assert tool._runtime_state.max_iterations == 80
|
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_context_window_valid(self):
|
async def test_modify_context_window_valid(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||||
assert "Set context_window_tokens" in result
|
assert "Set context_window_tokens" in result
|
||||||
assert loop.context_window_tokens == 131072
|
assert loop.context_window_tokens == 131072
|
||||||
@@ -324,15 +324,15 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||||
assert "Set provider_retry_mode" in result
|
assert "Set provider_retry_mode" in result
|
||||||
assert tool._runtime_state.provider_retry_mode == "persistent"
|
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_new_key_stores_in_runtime_vars(self):
|
async def test_modify_new_key_stores_in_scratchpad(self):
|
||||||
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
"""Modifying an unknown key should store it in the scratchpad."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||||
assert "my_custom_var" in result
|
assert "my_custom_var" in result
|
||||||
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
|
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_rejects_callable(self):
|
async def test_modify_rejects_callable(self):
|
||||||
@@ -351,14 +351,14 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||||
assert result == "Set scratchpad.items = [1, 2, 3]"
|
assert result == "Set scratchpad.items = [1, 2, 3]"
|
||||||
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_allows_dict(self):
|
async def test_modify_allows_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||||
assert result == "Set scratchpad.data = {'a': 1}"
|
assert result == "Set scratchpad.data = {'a': 1}"
|
||||||
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_whitespace_key_rejected(self):
|
async def test_modify_whitespace_key_rejected(self):
|
||||||
@@ -396,7 +396,7 @@ class TestModifyFree:
|
|||||||
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert "str" in result
|
assert "str" in result
|
||||||
assert tool._runtime_state.provider_retry_mode == "standard"
|
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||||
@@ -404,7 +404,7 @@ class TestModifyFree:
|
|||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert tool._runtime_state.max_tool_result_chars == 16000
|
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -486,11 +486,12 @@ class TestModifyOpen:
|
|||||||
assert "protected" in result
|
assert "protected" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_workspace_allowed(self):
|
async def test_modify_workspace_preserves_display_compatibility(self):
|
||||||
"""workspace was READONLY in v1, now freely modifiable."""
|
"""The compatibility value is isolated from filesystem security boundaries."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
||||||
assert "Set workspace" in result
|
assert "Set workspace" in result
|
||||||
|
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_mcp_servers_blocked(self):
|
async def test_modify_mcp_servers_blocked(self):
|
||||||
@@ -584,28 +585,28 @@ class TestUnknownAction:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# runtime_vars limits (from code review)
|
# scratchpad limits
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestRuntimeVarsLimits:
|
class TestScratchpadLimits:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_vars_rejects_at_max_keys(self):
|
async def test_scratchpad_rejects_at_max_keys(self):
|
||||||
loop = _make_mock_loop()
|
tool = _make_tool()
|
||||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
for i in range(64):
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||||
result = await tool.execute(action="set", key="overflow", value="data")
|
result = await tool.execute(action="set", key="overflow", value="data")
|
||||||
assert "full" in result
|
assert "full" in result
|
||||||
assert "overflow" not in loop._runtime_vars
|
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
||||||
loop = _make_mock_loop()
|
tool = _make_tool()
|
||||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
for i in range(64):
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop._runtime_vars["key_0"] == "updated"
|
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -844,7 +845,7 @@ class TestInspectTaskStatuses:
|
|||||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="subagents._task_statuses")
|
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||||
assert "abc12345" in result
|
assert "abc12345" in result
|
||||||
assert "read logs" in result
|
assert "read logs" in result
|
||||||
@@ -865,7 +866,7 @@ class TestInspectTaskStatuses:
|
|||||||
stop_reason="completed",
|
stop_reason="completed",
|
||||||
)
|
)
|
||||||
loop.subagents._task_statuses = {"xyz": status}
|
loop.subagents._task_statuses = {"xyz": status}
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||||
assert "search code" in result
|
assert "search code" in result
|
||||||
assert "completed" in result
|
assert "completed" in result
|
||||||
@@ -879,7 +880,10 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
def _make_readonly_tool(self):
|
def _make_readonly_tool(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
return MyTool(runtime_state=loop, modify_allowed=False)
|
return MyTool(
|
||||||
|
runtime_control=AgentRuntimeControl(loop),
|
||||||
|
modify_allowed=False,
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_allowed_in_readonly(self):
|
async def test_inspect_allowed_in_readonly(self):
|
||||||
@@ -904,13 +908,13 @@ class TestReadOnlyMode:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# runtime vars check fallback (Fix #1: cross-turn memory)
|
# scratchpad inspection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestRuntimeVarsInspectFallback:
|
class TestScratchpadInspection:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_runtime_var_after_modify(self):
|
async def test_inspect_scratchpad_value_after_modify(self):
|
||||||
"""Design doc scenario: set then check should return the value."""
|
"""Design doc scenario: set then check should return the value."""
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
||||||
@@ -918,14 +922,14 @@ class TestRuntimeVarsInspectFallback:
|
|||||||
assert "True" in result
|
assert "True" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_runtime_var_string(self):
|
async def test_inspect_scratchpad_string(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="current_project", value="nanobot")
|
await tool.execute(action="set", key="current_project", value="nanobot")
|
||||||
result = await tool.execute(action="check", key="current_project")
|
result = await tool.execute(action="check", key="current_project")
|
||||||
assert "nanobot" in result
|
assert "nanobot" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_runtime_var_dict(self):
|
async def test_inspect_scratchpad_dict(self):
|
||||||
tool = _make_tool()
|
tool = _make_tool()
|
||||||
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
||||||
result = await tool.execute(action="check", key="task_meta")
|
result = await tool.execute(action="check", key="task_meta")
|
||||||
@@ -958,7 +962,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.some_config = MagicMock()
|
loop.some_config = MagicMock()
|
||||||
loop.some_config.password = "hunter2"
|
loop.some_config.password = "hunter2"
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="some_config.password")
|
result = await tool.execute(action="check", key="some_config.password")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -967,7 +971,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.vault = MagicMock()
|
loop.vault = MagicMock()
|
||||||
loop.vault.secret = "classified"
|
loop.vault.secret = "classified"
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="vault.secret")
|
result = await tool.execute(action="check", key="vault.secret")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -976,7 +980,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.auth_data = MagicMock()
|
loop.auth_data = MagicMock()
|
||||||
loop.auth_data.token = "jwt-payload"
|
loop.auth_data.token = "jwt-payload"
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check", key="auth_data.token")
|
result = await tool.execute(action="check", key="auth_data.token")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -992,7 +996,7 @@ class TestSensitiveSubFieldBlocking:
|
|||||||
async def test_modify_password_blocked(self):
|
async def test_modify_password_blocked(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop.some_config = MagicMock()
|
loop.some_config = MagicMock()
|
||||||
tool = _make_tool(runtime_state=loop)
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||||
assert "not accessible" in result
|
assert "not accessible" in result
|
||||||
|
|
||||||
@@ -1083,8 +1087,8 @@ class TestSecurityAttributeProtection:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_model_presets_dotpath_blocked(self):
|
async def test_modify_model_presets_dotpath_blocked(self):
|
||||||
"""The config-derived model preset catalog is inspectable but not mutable."""
|
"""The config-derived model preset catalog is inspectable but not mutable."""
|
||||||
presets = {"fast": {"model": "fast-model"}}
|
presets = {"fast": ModelPresetConfig(model="fast-model")}
|
||||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||||
|
|
||||||
result = await tool.execute(
|
result = await tool.execute(
|
||||||
action="set",
|
action="set",
|
||||||
@@ -1093,14 +1097,14 @@ class TestSecurityAttributeProtection:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "read-only" in result
|
assert "read-only" in result
|
||||||
assert presets == {"fast": {"model": "fast-model"}}
|
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_inspect_read_only_model_preset_dotpath(self):
|
async def test_inspect_read_only_model_preset_dotpath(self):
|
||||||
presets = MappingProxyType({
|
presets = MappingProxyType({
|
||||||
"fast": ModelPresetConfig(model="fast-model"),
|
"fast": ModelPresetConfig(model="fast-model"),
|
||||||
})
|
})
|
||||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||||
|
|
||||||
result = await tool.execute(action="check", key="model_presets.fast.model")
|
result = await tool.execute(action="check", key="model_presets.fast.model")
|
||||||
|
|
||||||
@@ -1150,7 +1154,8 @@ class TestLastUsageInSummary:
|
|||||||
async def test_last_usage_not_shown_when_empty(self):
|
async def test_last_usage_not_shown_when_empty(self):
|
||||||
loop = _make_mock_loop()
|
loop = _make_mock_loop()
|
||||||
loop._last_usage = {}
|
loop._last_usage = {}
|
||||||
tool = _make_tool(runtime_state=loop)
|
loop.last_usage = loop._last_usage
|
||||||
|
tool = _make_tool(loop=loop)
|
||||||
result = await tool.execute(action="check")
|
result = await tool.execute(action="check")
|
||||||
assert "_last_usage" not in result
|
assert "_last_usage" not in result
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
|
||||||
loop = MagicMock()
|
provider = MagicMock()
|
||||||
loop.max_iterations = 40
|
provider.get_default_model.return_value = "test-model"
|
||||||
loop._runtime_vars = {}
|
loop = AgentLoop(
|
||||||
loop.subagents = MagicMock()
|
bus=MessageBus(),
|
||||||
loop.subagents.max_iterations = loop.max_iterations
|
provider=provider,
|
||||||
|
workspace=tmp_path,
|
||||||
def _sync_subagent_runtime_limits() -> None:
|
max_iterations=40,
|
||||||
loop.subagents.max_iterations = loop.max_iterations
|
)
|
||||||
|
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||||
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
|
||||||
|
|
||||||
tool = MyTool(runtime_state=loop)
|
|
||||||
|
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||||
|
|
||||||
|
|||||||
@@ -241,24 +241,6 @@ async def test_file_edit_events_route_to_channel_capability(manager):
|
|||||||
channel._send_mock.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_base_channel_file_edit_events_are_noop_safe():
|
async def test_base_channel_file_edit_events_are_noop_safe():
|
||||||
class _Plain(BaseChannel):
|
class _Plain(BaseChannel):
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
|||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -660,6 +661,7 @@ def test_plugin_setup_contract_drives_save_and_validation(
|
|||||||
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
||||||
)
|
)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
|
router.settings = WebUISettingsServices.create(config_path)
|
||||||
|
|
||||||
saved = router._save_channel_config_values(
|
saved = router._save_channel_config_values(
|
||||||
"setupplugin",
|
"setupplugin",
|
||||||
@@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
|||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_api import WebUISettingsError
|
from nanobot.webui.settings_api import WebUISettingsError
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -756,6 +759,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
|||||||
before = config_path.read_text(encoding="utf-8")
|
before = config_path.read_text(encoding="utf-8")
|
||||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
|
router.settings = WebUISettingsServices.create(config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
||||||
router._save_channel_config_values(
|
router._save_channel_config_values(
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
)
|
||||||
@@ -1231,27 +1231,6 @@ def test_openai_compat_provider_passes_model_through():
|
|||||||
assert provider.get_default_model() == "github-copilot/gpt-5.3-codex"
|
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 test_openai_codex_proxy_config_affects_provider_and_signature():
|
||||||
def config_with_proxy(proxy: str) -> Config:
|
def config_with_proxy(proxy: str) -> Config:
|
||||||
return Config.model_validate(
|
return Config.model_validate(
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import plugins as agent_plugins
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
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:
|
def _write_cache(path: Path, registry: dict) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(
|
path.write_text(
|
||||||
@@ -391,6 +402,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
"_fetch_skill_content",
|
"_fetch_skill_content",
|
||||||
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
|
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")
|
payload = manager.install("gimp")
|
||||||
|
|
||||||
@@ -400,9 +414,23 @@ def test_install_dispatches_safe_pip_and_installs_skill(
|
|||||||
assert "state_recorded" in payload["last_action"]["verification"]
|
assert "state_recorded" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
|
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 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 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
|
||||||
|
assert [
|
||||||
|
item["name"]
|
||||||
|
for item in SkillsLoader(manager.workspace).list_skills()
|
||||||
|
if item["source"] == "plugin"
|
||||||
|
] == ["cli-app-gimp"]
|
||||||
|
assert not legacy.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_run_argv_logs_command_exit_and_output(
|
def test_run_argv_logs_command_exit_and_output(
|
||||||
@@ -487,7 +515,7 @@ def test_install_records_available_cli_without_reinstalling(
|
|||||||
assert "entry_point_available" in payload["last_action"]["verification"]
|
assert "entry_point_available" in payload["last_action"]["verification"]
|
||||||
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
||||||
assert installed["feishu"]["entry_point_path"] == str(resolved)
|
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 skill.is_file()
|
||||||
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -704,7 +732,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
|
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.mkdir(parents=True)
|
||||||
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -717,7 +746,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
|
|||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
assert payload["last_action"]["ok"] is True
|
||||||
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
|
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(
|
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
|
||||||
@@ -845,19 +874,47 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
|
|||||||
"name": "zoom",
|
"name": "zoom",
|
||||||
"entry_point": "cli-anything-zoom",
|
"entry_point": "cli-anything-zoom",
|
||||||
"source": "public",
|
"source": "public",
|
||||||
"skill": "skills/cli-app-zoom/SKILL.md",
|
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gimp",
|
"name": "gimp",
|
||||||
"entry_point": "cli-anything-gimp",
|
"entry_point": "cli-anything-gimp",
|
||||||
"source": "harness",
|
"source": "harness",
|
||||||
"skill": "skills/cli-app-gimp/SKILL.md",
|
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
|
||||||
"tool": "run_cli_app",
|
"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:
|
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
|
||||||
manager = _manager(tmp_path)
|
manager = _manager(tmp_path)
|
||||||
_seed_catalog(manager)
|
_seed_catalog(manager)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
|
|||||||
assert "CLI App Mention: @zoom" in joined
|
assert "CLI App Mention: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" 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):
|
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 "CLI App Attachment: @zoom" in joined
|
||||||
assert "tool=run_cli_app" in joined
|
assert "tool=run_cli_app" in joined
|
||||||
assert "entry_point=cli-anything-zoom" 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)
|
||||||
|
|||||||
@@ -243,27 +243,6 @@ def test_workspace_override(tmp_path):
|
|||||||
assert bot._loop.workspace == custom_ws
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_custom_session_key(tmp_path):
|
async def test_run_custom_session_key(tmp_path):
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ class TestEditFileTool:
|
|||||||
assert "Successfully" in result
|
assert "Successfully" in result
|
||||||
assert f.read_text() == "hello earth"
|
assert f.read_text() == "hello earth"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_identical_replacement_returns_clear_error(self, tool, tmp_path):
|
||||||
|
f = tmp_path / "a.py"
|
||||||
|
f.write_text("hello world", encoding="utf-8")
|
||||||
|
|
||||||
|
result = await tool.execute(path=str(f), old_text="world", new_text="world")
|
||||||
|
|
||||||
|
assert result == "Error: new_text must be different from old_text."
|
||||||
|
assert f.read_text(encoding="utf-8") == "hello world"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_crlf_normalisation(self, tool, tmp_path):
|
async def test_crlf_normalisation(self, tool, tmp_path):
|
||||||
f = tmp_path / "crlf.py"
|
f = tmp_path / "crlf.py"
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||||
|
|
||||||
|
from nanobot.agent.tools.mcp_oauth import (
|
||||||
|
MCPAuthorizationRequiredError,
|
||||||
|
MCPOAuthHandlers,
|
||||||
|
MCPOAuthStorage,
|
||||||
|
create_mcp_oauth_auth,
|
||||||
|
delete_mcp_oauth_credentials,
|
||||||
|
mcp_oauth_has_credentials,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _use_data_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.agent.tools.mcp_oauth.get_data_dir", lambda: tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_server_config_accepts_explicit_oauth() -> None:
|
||||||
|
config = MCPServerConfig.model_validate({
|
||||||
|
"type": "streamableHttp",
|
||||||
|
"url": "https://mcp.example.com/mcp",
|
||||||
|
"auth": "oauth",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert config.auth == "oauth"
|
||||||
|
assert config.model_dump(by_alias=True)["auth"] == "oauth"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_oauth_storage_isolates_name_and_server_url(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
storage = MCPOAuthStorage("notion-work", "https://mcp.example.com/mcp")
|
||||||
|
tokens = OAuthToken(access_token="access-secret", refresh_token="refresh-secret")
|
||||||
|
client_info = OAuthClientInformationFull(
|
||||||
|
redirect_uris=["https://agent.example/auth/mcp/callback"],
|
||||||
|
client_id="client-id",
|
||||||
|
client_secret="client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
await storage.prepare_redirect_uri("https://agent.example/auth/mcp/callback")
|
||||||
|
await storage.set_tokens(tokens)
|
||||||
|
await storage.set_client_info(client_info)
|
||||||
|
|
||||||
|
assert await storage.get_tokens() == tokens
|
||||||
|
assert await storage.get_client_info() == client_info
|
||||||
|
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
|
||||||
|
assert mcp_oauth_has_credentials("notion-work", "https://mcp.example.com/mcp")
|
||||||
|
assert not mcp_oauth_has_credentials("notion-home", "https://mcp.example.com/mcp")
|
||||||
|
assert not mcp_oauth_has_credentials("notion-work", "https://other.example.com/mcp")
|
||||||
|
|
||||||
|
payload = json.loads((tmp_path / "auth" / "mcp.json").read_text(encoding="utf-8"))
|
||||||
|
assert "https://mcp.example.com/mcp" not in str(payload)
|
||||||
|
assert "access-secret" in str(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_changed_redirect_uri_discards_dynamic_registration_but_keeps_tokens(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
storage = MCPOAuthStorage("linear", "https://mcp.linear.example/mcp")
|
||||||
|
await storage.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||||
|
await storage.set_tokens(OAuthToken(access_token="access-secret"))
|
||||||
|
await storage.set_client_info(OAuthClientInformationFull(
|
||||||
|
redirect_uris=["https://old.example/auth/mcp/callback"],
|
||||||
|
client_id="old-client",
|
||||||
|
))
|
||||||
|
|
||||||
|
await storage.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||||
|
|
||||||
|
assert await storage.get_tokens() is not None
|
||||||
|
assert await storage.get_client_info() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_and_delete_credentials_are_scoped_to_one_server(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
first = MCPOAuthStorage("first", "https://mcp.example.com/mcp")
|
||||||
|
second = MCPOAuthStorage("second", "https://mcp.example.com/mcp")
|
||||||
|
await first.set_tokens(OAuthToken(access_token="first-token"))
|
||||||
|
await second.set_tokens(OAuthToken(access_token="second-token"))
|
||||||
|
|
||||||
|
await first.prepare_redirect_uri(
|
||||||
|
"https://agent.example/auth/mcp/callback",
|
||||||
|
reset=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await first.get_tokens() is None
|
||||||
|
assert await second.get_tokens() is not None
|
||||||
|
assert delete_mcp_oauth_credentials("first")
|
||||||
|
assert not delete_mcp_oauth_credentials("first")
|
||||||
|
assert await second.get_tokens() is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_credentials_reject_late_writes_from_stale_oauth_flow(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
server_url = "https://mcp.linear.example/mcp"
|
||||||
|
stale = MCPOAuthStorage("linear", server_url)
|
||||||
|
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||||
|
|
||||||
|
assert delete_mcp_oauth_credentials("linear")
|
||||||
|
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
|
||||||
|
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||||
|
|
||||||
|
replacement = MCPOAuthStorage("linear", server_url)
|
||||||
|
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||||
|
await stale.set_tokens(OAuthToken(access_token="late-after-replacement"))
|
||||||
|
|
||||||
|
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||||
|
assert await replacement.get_tokens() is None
|
||||||
|
|
||||||
|
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
|
||||||
|
stored = await replacement.get_tokens()
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.access_token == "fresh-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_before_oauth_claim_rejects_late_credential_writes(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
server_url = "https://mcp.linear.example/mcp"
|
||||||
|
stale = MCPOAuthStorage("linear", server_url)
|
||||||
|
|
||||||
|
assert not delete_mcp_oauth_credentials("linear")
|
||||||
|
with pytest.raises(MCPAuthorizationRequiredError, match="cancelled"):
|
||||||
|
await stale.prepare_redirect_uri("https://old.example/auth/mcp/callback")
|
||||||
|
await stale.set_tokens(OAuthToken(access_token="late-after-delete"))
|
||||||
|
assert not mcp_oauth_has_credentials("linear", server_url)
|
||||||
|
|
||||||
|
replacement = MCPOAuthStorage("linear", server_url)
|
||||||
|
await replacement.prepare_redirect_uri("https://new.example/auth/mcp/callback")
|
||||||
|
await replacement.set_tokens(OAuthToken(access_token="fresh-token"))
|
||||||
|
assert mcp_oauth_has_credentials("linear", server_url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_mcp_oauth_auth_uses_browser_handlers_and_persists_redirect(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
async def redirect(_url: str) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def callback() -> tuple[str, str | None]:
|
||||||
|
return "code", "state"
|
||||||
|
|
||||||
|
handlers = MCPOAuthHandlers(
|
||||||
|
redirect_uri="https://agent.example/auth/mcp/callback",
|
||||||
|
redirect_handler=redirect,
|
||||||
|
callback_handler=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth = await create_mcp_oauth_auth(
|
||||||
|
"xmind",
|
||||||
|
"https://app.xmind.example/api/mcp",
|
||||||
|
handlers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert str(auth.context.client_metadata.redirect_uris[0]) == (
|
||||||
|
"https://agent.example/auth/mcp/callback"
|
||||||
|
)
|
||||||
|
assert str(auth.context.client_metadata.client_uri) == "https://github.com/HKUDS/nanobot"
|
||||||
|
assert str(auth.context.client_metadata.logo_uri) == (
|
||||||
|
"https://raw.githubusercontent.com/HKUDS/nanobot/main/"
|
||||||
|
"webui/public/brand/nanobot_apple_touch.png"
|
||||||
|
)
|
||||||
|
assert auth.context.redirect_handler is redirect
|
||||||
|
assert auth.context.callback_handler is callback
|
||||||
|
storage = MCPOAuthStorage("xmind", "https://app.xmind.example/api/mcp")
|
||||||
|
assert await storage.redirect_uri() == "https://agent.example/auth/mcp/callback"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_authorization_without_tokens_stops_locally(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
with pytest.raises(MCPAuthorizationRequiredError):
|
||||||
|
await create_mcp_oauth_auth("notion", "https://mcp.notion.example/mcp")
|
||||||
|
|
||||||
|
assert not (tmp_path / "auth" / "mcp.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_authorization_request_clears_rejected_token(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
server_url = "https://mcp.example.com/mcp"
|
||||||
|
storage = MCPOAuthStorage("notion", server_url)
|
||||||
|
client_info = OAuthClientInformationFull(
|
||||||
|
redirect_uris=["https://agent.example/auth/mcp/callback"],
|
||||||
|
client_id="registered-client",
|
||||||
|
)
|
||||||
|
await storage.set_tokens(OAuthToken(access_token="rejected-token"))
|
||||||
|
await storage.set_client_info(client_info)
|
||||||
|
auth = await create_mcp_oauth_auth("notion", server_url)
|
||||||
|
|
||||||
|
redirect_handler = auth.context.redirect_handler
|
||||||
|
assert redirect_handler is not None
|
||||||
|
with pytest.raises(MCPAuthorizationRequiredError):
|
||||||
|
await redirect_handler("https://accounts.example.com/authorize?state=state")
|
||||||
|
|
||||||
|
assert await storage.get_tokens() is None
|
||||||
|
assert await storage.get_client_info() == client_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_official_mcp_sdk_completes_discovery_registration_and_token_exchange(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_data_dir(tmp_path, monkeypatch)
|
||||||
|
server_url = "https://mcp.example.com/mcp"
|
||||||
|
authorization_url = ""
|
||||||
|
requests: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def redirect(url: str) -> None:
|
||||||
|
nonlocal authorization_url
|
||||||
|
authorization_url = url
|
||||||
|
|
||||||
|
async def callback() -> tuple[str, str | None]:
|
||||||
|
state = parse_qs(urlsplit(authorization_url).query)["state"][0]
|
||||||
|
return "authorization-code", state
|
||||||
|
|
||||||
|
auth = await create_mcp_oauth_auth(
|
||||||
|
"company-mcp",
|
||||||
|
server_url,
|
||||||
|
MCPOAuthHandlers(
|
||||||
|
redirect_uri="https://agent.example/auth/mcp/callback",
|
||||||
|
redirect_handler=redirect,
|
||||||
|
callback_handler=callback,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def respond(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append((request.method, str(request.url)))
|
||||||
|
if str(request.url) == server_url:
|
||||||
|
if request.headers.get("Authorization") == "Bearer access-token":
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
return httpx.Response(
|
||||||
|
401,
|
||||||
|
headers={
|
||||||
|
"WWW-Authenticate": (
|
||||||
|
'Bearer resource_metadata="https://mcp.example.com/'
|
||||||
|
'.well-known/oauth-protected-resource"'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.url.path == "/.well-known/oauth-protected-resource":
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"resource": server_url,
|
||||||
|
"authorization_servers": ["https://auth.example.com"],
|
||||||
|
})
|
||||||
|
if request.url.path == "/.well-known/oauth-authorization-server":
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"issuer": "https://auth.example.com",
|
||||||
|
"authorization_endpoint": "https://auth.example.com/authorize",
|
||||||
|
"token_endpoint": "https://auth.example.com/token",
|
||||||
|
"registration_endpoint": "https://auth.example.com/register",
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"code_challenge_methods_supported": ["S256"],
|
||||||
|
})
|
||||||
|
if request.url.path == "/register":
|
||||||
|
registration = json.loads(request.content)
|
||||||
|
assert registration["client_uri"] == "https://github.com/HKUDS/nanobot"
|
||||||
|
assert registration["logo_uri"].endswith(
|
||||||
|
"/webui/public/brand/nanobot_apple_touch.png"
|
||||||
|
)
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"client_id": "nanobot-client",
|
||||||
|
"redirect_uris": ["https://agent.example/auth/mcp/callback"],
|
||||||
|
"token_endpoint_auth_method": "none",
|
||||||
|
})
|
||||||
|
if request.url.path == "/token":
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"access_token": "access-token",
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
})
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(respond),
|
||||||
|
auth=auth,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(server_url)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert urlsplit(authorization_url)._replace(query="").geturl() == (
|
||||||
|
"https://auth.example.com/authorize"
|
||||||
|
)
|
||||||
|
assert ("POST", "https://auth.example.com/register") in requests
|
||||||
|
assert ("POST", "https://auth.example.com/token") in requests
|
||||||
|
stored = await MCPOAuthStorage("company-mcp", server_url).get_tokens()
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.access_token == "access-token"
|
||||||
|
assert stored.refresh_token == "refresh-token"
|
||||||
@@ -5,10 +5,13 @@ import asyncio
|
|||||||
import socket
|
import socket
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
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.mcp import _probe_http_url, connect_mcp_servers
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
_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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_probe_not_called_for_stdio():
|
async def test_probe_not_called_for_stdio():
|
||||||
"""stdio transport should not be probed — it spawns a local process."""
|
"""stdio transport should not be probed — it spawns a local process."""
|
||||||
|
|||||||
@@ -826,19 +826,23 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
|||||||
) -> None:
|
) -> None:
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
def _error(message: str, *args: object) -> None:
|
|
||||||
messages.append(message.format(*args))
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _broken_stdio_client(_params: object):
|
async def _broken_stdio_client(_params: object):
|
||||||
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
raise RuntimeError("Parse error: Unexpected token 'INFO' before JSON-RPC headers")
|
||||||
yield # pragma: no cover
|
yield # pragma: no cover
|
||||||
|
|
||||||
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _broken_stdio_client)
|
||||||
monkeypatch.setattr("nanobot.agent.tools.mcp.logger.exception", _error)
|
sink = mcp_mod.logger.add(
|
||||||
|
lambda message: messages.append(message.record["message"]), level="ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers({"gh": MCPServerConfig(command="github-mcp")}, registry)
|
try:
|
||||||
|
stacks = await connect_mcp_servers(
|
||||||
|
{"gh": MCPServerConfig(command="github-mcp")}, registry
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
mcp_mod.logger.remove(sink)
|
||||||
|
|
||||||
assert stacks == {}
|
assert stacks == {}
|
||||||
assert messages
|
assert messages
|
||||||
@@ -847,6 +851,36 @@ async def test_connect_mcp_servers_logs_stdio_pollution_hint(
|
|||||||
assert "stderr" in messages[-1]
|
assert "stderr" in messages[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_transient_connection_group_logs_brief_warning_and_debug_trace() -> None:
|
||||||
|
records: list[dict] = []
|
||||||
|
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||||
|
error = ExceptionGroup("transport failed", [httpx.ConnectError("")])
|
||||||
|
try:
|
||||||
|
mcp_mod._log_mcp_connection_failure("notion", error)
|
||||||
|
finally:
|
||||||
|
mcp_mod.logger.remove(sink)
|
||||||
|
|
||||||
|
warning = next(record for record in records if record["level"].name == "WARNING")
|
||||||
|
debug = next(record for record in records if record["level"].name == "DEBUG")
|
||||||
|
assert warning["exception"] is None
|
||||||
|
assert "transient connection failure" in warning["message"]
|
||||||
|
assert debug["exception"] is not None
|
||||||
|
assert not any(record["level"].name == "ERROR" for record in records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_connection_failure_keeps_error_trace() -> None:
|
||||||
|
records: list[dict] = []
|
||||||
|
sink = mcp_mod.logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||||||
|
try:
|
||||||
|
mcp_mod._log_mcp_connection_failure("notion", RuntimeError("boom"))
|
||||||
|
finally:
|
||||||
|
mcp_mod.logger.remove(sink)
|
||||||
|
|
||||||
|
error = next(record for record in records if record["level"].name == "ERROR")
|
||||||
|
assert error["exception"] is not None
|
||||||
|
assert not any(record["level"].name == "WARNING" for record in records)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"config",
|
"config",
|
||||||
@@ -1082,10 +1116,22 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
|
||||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
failure_mode: str,
|
||||||
) -> None:
|
) -> 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:
|
class _SelectiveClientSession:
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
def __init__(self, read: object, _write: object) -> None:
|
||||||
@@ -1099,7 +1145,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _selective_stdio_client(params: object):
|
async def _selective_stdio_client(params: object):
|
||||||
if params.command == "bad":
|
if params.command == "bad" and failure_mode == "exception":
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
yield params.command, object()
|
yield params.command, object()
|
||||||
|
|
||||||
@@ -1109,8 +1155,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stacks = await connect_mcp_servers(
|
||||||
{
|
{
|
||||||
"good": MCPServerConfig(command="good"),
|
|
||||||
"bad": MCPServerConfig(command="bad"),
|
"bad": MCPServerConfig(command="bad"),
|
||||||
|
"good": MCPServerConfig(command="good"),
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
)
|
)
|
||||||
@@ -1121,6 +1167,36 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
assert set(stacks) == {"good"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
@@ -1168,6 +1244,129 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
|||||||
assert timeout.pool == 30.0
|
assert timeout.pool == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("transport", ["sse", "streamableHttp"])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_mcp_servers_attaches_oauth_to_remote_http_client(
|
||||||
|
transport: str,
|
||||||
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
|
||||||
|
oauth_auth = object()
|
||||||
|
oauth_handlers = object()
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def _reachable(_url: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate(_url: str) -> tuple[bool, str]:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
async def _create_auth(name: str, url: str, handlers: object) -> object:
|
||||||
|
captured.update(name=name, url=url, handlers=handlers)
|
||||||
|
return oauth_auth
|
||||||
|
|
||||||
|
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||||
|
oauth_mod.MCPAuthorizationRequiredError = RuntimeError # type: ignore[attr-defined]
|
||||||
|
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
|
||||||
|
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||||
|
|
||||||
|
class FakeAsyncClient:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||||
|
captured["client_kwargs"] = kwargs
|
||||||
|
|
||||||
|
async def __aenter__(self) -> object:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _capturing_sse_client(
|
||||||
|
_url: str,
|
||||||
|
httpx_client_factory=None,
|
||||||
|
auth=None,
|
||||||
|
):
|
||||||
|
captured["transport_auth"] = auth
|
||||||
|
yield object(), object()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||||
|
assert http_client is not None
|
||||||
|
yield object(), object(), object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||||
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
|
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
|
||||||
|
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys.modules["mcp.client.streamable_http"],
|
||||||
|
"streamable_http_client",
|
||||||
|
_capturing_streamable_http_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
url = "https://mcp.example.com/sse" if transport == "sse" else "https://mcp.example.com/mcp"
|
||||||
|
registry = ToolRegistry()
|
||||||
|
stacks = await connect_mcp_servers(
|
||||||
|
{"remote": MCPServerConfig(type=transport, url=url, auth="oauth")},
|
||||||
|
registry,
|
||||||
|
oauth_handlers={"remote": oauth_handlers}, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
for stack in stacks.values():
|
||||||
|
await stack.aclose()
|
||||||
|
|
||||||
|
assert captured["name"] == "remote"
|
||||||
|
assert captured["url"] == url
|
||||||
|
assert captured["handlers"] is oauth_handlers
|
||||||
|
if transport == "sse":
|
||||||
|
assert captured["transport_auth"] is oauth_auth
|
||||||
|
else:
|
||||||
|
client_kwargs = captured["client_kwargs"]
|
||||||
|
assert isinstance(client_kwargs, dict)
|
||||||
|
assert client_kwargs["auth"] is oauth_auth
|
||||||
|
assert client_kwargs["event_hooks"] == {"request": [mcp_mod._validate_mcp_request_url]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_mcp_servers_skips_background_oauth_without_credentials(
|
||||||
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
class AuthorizationRequiredError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _create_auth(*_args: object) -> object:
|
||||||
|
raise AuthorizationRequiredError
|
||||||
|
|
||||||
|
probe_called = False
|
||||||
|
|
||||||
|
async def _probe(_url: str) -> bool:
|
||||||
|
nonlocal probe_called
|
||||||
|
probe_called = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
oauth_mod = ModuleType("nanobot.agent.tools.mcp_oauth")
|
||||||
|
oauth_mod.MCPAuthorizationRequiredError = AuthorizationRequiredError # type: ignore[attr-defined]
|
||||||
|
oauth_mod.create_mcp_oauth_auth = _create_auth # type: ignore[attr-defined]
|
||||||
|
monkeypatch.setitem(sys.modules, "nanobot.agent.tools.mcp_oauth", oauth_mod)
|
||||||
|
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||||
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _probe)
|
||||||
|
|
||||||
|
stacks = await connect_mcp_servers(
|
||||||
|
{
|
||||||
|
"remote": MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
url="https://mcp.example.com/mcp",
|
||||||
|
auth="oauth",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
ToolRegistry(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stacks == {}
|
||||||
|
assert not probe_called
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Shared characterization cases for live and persisted WebUI projection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.webui.transcript import replay_transcript_to_ui_messages
|
||||||
|
|
||||||
|
_FIXTURE_PATH = (
|
||||||
|
Path(__file__).parents[2]
|
||||||
|
/ "webui"
|
||||||
|
/ "src"
|
||||||
|
/ "tests"
|
||||||
|
/ "fixtures"
|
||||||
|
/ "live-replay-event-projection.json"
|
||||||
|
)
|
||||||
|
_SEMANTIC_MESSAGE_FIELDS = (
|
||||||
|
"role",
|
||||||
|
"content",
|
||||||
|
"kind",
|
||||||
|
"traces",
|
||||||
|
"toolEvents",
|
||||||
|
"fileEdits",
|
||||||
|
"images",
|
||||||
|
"media",
|
||||||
|
"cliApps",
|
||||||
|
"mcpPresets",
|
||||||
|
"sessionMentions",
|
||||||
|
"reasoning",
|
||||||
|
"latencyMs",
|
||||||
|
"source",
|
||||||
|
"turnId",
|
||||||
|
"turnPhase",
|
||||||
|
"turnSeq",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_projection(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
segment_aliases: dict[str, str] = {}
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for message in messages:
|
||||||
|
row = {
|
||||||
|
field: message[field]
|
||||||
|
for field in _SEMANTIC_MESSAGE_FIELDS
|
||||||
|
if field in message and message[field] is not None
|
||||||
|
}
|
||||||
|
segment_id = message.get("activitySegmentId")
|
||||||
|
if isinstance(segment_id, str) and segment_id:
|
||||||
|
row["activitySegmentId"] = segment_aliases.setdefault(
|
||||||
|
segment_id,
|
||||||
|
f"segment-{len(segment_aliases) + 1}",
|
||||||
|
)
|
||||||
|
normalized.append(row)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_matches_shared_live_projection_before_canonical_revision_migration() -> None:
|
||||||
|
"""Lock the known-equivalent subset without defining the future snapshot protocol."""
|
||||||
|
fixture = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
for case in fixture["cases"]:
|
||||||
|
actual = replay_transcript_to_ui_messages(case["transcript"])
|
||||||
|
assert _normalize_projection(actual) == case["expected"], case["name"]
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
from nanobot.webui.mcp_oauth_api import (
|
||||||
|
McpOAuthError,
|
||||||
|
McpOAuthManager,
|
||||||
|
prepare_mcp_oauth_redirect_uri,
|
||||||
|
validate_mcp_oauth_redirect_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Connection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _config() -> MCPServerConfig:
|
||||||
|
return MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://mcp.example.com/mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_flow_retries_current_server_and_ignores_unrelated_reload_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = McpOAuthManager()
|
||||||
|
connection = _Connection()
|
||||||
|
received: dict[str, object] = {}
|
||||||
|
reload_calls = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||||
|
lambda _url: (True, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(servers, _registry, *, oauth_handlers):
|
||||||
|
assert set(servers) == {"xmind"}
|
||||||
|
handlers = oauth_handlers["xmind"]
|
||||||
|
await handlers.redirect_handler(
|
||||||
|
"https://accounts.example.com/authorize?client_id=test&state=state-123"
|
||||||
|
)
|
||||||
|
received["callback"] = await handlers.callback_handler()
|
||||||
|
return {"xmind": connection}
|
||||||
|
|
||||||
|
async def reload_mcp() -> dict[str, object]:
|
||||||
|
nonlocal reload_calls
|
||||||
|
reload_calls += 1
|
||||||
|
if reload_calls == 1:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"requires_restart": False,
|
||||||
|
"failed": ["xmind"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"requires_restart": False,
|
||||||
|
"connected": ["xmind"],
|
||||||
|
"failed": ["notion"],
|
||||||
|
"message": "MCP config reloaded, but some servers did not connect: notion",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||||
|
|
||||||
|
started = await manager.start(
|
||||||
|
"xmind",
|
||||||
|
_config(),
|
||||||
|
"https://agent.example.com/auth/mcp/callback",
|
||||||
|
reload_mcp=reload_mcp,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert started["status"] == "authorization_required"
|
||||||
|
assert started["authorization_url"].startswith("https://accounts.example.com/authorize?")
|
||||||
|
manager.submit_callback(state="state-123", code="oauth-code", error=None)
|
||||||
|
with pytest.raises(McpOAuthError, match="expired"):
|
||||||
|
manager.submit_callback(state="state-123", code="replayed-code", error=None)
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if reload_calls == 2:
|
||||||
|
break
|
||||||
|
assert reload_calls == 2
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
first, second = await asyncio.gather(
|
||||||
|
manager.status(started["flow_id"]),
|
||||||
|
manager.status(started["flow_id"]),
|
||||||
|
)
|
||||||
|
if first["status"] == "connected":
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert first["status"] == "connected"
|
||||||
|
assert second["status"] == "connected"
|
||||||
|
assert first["hot_reload"]["failed"] == ["notion"]
|
||||||
|
assert received["callback"] == ("oauth-code", "state-123")
|
||||||
|
assert reload_calls == 2
|
||||||
|
assert connection.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remote_http_flow_accepts_a_pasted_loopback_callback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = McpOAuthManager()
|
||||||
|
connection = _Connection()
|
||||||
|
received: dict[str, object] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||||
|
lambda _url: (True, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(_servers, _registry, *, oauth_handlers):
|
||||||
|
handlers = oauth_handlers["linear"]
|
||||||
|
received["redirect_uri"] = handlers.redirect_uri
|
||||||
|
await handlers.redirect_handler(
|
||||||
|
"https://accounts.example.com/authorize?client_id=test&state=manual-state"
|
||||||
|
)
|
||||||
|
received["callback"] = await handlers.callback_handler()
|
||||||
|
return {"linear": connection}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||||
|
|
||||||
|
started = await manager.start(
|
||||||
|
"linear",
|
||||||
|
_config(),
|
||||||
|
"http://192.0.2.10:8765/auth/mcp/callback",
|
||||||
|
reload_mcp=lambda: asyncio.sleep(
|
||||||
|
0,
|
||||||
|
result={"ok": True, "requires_restart": False},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert started["status"] == "authorization_required"
|
||||||
|
assert started["completion_input"] == "callback_url"
|
||||||
|
assert received["redirect_uri"] == "http://127.0.0.1:8765/auth/mcp/callback"
|
||||||
|
|
||||||
|
with pytest.raises(McpOAuthError, match="complete callback URL"):
|
||||||
|
manager.submit_callback_url(
|
||||||
|
flow_id=started["flow_id"],
|
||||||
|
callback_url=(
|
||||||
|
"http://127.0.0.1:8765/wrong?code=oauth-code&state=manual-state"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with pytest.raises(McpOAuthError, match="different or expired"):
|
||||||
|
manager.submit_callback_url(
|
||||||
|
flow_id=started["flow_id"],
|
||||||
|
callback_url=(
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||||
|
"?code=oauth-code&state=other-state"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
submitted = manager.submit_callback_url(
|
||||||
|
flow_id=started["flow_id"],
|
||||||
|
callback_url=(
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||||
|
"?code=oauth-code&state=manual-state"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert submitted["status"] == "connecting"
|
||||||
|
|
||||||
|
for _ in range(20):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
result = await manager.status(started["flow_id"])
|
||||||
|
if result["status"] == "connected":
|
||||||
|
break
|
||||||
|
|
||||||
|
assert result["status"] == "connected"
|
||||||
|
assert result["completion_input"] == "callback_url"
|
||||||
|
assert received["callback"] == ("oauth-code", "manual-state")
|
||||||
|
assert connection.closed is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_flow_surfaces_provider_denial_without_callback_description(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
manager = McpOAuthManager()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||||
|
lambda _url: (True, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(_servers, _registry, *, oauth_handlers):
|
||||||
|
handlers = oauth_handlers["notion"]
|
||||||
|
await handlers.redirect_handler("https://accounts.example.com/auth?state=deny-state")
|
||||||
|
await handlers.callback_handler()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||||
|
started = await manager.start(
|
||||||
|
"notion",
|
||||||
|
_config(),
|
||||||
|
"https://agent.example.com/auth/mcp/callback",
|
||||||
|
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(McpOAuthError, match="access_denied"):
|
||||||
|
manager.submit_callback(state="deny-state", code=None, error="access_denied")
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
result = await manager.status(started["flow_id"])
|
||||||
|
if result["status"] == "failed":
|
||||||
|
break
|
||||||
|
|
||||||
|
assert result["status"] == "failed"
|
||||||
|
assert result["error"] == "Authorization was not completed (access_denied)."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("authorization_url", "url_is_safe", "state"),
|
||||||
|
[
|
||||||
|
("https://127.0.0.1/authorize?state=private-state", False, "private-state"),
|
||||||
|
("http://accounts.example.com/authorize?state=http-state", True, "http-state"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_flow_blocks_unsafe_authorization_url(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
authorization_url: str,
|
||||||
|
url_is_safe: bool,
|
||||||
|
state: str,
|
||||||
|
) -> None:
|
||||||
|
manager = McpOAuthManager()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.mcp_oauth_api.validate_url_target",
|
||||||
|
lambda _url: (url_is_safe, "private address"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def connect(_servers, _registry, *, oauth_handlers):
|
||||||
|
await oauth_handlers["linear"].redirect_handler(authorization_url)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.webui.mcp_oauth_api.connect_mcp_servers", connect)
|
||||||
|
|
||||||
|
result = await manager.start(
|
||||||
|
"linear",
|
||||||
|
_config(),
|
||||||
|
"https://agent.example.com/auth/mcp/callback",
|
||||||
|
reload_mcp=lambda: asyncio.sleep(0, result={"ok": True}),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "failed"
|
||||||
|
assert result["error"] == "The MCP server returned an unsafe authorization URL."
|
||||||
|
with pytest.raises(McpOAuthError, match="expired"):
|
||||||
|
manager.submit_callback(state=state, code="code", error=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_uri_requires_https_except_for_loopback() -> None:
|
||||||
|
assert validate_mcp_oauth_redirect_uri(
|
||||||
|
"https://agent.example.com/auth/mcp/callback"
|
||||||
|
) == "https://agent.example.com/auth/mcp/callback"
|
||||||
|
assert validate_mcp_oauth_redirect_uri(
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||||
|
) == "http://127.0.0.1:8765/auth/mcp/callback"
|
||||||
|
|
||||||
|
with pytest.raises(McpOAuthError, match="HTTPS or localhost"):
|
||||||
|
validate_mcp_oauth_redirect_uri("http://192.0.2.10/auth/mcp/callback")
|
||||||
|
with pytest.raises(McpOAuthError, match="Invalid"):
|
||||||
|
validate_mcp_oauth_redirect_uri("https://agent.example.com/wrong")
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_http_redirect_prepares_a_manual_loopback_callback() -> None:
|
||||||
|
assert prepare_mcp_oauth_redirect_uri(
|
||||||
|
"https://agent.example.com/auth/mcp/callback"
|
||||||
|
) == ("https://agent.example.com/auth/mcp/callback", False)
|
||||||
|
assert prepare_mcp_oauth_redirect_uri(
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback"
|
||||||
|
) == ("http://127.0.0.1:8765/auth/mcp/callback", False)
|
||||||
|
assert prepare_mcp_oauth_redirect_uri(
|
||||||
|
"http://agent.example.com:9443/auth/mcp/callback"
|
||||||
|
) == ("http://127.0.0.1:9443/auth/mcp/callback", True)
|
||||||
@@ -1,22 +1,78 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp.shared.auth import OAuthToken
|
||||||
|
|
||||||
|
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||||
|
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
mcp_presets_action,
|
mcp_presets_action,
|
||||||
mcp_presets_payload,
|
mcp_presets_payload,
|
||||||
|
mcp_presets_settings_action,
|
||||||
mcp_presets_test_action,
|
mcp_presets_test_action,
|
||||||
normalize_mcp_preset_mentions,
|
normalize_mcp_preset_mentions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
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")
|
||||||
|
setup = root / "bin" / "install"
|
||||||
|
setup.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
setup.chmod(0o755)
|
||||||
|
assets = root / "assets"
|
||||||
|
assets.mkdir()
|
||||||
|
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
|
||||||
|
(root / "plugin.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": "desktop",
|
||||||
|
"description": "Control the local desktop.",
|
||||||
|
"extensions": {
|
||||||
|
"dev.nanobot": {
|
||||||
|
"displayName": "Desktop Control",
|
||||||
|
"accentColor": "#ff7a1a",
|
||||||
|
"logo": "./assets/icon.png",
|
||||||
|
"permissions": ["screen-recording"],
|
||||||
|
"installCommand": ["./bin/install"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "mcp.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
"mcpServers": {
|
||||||
|
"desktop": {"type": "stdio", "command": "./bin/server"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -38,6 +94,9 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
"aws-docs",
|
"aws-docs",
|
||||||
"brave-search",
|
"brave-search",
|
||||||
"postman",
|
"postman",
|
||||||
|
"xmind",
|
||||||
|
"notion",
|
||||||
|
"linear",
|
||||||
}.issubset(names)
|
}.issubset(names)
|
||||||
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
browserbase = next(preset for preset in payload["presets"] if preset["name"] == "browserbase")
|
||||||
assert browserbase["installed"] is False
|
assert browserbase["installed"] is False
|
||||||
@@ -55,6 +114,99 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
_write_agent_plugin(load_config().workspace_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.agent.plugins.subprocess.run",
|
||||||
|
lambda command, **_: subprocess.CompletedProcess(command, 0, "", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||||
|
assert row["name"] == "plugin-desktop"
|
||||||
|
assert row["display_name"] == "Desktop Control"
|
||||||
|
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||||
|
assert row["install_supported"] is False
|
||||||
|
assert row["installed"] is True
|
||||||
|
assert row["configured"] is False
|
||||||
|
assert row["enabled"] is False
|
||||||
|
assert row["status"] == "disabled"
|
||||||
|
|
||||||
|
async def reload() -> dict[str, object]:
|
||||||
|
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
|
||||||
|
|
||||||
|
plugin_action = partial(
|
||||||
|
mcp_presets_settings_action,
|
||||||
|
query={"name": ["plugin-desktop"]},
|
||||||
|
)
|
||||||
|
with pytest.raises(McpPresetError, match="restricted") as restricted:
|
||||||
|
asyncio.run(plugin_action("enable", remote=True))
|
||||||
|
assert restricted.value.status == 403
|
||||||
|
|
||||||
|
enabled = asyncio.run(plugin_action("enable", reload_mcp=reload))
|
||||||
|
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
|
||||||
|
assert enabled_row["configured"] is True
|
||||||
|
assert enabled_row["enabled"] is True
|
||||||
|
assert enabled_row["status"] == "enabled"
|
||||||
|
assert enabled["requires_restart"] is False
|
||||||
|
|
||||||
|
disabled = asyncio.run(plugin_action("disable", reload_mcp=reload))
|
||||||
|
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
|
||||||
|
assert disabled_row["installed"] is True
|
||||||
|
assert disabled_row["configured"] is True
|
||||||
|
assert disabled_row["enabled"] is False
|
||||||
|
assert disabled_row["status"] == "disabled"
|
||||||
|
|
||||||
|
with pytest.raises(McpPresetError, match="enable and disable"):
|
||||||
|
asyncio.run(plugin_action("remove"))
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
config["tools"] = {
|
||||||
|
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
|
||||||
|
}
|
||||||
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
rows = [
|
||||||
|
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
|
||||||
|
]
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["source"] == "custom"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oauth_preset_is_one_click_configured_after_token_storage(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = mcp_presets_action("enable", {"name": ["xmind"]})
|
||||||
|
|
||||||
|
row = next(item for item in payload["presets"] if item["name"] == "xmind")
|
||||||
|
assert row["installed"] is True
|
||||||
|
assert row["configured"] is False
|
||||||
|
assert row["status"] == "authorization_required"
|
||||||
|
assert row["transport"] == "streamableHttp"
|
||||||
|
assert row["auth"] == "oauth"
|
||||||
|
config = load_config()
|
||||||
|
cfg = config.tools.mcp_servers["xmind"]
|
||||||
|
assert cfg.type == "streamableHttp"
|
||||||
|
assert cfg.auth == "oauth"
|
||||||
|
assert cfg.url == "https://app.xmind.com/api/mcp"
|
||||||
|
|
||||||
|
await MCPOAuthStorage("xmind", cfg.url).set_tokens(OAuthToken(access_token="secret"))
|
||||||
|
connected = mcp_presets_payload()
|
||||||
|
row = next(item for item in connected["presets"] if item["name"] == "xmind")
|
||||||
|
assert row["configured"] is True
|
||||||
|
assert row["status"] == "configured"
|
||||||
|
|
||||||
|
mcp_presets_action("remove", {"name": ["xmind"]})
|
||||||
|
assert await MCPOAuthStorage("xmind", cfg.url).get_tokens() is None
|
||||||
|
|
||||||
|
|
||||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -296,11 +448,11 @@ def test_test_mcp_preset_scrubs_connection_errors(
|
|||||||
assert "<redacted>" in payload["last_action"]["error"]
|
assert "<redacted>" in payload["last_action"]["error"]
|
||||||
|
|
||||||
|
|
||||||
def test_unlisted_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_unknown_oauth_placeholder_is_not_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
_use_config(tmp_path, monkeypatch)
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
with pytest.raises(McpPresetError) as exc:
|
with pytest.raises(McpPresetError) as exc:
|
||||||
mcp_presets_action("enable", {"name": ["linear"]})
|
mcp_presets_action("enable", {"name": ["asana"]})
|
||||||
|
|
||||||
assert exc.value.status == 404
|
assert exc.value.status == 404
|
||||||
|
|
||||||
@@ -414,6 +566,72 @@ def test_import_mcp_config_and_tool_allowlist(
|
|||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_recognizes_known_and_explicit_oauth_servers(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
payload = custom_mcp_action(
|
||||||
|
"import",
|
||||||
|
{
|
||||||
|
"config": [
|
||||||
|
(
|
||||||
|
'{"mcpServers":{'
|
||||||
|
'"notion-work":{"url":"https://mcp.notion.com/mcp"},'
|
||||||
|
'"company-mcp":{"url":"https://mcp.example.com/mcp","auth":"oauth"},'
|
||||||
|
'"notion-pat":{"url":"https://mcp.notion.com/mcp",'
|
||||||
|
'"headers":{"Authorization":"Bearer secret"}}'
|
||||||
|
'}}'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
assert config.tools.mcp_servers["notion-work"].auth == "oauth"
|
||||||
|
assert config.tools.mcp_servers["company-mcp"].auth == "oauth"
|
||||||
|
assert config.tools.mcp_servers["notion-pat"].auth is None
|
||||||
|
rows = {row["name"]: row for row in payload["presets"]}
|
||||||
|
assert rows["notion-work"]["status"] == "authorization_required"
|
||||||
|
assert rows["company-mcp"]["status"] == "authorization_required"
|
||||||
|
assert rows["notion-pat"]["status"] == "configured"
|
||||||
|
assert "Bearer secret" not in str(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_replacing_oauth_config_removes_its_stored_credentials(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
server_url = "https://mcp.example.com/mcp"
|
||||||
|
custom_mcp_action(
|
||||||
|
"custom",
|
||||||
|
{
|
||||||
|
"name": ["company-mcp"],
|
||||||
|
"transport": ["streamableHttp"],
|
||||||
|
"url": [server_url],
|
||||||
|
"auth": ["oauth"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await MCPOAuthStorage("company-mcp", server_url).set_tokens(
|
||||||
|
OAuthToken(access_token="secret")
|
||||||
|
)
|
||||||
|
assert mcp_oauth_has_credentials("company-mcp", server_url)
|
||||||
|
|
||||||
|
custom_mcp_action(
|
||||||
|
"custom",
|
||||||
|
{
|
||||||
|
"name": ["company-mcp"],
|
||||||
|
"transport": ["streamableHttp"],
|
||||||
|
"url": [server_url],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not mcp_oauth_has_credentials("company-mcp", server_url)
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
|
|||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
_clear_webui_oauth_flows,
|
|
||||||
_docs_version,
|
_docs_version,
|
||||||
_model_catalog_kind,
|
_model_catalog_kind,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
@@ -36,11 +35,17 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||||
|
|
||||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oauth_flows() -> WebUIOAuthFlowRegistry:
|
||||||
|
return WebUIOAuthFlowRegistry()
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_propagates_preset_resolution_failure(
|
def test_settings_payload_propagates_preset_resolution_failure(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1490,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login(
|
|||||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -1522,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
fake_start,
|
fake_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
payload = login_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
@@ -1548,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda: {"settings": "ready"},
|
lambda **_kwargs: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
assert pending == {
|
||||||
@@ -1574,6 +1585,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1599,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
{"provider": ["openai-codex"], "remote_browser": ["true"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_clear_webui_oauth_flows("openai_codex")
|
oauth_flows.clear("openai_codex")
|
||||||
|
|
||||||
assert payload["completion_input"] == "callback_url"
|
assert payload["completion_input"] == "callback_url"
|
||||||
assert captured["open_browser"] is False
|
assert captured["open_browser"] is False
|
||||||
@@ -1611,6 +1624,7 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
|||||||
|
|
||||||
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
real_import = builtins.__import__
|
||||||
|
|
||||||
@@ -1622,7 +1636,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
with pytest.raises(WebUISettingsError) as exc:
|
||||||
login_oauth_provider({"provider": ["openai-codex"]})
|
login_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||||
@@ -1632,6 +1649,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
|
|
||||||
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
real_import = builtins.__import__
|
||||||
|
|
||||||
@@ -1643,7 +1661,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
with pytest.raises(WebUISettingsError) as exc:
|
||||||
login_oauth_provider({"provider": ["github-copilot"]})
|
login_oauth_provider(
|
||||||
|
{"provider": ["github-copilot"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"This nanobot installation is missing the required oauth-cli-kit package. "
|
||||||
@@ -1654,6 +1675,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
|||||||
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -1675,7 +1697,10 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||||
|
|
||||||
payload = login_oauth_provider({"provider": ["xai-grok"]})
|
payload = login_oauth_provider(
|
||||||
|
{"provider": ["xai-grok"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert captured["proxy"] == proxy
|
assert captured["proxy"] == proxy
|
||||||
assert captured["timeout_s"] == 600
|
assert captured["timeout_s"] == 600
|
||||||
@@ -1699,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda: {"settings": "ready"},
|
lambda **_kwargs: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
"secret",
|
"secret",
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
assert pending == {
|
||||||
@@ -1722,6 +1749,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1734,7 +1762,10 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
|||||||
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
with pytest.raises(WebUISettingsError) as exc:
|
||||||
login_oauth_provider({"provider": ["xai-grok"]})
|
login_oauth_provider(
|
||||||
|
{"provider": ["xai-grok"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert exc.value.status == 502
|
assert exc.value.status == 502
|
||||||
assert str(exc.value) == (
|
assert str(exc.value) == (
|
||||||
@@ -1746,6 +1777,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
|||||||
def test_xai_grok_logout_removes_token_through_shared_lock(
|
def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1759,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
|||||||
lambda: token_path,
|
lambda: token_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
logout_oauth_provider({"provider": ["xai-grok"]})
|
logout_oauth_provider(
|
||||||
|
{"provider": ["xai-grok"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert not token_path.exists()
|
assert not token_path.exists()
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import ANY, AsyncMock, MagicMock
|
||||||
from urllib.parse import parse_qs, urlsplit
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from websockets.datastructures import Headers
|
from websockets.datastructures import Headers
|
||||||
|
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
from nanobot.webui.http_utils import http_json_response
|
from nanobot.webui.http_utils import http_json_response
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
|
||||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||||
return WebUISettingsRouter(
|
return WebUISettingsRouter(
|
||||||
|
settings=WebUISettingsServices.create(get_config_path()),
|
||||||
bus=SimpleNamespace(),
|
bus=SimpleNamespace(),
|
||||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||||
check_api_token=lambda _request: authorized,
|
check_api_token=lambda _request: authorized,
|
||||||
@@ -24,30 +28,158 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
|||||||
),
|
),
|
||||||
runtime_surface="browser",
|
runtime_surface="browser",
|
||||||
runtime_capabilities={},
|
runtime_capabilities={},
|
||||||
|
mcp_oauth_redirect_uri=lambda _request: "https://gateway.example/auth/mcp/callback",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace:
|
||||||
|
request = SimpleNamespace(path=path, headers=Headers())
|
||||||
|
request._nanobot_webui_mutation_request = True
|
||||||
|
request._nanobot_webui_mutation_payload = payload
|
||||||
|
request._nanobot_trusted_proxy_authenticated = True
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None:
|
||||||
|
config = SimpleNamespace(
|
||||||
|
type="streamableHttp",
|
||||||
|
auth="oauth",
|
||||||
|
url="https://app.xmind.com/api/mcp",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.settings_routes.ensure_mcp_oauth_server",
|
||||||
|
lambda _query, *, config_path=None: ("xmind", config),
|
||||||
|
)
|
||||||
|
router = _router()
|
||||||
|
start = AsyncMock(return_value={
|
||||||
|
"status": "authorization_required",
|
||||||
|
"flow_id": "flow-123",
|
||||||
|
"name": "xmind",
|
||||||
|
"authorization_url": "https://xmind.example/authorize?state=state-123",
|
||||||
|
})
|
||||||
|
router._mcp_oauth = SimpleNamespace(start=start)
|
||||||
|
request = _mutation_request(
|
||||||
|
"/api/settings/mcp-oauth/start",
|
||||||
|
{"name": "xmind"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.body)["flow_id"] == "flow-123"
|
||||||
|
start.assert_awaited_once_with(
|
||||||
|
"xmind",
|
||||||
|
config,
|
||||||
|
"https://gateway.example/auth/mcp/callback",
|
||||||
|
reload_mcp=ANY,
|
||||||
|
reset_credentials=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
denied = _router(authorized=False)
|
||||||
|
denied_response = await denied.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||||
|
assert denied_response is not None
|
||||||
|
assert denied_response.status_code == 401
|
||||||
|
|
||||||
|
failed = _router()
|
||||||
|
failed._mcp_oauth = SimpleNamespace(
|
||||||
|
start=AsyncMock(side_effect=RuntimeError("upstream secret response"))
|
||||||
|
)
|
||||||
|
failed_response = await failed.dispatch(None, request, "/api/settings/mcp-oauth/start")
|
||||||
|
assert failed_response is not None
|
||||||
|
assert failed_response.status_code == 500
|
||||||
|
assert json.loads(failed_response.body) == {"error": "MCP OAuth start failed"}
|
||||||
|
assert b"upstream secret response" not in failed_response.body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_oauth_callback_is_state_authenticated_and_returns_close_page() -> None:
|
||||||
|
router = _router(authorized=False)
|
||||||
|
submit = MagicMock(return_value="xmind")
|
||||||
|
router._mcp_oauth = SimpleNamespace(submit_callback=submit)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
path="/auth/mcp/callback?code=oauth-code&state=state-123",
|
||||||
|
headers=Headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await router.dispatch(None, request, "/auth/mcp/callback")
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
|
||||||
|
assert response.headers["Cache-Control"] == "no-store"
|
||||||
|
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
|
||||||
|
assert b"window.close" in response.body
|
||||||
|
assert b"Authorization received" in response.body
|
||||||
|
assert b"oauth-code" not in response.body
|
||||||
|
submit.assert_called_once_with(state="state-123", code="oauth-code", error=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_oauth_manual_completion_reads_websocket_payload() -> None:
|
||||||
|
callback_url = (
|
||||||
|
"http://127.0.0.1:8765/auth/mcp/callback?code=oauth-code&state=state-123"
|
||||||
|
)
|
||||||
|
router = _router()
|
||||||
|
submit = MagicMock(
|
||||||
|
return_value={
|
||||||
|
"flow_id": "flow-123",
|
||||||
|
"name": "linear",
|
||||||
|
"status": "connecting",
|
||||||
|
"expires_in": 299,
|
||||||
|
"completion_input": "callback_url",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
router._mcp_oauth = SimpleNamespace(submit_callback_url=submit)
|
||||||
|
request = _mutation_request(
|
||||||
|
"/api/settings/mcp-oauth/complete",
|
||||||
|
{"flow_id": "flow-123", "callback_url": callback_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await router.dispatch(None, request, "/api/settings/mcp-oauth/complete")
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert json.loads(response.body)["status"] == "connecting"
|
||||||
|
assert b"oauth-code" not in response.body
|
||||||
|
submit.assert_called_once_with(flow_id="flow-123", callback_url=callback_url)
|
||||||
|
|
||||||
|
denied = _router(authorized=False)
|
||||||
|
denied_response = await denied.dispatch(
|
||||||
|
None,
|
||||||
|
request,
|
||||||
|
"/api/settings/mcp-oauth/complete",
|
||||||
|
)
|
||||||
|
assert denied_response is not None
|
||||||
|
assert denied_response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("provider", "header_name", "authorization_response"),
|
("provider", "authorization_response"),
|
||||||
[
|
[
|
||||||
("xai_grok", "X-Nanobot-OAuth-Code", "secret"),
|
("xai_grok", "secret"),
|
||||||
(
|
(
|
||||||
"openai_codex",
|
"openai_codex",
|
||||||
"X-Nanobot-OAuth-Callback",
|
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_oauth_completion_reads_private_response_header(
|
async def test_oauth_completion_reads_websocket_payload(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
provider: str,
|
provider: str,
|
||||||
header_name: str,
|
|
||||||
authorization_response: str,
|
authorization_response: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def complete(query, authorization_response=None):
|
def complete(
|
||||||
|
query,
|
||||||
|
authorization_response=None,
|
||||||
|
*,
|
||||||
|
oauth_flows=None,
|
||||||
|
config_path=None,
|
||||||
|
):
|
||||||
captured.update(query=query, authorization_response=authorization_response)
|
captured.update(query=query, authorization_response=authorization_response)
|
||||||
return {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@@ -57,19 +189,13 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
|
||||||
router = _router()
|
router = _router()
|
||||||
request = SimpleNamespace(
|
request = _mutation_request(
|
||||||
path=(
|
"/api/settings/provider/oauth-login/complete",
|
||||||
"/api/settings/provider/oauth-login/complete"
|
{
|
||||||
f"?provider={provider}&flow_id=flow-123"
|
"provider": provider,
|
||||||
),
|
"flow_id": "flow-123",
|
||||||
headers=Headers(
|
"authorization_response": authorization_response,
|
||||||
[
|
},
|
||||||
(
|
|
||||||
header_name,
|
|
||||||
authorization_response,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await router.dispatch(
|
response = await router.dispatch(
|
||||||
@@ -89,28 +215,29 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
"query": {"provider": [provider], "flow_id": ["flow-123"]},
|
||||||
"authorization_response": authorization_response,
|
"authorization_response": authorization_response,
|
||||||
}
|
}
|
||||||
assert authorization_response not in request.path
|
assert request.path == "/api/settings/provider/oauth-login/complete"
|
||||||
|
assert not request.headers
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("request_path", "route_path", "function_name", "expected_query"),
|
("route_path", "function_name", "payload", "expected_query"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
"/api/settings/model-configurations/delete?name=spare",
|
|
||||||
"/api/settings/model-configurations/delete",
|
"/api/settings/model-configurations/delete",
|
||||||
"delete_model_configuration",
|
"delete_model_configuration",
|
||||||
|
{"name": "spare"},
|
||||||
{"name": ["spare"]},
|
{"name": ["spare"]},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"/api/settings/model-configurations/migrate",
|
|
||||||
"/api/settings/model-configurations/migrate",
|
"/api/settings/model-configurations/migrate",
|
||||||
"migrate_model_configurations",
|
"migrate_model_configurations",
|
||||||
{},
|
{},
|
||||||
|
{},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
|
|
||||||
"/api/settings/model-call-order/update",
|
"/api/settings/model-call-order/update",
|
||||||
"update_model_call_order",
|
"update_model_call_order",
|
||||||
|
{"order": ["backup"]},
|
||||||
{"order": ['["backup"]']},
|
{"order": ['["backup"]']},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -118,19 +245,19 @@ async def test_oauth_completion_reads_private_response_header(
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_model_preset_mutation_routes(
|
async def test_model_preset_mutation_routes(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
request_path: str,
|
|
||||||
route_path: str,
|
route_path: str,
|
||||||
function_name: str,
|
function_name: str,
|
||||||
|
payload: dict[str, object],
|
||||||
expected_query: dict[str, list[str]],
|
expected_query: dict[str, list[str]],
|
||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def mutate(query):
|
def mutate(query, *, config_path=None):
|
||||||
captured["query"] = query
|
captured["query"] = query
|
||||||
return {"routed": function_name}
|
return {"routed": function_name}
|
||||||
|
|
||||||
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate)
|
||||||
request = SimpleNamespace(path=request_path, headers=Headers())
|
request = _mutation_request(route_path, payload)
|
||||||
|
|
||||||
response = await _router().dispatch(None, request, route_path)
|
response = await _router().dispatch(None, request, route_path)
|
||||||
|
|
||||||
@@ -138,3 +265,79 @@ async def test_model_preset_mutation_routes(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.body)["routed"] == function_name
|
assert json.loads(response.body)["routed"] == function_name
|
||||||
assert captured["query"] == expected_query
|
assert captured["query"] == expected_query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settings_get_mutation_route_is_method_not_allowed() -> None:
|
||||||
|
path = "/api/settings/provider/update"
|
||||||
|
request = SimpleNamespace(
|
||||||
|
path=f"{path}?provider=openrouter&api_key=must-not-run",
|
||||||
|
headers=Headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await _router().dispatch(None, request, path)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 405
|
||||||
|
assert json.loads(response.body) == {
|
||||||
|
"error": "WebUI mutations require an authenticated WebSocket"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("update_info", "expected"),
|
||||||
|
[
|
||||||
|
(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()
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.gateway_services import build_gateway_services
|
||||||
|
from nanobot.webui.settings_api import settings_payload, update_agent_settings, update_api_settings
|
||||||
|
from nanobot.webui.settings_services import (
|
||||||
|
WebUIOAuthFlowRegistry,
|
||||||
|
WebUISettingsServices,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Flow:
|
||||||
|
def __init__(self, *, expired: bool = False) -> None:
|
||||||
|
self.expired = expired
|
||||||
|
self.cancel_count = 0
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
self.cancel_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _gateway(config_path: Path, workspace: Path):
|
||||||
|
return build_gateway_services(
|
||||||
|
config=WebSocketConfig(),
|
||||||
|
bus=MagicMock(),
|
||||||
|
session_manager=None,
|
||||||
|
static_dist_path=None,
|
||||||
|
workspace_path=workspace,
|
||||||
|
default_restrict_to_workspace=False,
|
||||||
|
config_path=config_path,
|
||||||
|
runtime_model_name=None,
|
||||||
|
runtime_surface="browser",
|
||||||
|
runtime_capabilities_overrides=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_settings_services_isolate_config_paths_and_oauth_flows(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
first_path = tmp_path / "first" / "config.json"
|
||||||
|
second_path = tmp_path / "second" / "config.json"
|
||||||
|
first_config = Config()
|
||||||
|
first_config.api.host = "127.0.0.2"
|
||||||
|
second_config = Config()
|
||||||
|
second_config.api.host = "127.0.0.3"
|
||||||
|
save_config(first_config, first_path)
|
||||||
|
save_config(second_config, second_path)
|
||||||
|
|
||||||
|
first = _gateway(first_path, tmp_path / "first-workspace")
|
||||||
|
second = _gateway(second_path, tmp_path / "second-workspace")
|
||||||
|
|
||||||
|
assert first.settings.config.path == first_path.resolve()
|
||||||
|
assert second.settings.config.path == second_path.resolve()
|
||||||
|
assert first.http.settings_routes.settings is first.settings
|
||||||
|
assert second.http.settings_routes.settings is second.settings
|
||||||
|
assert first.settings.config.load().api.host == "127.0.0.2"
|
||||||
|
assert second.settings.config.load().api.host == "127.0.0.3"
|
||||||
|
assert first.settings.read(settings_payload)["api"]["host"] == "127.0.0.2"
|
||||||
|
assert second.settings.read(settings_payload)["api"]["host"] == "127.0.0.3"
|
||||||
|
|
||||||
|
first.settings.mutate(update_api_settings, {"port": ["19001"]})
|
||||||
|
assert load_config(first_path).api.port == 19001
|
||||||
|
assert load_config(second_path).api.port != 19001
|
||||||
|
|
||||||
|
first_flow = _Flow()
|
||||||
|
second_flow = _Flow()
|
||||||
|
first.settings.oauth_flows.register("openai_codex", "same-id", first_flow)
|
||||||
|
second.settings.oauth_flows.register("openai_codex", "same-id", second_flow)
|
||||||
|
|
||||||
|
assert first.settings.oauth_flows.get("openai_codex", "same-id") is first_flow
|
||||||
|
assert second.settings.oauth_flows.get("openai_codex", "same-id") is second_flow
|
||||||
|
first.settings.oauth_flows.clear("openai_codex")
|
||||||
|
assert first_flow.cancel_count == 1
|
||||||
|
assert second_flow.cancel_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_mutations_serialize_read_modify_write(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
services = WebUISettingsServices.create(config_path)
|
||||||
|
first_loaded = threading.Event()
|
||||||
|
release_first = threading.Event()
|
||||||
|
second_started = threading.Event()
|
||||||
|
second_loaded = threading.Event()
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
|
from nanobot.webui import settings_api
|
||||||
|
|
||||||
|
original_load = settings_api._load_settings_config
|
||||||
|
|
||||||
|
def controlled_load(path: Path | None) -> Config:
|
||||||
|
config = original_load(path)
|
||||||
|
if threading.current_thread().name == "settings-first":
|
||||||
|
first_loaded.set()
|
||||||
|
if not release_first.wait(timeout=2):
|
||||||
|
raise TimeoutError("timed out waiting to release first settings mutation")
|
||||||
|
elif threading.current_thread().name == "settings-second":
|
||||||
|
second_loaded.set()
|
||||||
|
return config
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load)
|
||||||
|
|
||||||
|
def run_first() -> None:
|
||||||
|
try:
|
||||||
|
services.mutate(update_agent_settings, {"timezone": ["Asia/Tokyo"]})
|
||||||
|
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
def run_second() -> None:
|
||||||
|
try:
|
||||||
|
second_started.set()
|
||||||
|
services.mutate(update_api_settings, {"host": ["127.0.0.9"]})
|
||||||
|
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
first = threading.Thread(target=run_first, name="settings-first")
|
||||||
|
second = threading.Thread(target=run_second, name="settings-second")
|
||||||
|
first.start()
|
||||||
|
assert first_loaded.wait(timeout=2)
|
||||||
|
second.start()
|
||||||
|
assert second_started.wait(timeout=2)
|
||||||
|
assert not second_loaded.wait(timeout=0.1)
|
||||||
|
release_first.set()
|
||||||
|
first.join(timeout=2)
|
||||||
|
second.join(timeout=2)
|
||||||
|
|
||||||
|
assert not first.is_alive()
|
||||||
|
assert not second.is_alive()
|
||||||
|
assert not errors
|
||||||
|
saved = load_config(config_path)
|
||||||
|
assert saved.agents.defaults.timezone == "Asia/Tokyo"
|
||||||
|
assert saved.api.host == "127.0.0.9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None:
|
||||||
|
registry = WebUIOAuthFlowRegistry(max_flows=2)
|
||||||
|
expired = _Flow(expired=True)
|
||||||
|
oldest = _Flow()
|
||||||
|
newest = _Flow()
|
||||||
|
replacement = _Flow()
|
||||||
|
|
||||||
|
registry.register("openai_codex", "expired", expired)
|
||||||
|
registry.register("openai_codex", "oldest", oldest)
|
||||||
|
assert expired.cancel_count == 1
|
||||||
|
assert registry.get("openai_codex", "expired") is None
|
||||||
|
|
||||||
|
registry.register("xai_grok", "newest", newest)
|
||||||
|
registry.register("openai_codex", "replacement", replacement)
|
||||||
|
assert oldest.cancel_count == 1
|
||||||
|
assert registry.get("openai_codex", "oldest") is None
|
||||||
|
assert registry.get("xai_grok", "newest") is newest
|
||||||
|
assert registry.get("openai_codex", "newest") is None
|
||||||
|
|
||||||
|
registry.remove("xai_grok", "newest", newest, cancel=False)
|
||||||
|
assert newest.cancel_count == 0
|
||||||
|
assert registry.get("xai_grok", "newest") is None
|
||||||
|
|
||||||
|
registry.clear("openai_codex")
|
||||||
|
assert replacement.cancel_count == 1
|
||||||
|
assert registry.get("openai_codex", "replacement") is None
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from websockets.datastructures import Headers
|
||||||
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
|
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||||
|
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(config: WebSocketConfig) -> GatewayHTTPHandler:
|
||||||
|
handler = object.__new__(GatewayHTTPHandler)
|
||||||
|
handler.config = config
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _request(**headers: str) -> WsRequest:
|
||||||
|
return cast(WsRequest, SimpleNamespace(headers=Headers(headers)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_oauth_callback_uses_configured_public_websocket_origin() -> None:
|
||||||
|
handler = _handler(WebSocketConfig(path="/ws", public_ws_url="wss://agent.example/ws"))
|
||||||
|
|
||||||
|
redirect_uri = handler._mcp_oauth_redirect_uri(_request(Host="ignored.example"))
|
||||||
|
|
||||||
|
assert redirect_uri == "https://agent.example/auth/mcp/callback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_oauth_callback_uses_safe_forwarded_request_origin() -> None:
|
||||||
|
handler = _handler(WebSocketConfig(path="/ws", host="127.0.0.1", port=8765))
|
||||||
|
|
||||||
|
redirect_uri = handler._mcp_oauth_redirect_uri(
|
||||||
|
_request(Host="nanobot.example:9443", **{"X-Forwarded-Proto": "https"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert redirect_uri == "https://nanobot.example:9443/auth/mcp/callback"
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -741,6 +756,8 @@
|
|||||||
|
|
||||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
"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=="],
|
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||||
|
|
||||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||||
@@ -845,6 +868,16 @@
|
|||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||||
@@ -1163,6 +1206,8 @@
|
|||||||
|
|
||||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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": ["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=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
"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=="],
|
"@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-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=="],
|
"@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=="],
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"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=="],
|
"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-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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|||||||
Generated
+532
@@ -44,6 +44,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -75,6 +76,20 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@antfu/install-pkg": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||||
@@ -348,6 +363,13 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@braintree/sanitize-url": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||||
@@ -990,6 +1012,119 @@
|
|||||||
"import-meta-resolve": "^4.2.0"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -1071,6 +1206,17 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -3003,6 +3149,39 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4247,6 +4426,13 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.340",
|
"version": "1.5.340",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4703,6 +4889,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/format": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4768,6 +4971,27 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4779,6 +5003,39 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/globals": {
|
||||||
"version": "17.6.0",
|
"version": "17.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
||||||
@@ -4810,6 +5067,16 @@
|
|||||||
"node": ">=18.0.0"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5143,6 +5410,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "CC0-1.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": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
@@ -5378,6 +5652,76 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "1.21.7",
|
"version": "1.21.7",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5587,6 +5931,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/markdown-table": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6573,6 +6958,16 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -6699,6 +7094,13 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
||||||
@@ -6761,6 +7163,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pathe": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7619,6 +8045,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7697,6 +8136,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -7729,6 +8184,20 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/strip-indent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7789,6 +8258,19 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7864,6 +8346,21 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8521,6 +9018,41 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "tsc -p tsconfig.build.json && vite build",
|
"build": "tsc -p tsconfig.build.json && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0"
|
"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-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "2.1.9",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
|||||||
+57
-17
@@ -8,7 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
@@ -316,13 +316,23 @@ function AuthForm({
|
|||||||
onSecret: (secret: string) => void;
|
onSecret: (secret: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [validationError, setValidationError] = useState<"required" | "invalid" | null>(
|
||||||
|
failed ? "invalid" : null,
|
||||||
|
);
|
||||||
|
const errorMessage = validationError ? t(`app.auth.${validationError}`) : null;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const secret = value.trim();
|
const secret = value.trim();
|
||||||
if (!secret) return;
|
if (!secret) {
|
||||||
|
setValidationError("required");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
onSecret(secret);
|
onSecret(secret);
|
||||||
};
|
};
|
||||||
@@ -333,27 +343,57 @@ function AuthForm({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex w-full max-w-sm flex-col gap-4"
|
className="flex w-full max-w-sm flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1 text-center">
|
<div className="space-y-2">
|
||||||
<p className="text-lg font-semibold">{t("app.auth.title")}</p>
|
<h1 className="text-sm font-medium text-foreground">
|
||||||
<p className="text-sm text-muted-foreground">{t("app.auth.hint")}</p>
|
<label htmlFor="webui-access-password">{t("app.auth.label")}</label>
|
||||||
</div>
|
</h1>
|
||||||
{failed && (
|
<div className="relative">
|
||||||
<p className="text-center text-sm text-destructive">
|
|
||||||
{t("app.auth.invalid")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
ref={inputRef}
|
||||||
placeholder={t("app.auth.placeholder")}
|
id="webui-access-password"
|
||||||
|
name="webui-access-password"
|
||||||
|
type={passwordVisible ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
aria-invalid={validationError ? true : undefined}
|
||||||
|
aria-describedby={validationError ? "webui-auth-error" : undefined}
|
||||||
|
className="pr-10 focus-visible:ring-1 focus-visible:ring-ring/30 focus-visible:ring-offset-0"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={submitting}
|
||||||
|
aria-label={t(
|
||||||
|
passwordVisible ? "app.auth.hidePassword" : "app.auth.showPassword",
|
||||||
|
)}
|
||||||
|
aria-controls="webui-access-password"
|
||||||
|
onClick={() => setPasswordVisible((visible) => !visible)}
|
||||||
|
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{passwordVisible ? (
|
||||||
|
<EyeOff className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" strokeWidth={1.75} aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errorMessage ? (
|
||||||
|
<p id="webui-auth-error" role="alert" className="text-sm text-destructive">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!value.trim() || submitting}
|
disabled={submitting}
|
||||||
>
|
>
|
||||||
{t("app.auth.submit")}
|
{t("app.auth.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2041,7 +2081,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(getToken(), action, code);
|
const payload = await runPairingAction(client, action, code);
|
||||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
setSnoozedPairingCodes((current) => {
|
setSnoozedPairingCodes((current) => {
|
||||||
if (!current.has(code)) return current;
|
if (!current.has(code)) return current;
|
||||||
@@ -2056,7 +2096,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getToken, refreshPairingRequests],
|
[client, refreshPairingRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDismissPairingRequest = useCallback((code: string) => {
|
const onDismissPairingRequest = useCallback((code: string) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { getToken } = useClient();
|
const { client, getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -321,7 +321,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@@ -345,7 +345,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await deleteSkill(getToken(), activeSkill.name);
|
const payload = await deleteSkill(client, activeSkill.name);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
|
|||||||
installing: string;
|
installing: string;
|
||||||
onInstallingChange: (skillId: string) => void;
|
onInstallingChange: (skillId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { getToken } = useClient();
|
const { client, getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||||
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(
|
const payload = await installMarketplaceSkill(
|
||||||
getToken(),
|
client,
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export type ChannelInstancesPanelCustomization = {
|
export type ChannelInstancesPanelCustomization = {
|
||||||
countLabel?: (runningCount: number) => string;
|
countLabel?: (runningCount: number) => string;
|
||||||
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelInstancesPanel({
|
export function ChannelInstancesPanel({
|
||||||
token,
|
|
||||||
feature,
|
feature,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
chatAppsDocsUrl,
|
chatAppsDocsUrl,
|
||||||
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
customization = {},
|
customization = {},
|
||||||
}: {
|
}: {
|
||||||
token: string;
|
|
||||||
feature: NanobotFeatureInfo;
|
feature: NanobotFeatureInfo;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
chatAppsDocsUrl?: string;
|
chatAppsDocsUrl?: string;
|
||||||
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
|
|||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
customization?: ChannelInstancesPanelCustomization;
|
customization?: ChannelInstancesPanelCustomization;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const displayName = localizedChannelDisplayName(feature, t);
|
const displayName = localizedChannelDisplayName(feature, t);
|
||||||
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = checked
|
const payload = checked
|
||||||
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id })
|
? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
|
||||||
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id });
|
: await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
|
||||||
onFeaturesUpdate(payload);
|
onFeaturesUpdate(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setNotice((err as Error).message);
|
setNotice((err as Error).message);
|
||||||
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSave(instanceFields, fieldValues),
|
channelValuesForSave(instanceFields, fieldValues),
|
||||||
{ enable: selected.enabled, instanceId: selected.id },
|
{ enable: selected.enabled, instanceId: selected.id },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ChannelConnectPayload,
|
ChannelConnectPayload,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export type ChannelQrConnectLabels = {
|
export type ChannelQrConnectLabels = {
|
||||||
qrAlt: string;
|
qrAlt: string;
|
||||||
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelQrConnectFlow({
|
export function ChannelQrConnectFlow({
|
||||||
token,
|
|
||||||
channelName,
|
channelName,
|
||||||
startOptions = {},
|
startOptions = {},
|
||||||
idleLabel,
|
idleLabel,
|
||||||
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
|
|||||||
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
|
||||||
suppressSucceeded?: boolean;
|
suppressSucceeded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
@@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||||
const pollInFlight = useRef(false);
|
const pollInFlight = useRef(false);
|
||||||
const tokenRef = useRef(token);
|
|
||||||
tokenRef.current = token;
|
|
||||||
const startDomain = startOptions.domain;
|
const startDomain = startOptions.domain;
|
||||||
const startInstanceId = startOptions.instanceId;
|
const startInstanceId = startOptions.instanceId;
|
||||||
const startMode = startOptions.mode;
|
const startMode = startOptions.mode;
|
||||||
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
|
|||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
|
|||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
channelName,
|
channelName,
|
||||||
|
client,
|
||||||
connect?.interval_ms,
|
connect?.interval_ms,
|
||||||
connect?.session_id,
|
connect?.session_id,
|
||||||
connect?.status,
|
connect?.status,
|
||||||
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
const payload = await startChannelConnect(client, channelName, {
|
||||||
domain: startDomain,
|
domain: startDomain,
|
||||||
instanceId: startInstanceId,
|
instanceId: startInstanceId,
|
||||||
mode: startMode,
|
mode: startMode,
|
||||||
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
}, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||||
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payload = await cancelChannelConnect(
|
const payload = await cancelChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
);
|
);
|
||||||
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(
|
const payload = await pollChannelConnect(
|
||||||
tokenRef.current,
|
client,
|
||||||
channelName,
|
channelName,
|
||||||
connect.session_id,
|
connect.session_id,
|
||||||
"",
|
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import type {
|
|||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
export function ChannelCatalogRow({
|
export function ChannelCatalogRow({
|
||||||
feature,
|
feature,
|
||||||
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
|
|||||||
if (feature.instances !== undefined) {
|
if (feature.instances !== undefined) {
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
token={token}
|
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
|
|||||||
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { client } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||||
try {
|
try {
|
||||||
const validationPayload = await validateChannel(token, feature.name, values);
|
const validationPayload = await validateChannel(client, feature.name, values);
|
||||||
setValidation(validationPayload);
|
setValidation(validationPayload);
|
||||||
if (!validationPayload.can_enable) {
|
if (!validationPayload.can_enable) {
|
||||||
setNotice(
|
setNotice(
|
||||||
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = await configureChannel(
|
const payload = await configureChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
values,
|
values,
|
||||||
{ enable: true },
|
{ enable: true },
|
||||||
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
try {
|
try {
|
||||||
const payload = await validateChannel(
|
const payload = await validateChannel(
|
||||||
token,
|
client,
|
||||||
feature.name,
|
feature.name,
|
||||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -179,7 +179,11 @@ function getVoiceShortcutLabel(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadComposerProps {
|
interface ThreadComposerProps {
|
||||||
onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void;
|
onSend: (
|
||||||
|
content: string,
|
||||||
|
images?: SendAttachment[],
|
||||||
|
options?: SendOptions,
|
||||||
|
) => boolean | void | Promise<boolean | void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
@@ -981,6 +985,8 @@ export function ThreadComposer({
|
|||||||
end: number;
|
end: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||||
|
const [sendPending, setSendPending] = useState(false);
|
||||||
|
const interactionDisabled = !!disabled || sendPending;
|
||||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||||
@@ -1071,7 +1077,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (interactionDisabled || files.length === 0) return;
|
||||||
secondEnterPromptIdRef.current = null;
|
secondEnterPromptIdRef.current = null;
|
||||||
const { rejected } = enqueue(files);
|
const { rejected } = enqueue(files);
|
||||||
if (rejected.length > 0) {
|
if (rejected.length > 0) {
|
||||||
@@ -1080,7 +1086,7 @@ export function ThreadComposer({
|
|||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueue, formatRejection],
|
[enqueue, formatRejection, interactionDisabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -1093,18 +1099,20 @@ export function ThreadComposer({
|
|||||||
} = useClipboardAndDrop(addFiles);
|
} = useClipboardAndDrop(addFiles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (disabled || hasTouchPrimaryPointer) return;
|
if (interactionDisabled || hasTouchPrimaryPointer || (workspaceError && showProjectPicker)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const id = requestAnimationFrame(() => el.focus());
|
const id = requestAnimationFrame(() => el.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [disabled, hasTouchPrimaryPointer]);
|
}, [hasTouchPrimaryPointer, interactionDisabled, showProjectPicker, workspaceError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusRequest || disabled) return;
|
if (!focusRequest || interactionDisabled) return;
|
||||||
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
||||||
return () => cancelAnimationFrame(id);
|
return () => cancelAnimationFrame(id);
|
||||||
}, [disabled, focusRequest]);
|
}, [focusRequest, interactionDisabled]);
|
||||||
|
|
||||||
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
||||||
|
|
||||||
@@ -1118,15 +1126,17 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
|
||||||
const canSend =
|
const canSend =
|
||||||
!disabled
|
!interactionDisabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
&& hasComposerContent;
|
&& hasComposerContent;
|
||||||
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
|
const canOpenModelSettings = Boolean(
|
||||||
|
modelNeedsSetup && onModelBadgeClick && !interactionDisabled,
|
||||||
|
);
|
||||||
const canQueueGuidance =
|
const canQueueGuidance =
|
||||||
isStreaming
|
isStreaming
|
||||||
&& !disabled
|
&& !interactionDisabled
|
||||||
&& !modelNeedsSetup
|
&& !modelNeedsSetup
|
||||||
&& !encoding
|
&& !encoding
|
||||||
&& !hasErrors
|
&& !hasErrors
|
||||||
@@ -1134,14 +1144,14 @@ export function ThreadComposer({
|
|||||||
&& !value.trimStart().startsWith("/");
|
&& !value.trimStart().startsWith("/");
|
||||||
|
|
||||||
const slashQuery = useMemo(() => {
|
const slashQuery = useMemo(() => {
|
||||||
if (disabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
if (interactionDisabled || slashMenuDismissed || !value.startsWith("/")) return null;
|
||||||
const commandToken = value.slice(1);
|
const commandToken = value.slice(1);
|
||||||
if (/\s/.test(commandToken)) return null;
|
if (/\s/.test(commandToken)) return null;
|
||||||
return commandToken.toLowerCase();
|
return commandToken.toLowerCase();
|
||||||
}, [disabled, slashMenuDismissed, value]);
|
}, [interactionDisabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const skillQuery = useMemo(() => {
|
const skillQuery = useMemo(() => {
|
||||||
if (disabled || slashMenuDismissed) return null;
|
if (interactionDisabled || slashMenuDismissed) return null;
|
||||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
const beforeCaret = value.slice(0, caret);
|
const beforeCaret = value.slice(0, caret);
|
||||||
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
const match = /\$([A-Za-z0-9_-]*)$/i.exec(beforeCaret);
|
||||||
@@ -1151,7 +1161,7 @@ export function ThreadComposer({
|
|||||||
start: match.index,
|
start: match.index,
|
||||||
text: match[1].toLowerCase(),
|
text: match[1].toLowerCase(),
|
||||||
};
|
};
|
||||||
}, [cursorPosition, disabled, slashMenuDismissed, value]);
|
}, [cursorPosition, interactionDisabled, slashMenuDismissed, value]);
|
||||||
|
|
||||||
const visibleSlashCommands = useMemo(() => {
|
const visibleSlashCommands = useMemo(() => {
|
||||||
if (!(isStreaming && onStop)) return slashCommands;
|
if (!(isStreaming && onStop)) return slashCommands;
|
||||||
@@ -1279,7 +1289,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
|
||||||
if (disabled || cliAppMenuDismissed) return null;
|
if (interactionDisabled || cliAppMenuDismissed) return null;
|
||||||
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
|
||||||
const beforeCaret = value.slice(0, caret);
|
const beforeCaret = value.slice(0, caret);
|
||||||
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
const match = /(?:^|\s)@([\p{L}\p{N}_-]*)$/iu.exec(beforeCaret);
|
||||||
@@ -1290,7 +1300,7 @@ export function ThreadComposer({
|
|||||||
start: caret - query.length - 1,
|
start: caret - query.length - 1,
|
||||||
end: caret,
|
end: caret,
|
||||||
};
|
};
|
||||||
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
|
}, [cliAppMenuDismissed, cursorPosition, interactionDisabled, value]);
|
||||||
|
|
||||||
const availableSessionMentions = useMemo(
|
const availableSessionMentions = useMemo(
|
||||||
() => sessionMentionOptions(
|
() => sessionMentionOptions(
|
||||||
@@ -1580,7 +1590,7 @@ export function ThreadComposer({
|
|||||||
}, VOICE_ERROR_VISIBLE_MS);
|
}, VOICE_ERROR_VISIBLE_MS);
|
||||||
}, [clearVoiceErrorTimers, t]);
|
}, [clearVoiceErrorTimers, t]);
|
||||||
const voiceRecorder = useVoiceRecorder({
|
const voiceRecorder = useVoiceRecorder({
|
||||||
disabled,
|
disabled: interactionDisabled,
|
||||||
onClearError: clearInlineError,
|
onClearError: clearInlineError,
|
||||||
onError: setVoiceError,
|
onError: setVoiceError,
|
||||||
onTranscript: appendTranscription,
|
onTranscript: appendTranscription,
|
||||||
@@ -1714,7 +1724,7 @@ export function ThreadComposer({
|
|||||||
clearDraggedSession();
|
clearDraggedSession();
|
||||||
const preview = sessionDragPreview;
|
const preview = sessionDragPreview;
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
if (disabled) return true;
|
if (interactionDisabled) return true;
|
||||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||||
const mention = availableSessionMentions.find(
|
const mention = availableSessionMentions.find(
|
||||||
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
(candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
|
||||||
@@ -1732,11 +1742,17 @@ export function ThreadComposer({
|
|||||||
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]);
|
}, [
|
||||||
|
availableSessionMentions,
|
||||||
|
insertMentionCandidate,
|
||||||
|
interactionDisabled,
|
||||||
|
sessionDragPreview,
|
||||||
|
value.length,
|
||||||
|
]);
|
||||||
|
|
||||||
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
const previewSessionDrop = useCallback((event: React.DragEvent) => {
|
||||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||||
if (disabled) {
|
if (interactionDisabled) {
|
||||||
event.dataTransfer.dropEffect = "none";
|
event.dataTransfer.dropEffect = "none";
|
||||||
setSessionDragPreview(null);
|
setSessionDragPreview(null);
|
||||||
return true;
|
return true;
|
||||||
@@ -1765,7 +1781,7 @@ export function ThreadComposer({
|
|||||||
: { mention, start, end }
|
: { mention, start, end }
|
||||||
));
|
));
|
||||||
return true;
|
return true;
|
||||||
}, [activeSessionMentions, availableSessionMentions, disabled, value.length]);
|
}, [activeSessionMentions, availableSessionMentions, interactionDisabled, value.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionDragPreview) return;
|
if (!sessionDragPreview) return;
|
||||||
@@ -2008,7 +2024,16 @@ export function ThreadComposer({
|
|||||||
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
||||||
const finalizeActiveTurn =
|
const finalizeActiveTurn =
|
||||||
slashLifecycle === "finalize_active_turn";
|
slashLifecycle === "finalize_active_turn";
|
||||||
onSend(
|
const finishSend = () => {
|
||||||
|
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
||||||
|
setQueuedPrompts([]);
|
||||||
|
// Bubble owns the data URL copy; safe to revoke every staged blob
|
||||||
|
// preview here without affecting the rendered message.
|
||||||
|
clear();
|
||||||
|
clearComposerText(!hasTouchPrimaryPointer);
|
||||||
|
onQuotedContextChange?.(null);
|
||||||
|
};
|
||||||
|
const result = onSend(
|
||||||
content,
|
content,
|
||||||
payload,
|
payload,
|
||||||
isSlashSideChannel
|
isSlashSideChannel
|
||||||
@@ -2019,13 +2044,19 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
: options,
|
: options,
|
||||||
);
|
);
|
||||||
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
|
if (result instanceof Promise) {
|
||||||
setQueuedPrompts([]);
|
setSendPending(true);
|
||||||
// Bubble owns the data URL copy; safe to revoke every staged blob
|
void result
|
||||||
// preview here without affecting the rendered message.
|
.then((accepted) => {
|
||||||
clear();
|
if (accepted !== false) finishSend();
|
||||||
clearComposerText(!hasTouchPrimaryPointer);
|
})
|
||||||
onQuotedContextChange?.(null);
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to send message", error);
|
||||||
|
})
|
||||||
|
.finally(() => setSendPending(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result !== false) finishSend();
|
||||||
}, [
|
}, [
|
||||||
activeCliMentionApps,
|
activeCliMentionApps,
|
||||||
activeMcpPresetMentions,
|
activeMcpPresetMentions,
|
||||||
@@ -2168,7 +2199,7 @@ export function ThreadComposer({
|
|||||||
[removeChip],
|
[removeChip],
|
||||||
);
|
);
|
||||||
|
|
||||||
const attachButtonDisabled = disabled || full;
|
const attachButtonDisabled = interactionDisabled || full;
|
||||||
const showVoiceButton = Boolean(onTranscribeAudio);
|
const showVoiceButton = Boolean(onTranscribeAudio);
|
||||||
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", {
|
||||||
time: voiceRecorder.elapsedLabel,
|
time: voiceRecorder.elapsedLabel,
|
||||||
@@ -2253,7 +2284,7 @@ export function ThreadComposer({
|
|||||||
isHero
|
isHero
|
||||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
disabled && "opacity-60",
|
interactionDisabled && "opacity-60",
|
||||||
sessionDragPreview && "ring-1 ring-primary/25",
|
sessionDragPreview && "ring-1 ring-primary/25",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
goalState?.active &&
|
goalState?.active &&
|
||||||
@@ -2368,7 +2399,7 @@ export function ThreadComposer({
|
|||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={disabled}
|
disabled={interactionDisabled}
|
||||||
aria-label={t("thread.composer.inputAria")}
|
aria-label={t("thread.composer.inputAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputTextClasses,
|
inputTextClasses,
|
||||||
@@ -2383,7 +2414,7 @@ export function ThreadComposer({
|
|||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(
|
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",
|
"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",
|
voiceErrorFading && "mb-0 max-h-0 border-transparent py-0 opacity-0",
|
||||||
)}
|
)}
|
||||||
@@ -2443,7 +2474,7 @@ export function ThreadComposer({
|
|||||||
) : workspaceScope && !workspaceControlsHidden ? (
|
) : workspaceScope && !workspaceControlsHidden ? (
|
||||||
<WorkspaceAccessMenu
|
<WorkspaceAccessMenu
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
disabled={disabled || workspaceScopeDisabled}
|
disabled={interactionDisabled || workspaceScopeDisabled}
|
||||||
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
canUseFullAccess={workspaceControls?.can_use_full_access !== false}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
@@ -2521,7 +2552,7 @@ export function ThreadComposer({
|
|||||||
<Button
|
<Button
|
||||||
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
|
disabled={showStopButton ? interactionDisabled : !canSend && !canOpenModelSettings}
|
||||||
aria-label={
|
aria-label={
|
||||||
showStopButton
|
showStopButton
|
||||||
? t("thread.composer.stop")
|
? t("thread.composer.stop")
|
||||||
@@ -2562,7 +2593,7 @@ export function ThreadComposer({
|
|||||||
<div className="composer-workspace-drawer-content">
|
<div className="composer-workspace-drawer-content">
|
||||||
<WorkspaceProjectPicker
|
<WorkspaceProjectPicker
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
disabled={interactionDisabled || workspaceScopeDisabled || !showProjectPicker}
|
||||||
scope={workspaceScope}
|
scope={workspaceScope}
|
||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
|
|||||||
@@ -1261,7 +1261,7 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const handleWelcomeSend = useCallback(
|
const handleWelcomeSend = useCallback(
|
||||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
if (booting) return;
|
if (booting) return false;
|
||||||
setBooting(true);
|
setBooting(true);
|
||||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
@@ -1270,12 +1270,13 @@ export function ThreadShell({
|
|||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
setPendingFirstTargetChatId(null);
|
setPendingFirstTargetChatId(null);
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (localModelPreset) {
|
if (localModelPreset) {
|
||||||
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
await client.sendSystemCommand(newId, `/model ${localModelPreset}`).catch(() => {});
|
||||||
}
|
}
|
||||||
setPendingFirstTargetChatId(newId);
|
setPendingFirstTargetChatId(newId);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
[booting, client, localModelPreset, onCreateChat, withWorkspaceScope, workspaceScope],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
import { AlertTriangle, Check, ChevronDown, Folder, Hand } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -34,6 +34,14 @@ import {
|
|||||||
shortWorkspacePath,
|
shortWorkspacePath,
|
||||||
} from "@/lib/workspace";
|
} from "@/lib/workspace";
|
||||||
|
|
||||||
|
function workspacePathPlaceholder(defaultWorkspacePath: string, macPlaceholder: string): string {
|
||||||
|
const normalized = defaultWorkspacePath.trim().replace(/\\/g, "/");
|
||||||
|
const windowsDrive = normalized.match(/^([A-Za-z]):\//)?.[1];
|
||||||
|
if (windowsDrive) return `${windowsDrive.toUpperCase()}:\\path\\to\\project`;
|
||||||
|
if (normalized.startsWith("/Users/")) return macPlaceholder;
|
||||||
|
return "/home/name/project";
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkspaceProjectPicker({
|
export function WorkspaceProjectPicker({
|
||||||
isHero,
|
isHero,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -60,6 +68,9 @@ export function WorkspaceProjectPicker({
|
|||||||
const [pathDraft, setPathDraft] = useState("");
|
const [pathDraft, setPathDraft] = useState("");
|
||||||
const [pathError, setPathError] = useState<string | null>(null);
|
const [pathError, setPathError] = useState<string | null>(null);
|
||||||
const [pickingFolder, setPickingFolder] = useState(false);
|
const [pickingFolder, setPickingFolder] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const pathErrorId = useId();
|
||||||
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
const currentProjectScope = selectedProjectScope(scope, defaultScope);
|
||||||
const projectLabel = currentProjectScope
|
const projectLabel = currentProjectScope
|
||||||
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
? currentProjectScope.project_name || projectNameFromPath(currentProjectScope.project_path)
|
||||||
@@ -82,9 +93,17 @@ export function WorkspaceProjectPicker({
|
|||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error && visible && !disabled) setOpen(true);
|
if (!error || !visible || disabled) return;
|
||||||
|
const frame = window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [disabled, error, visible]);
|
}, [disabled, error, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !error) return;
|
||||||
|
const frame = window.requestAnimationFrame(() => pathInputRef.current?.focus());
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [error, open]);
|
||||||
|
|
||||||
const applyProjectPath = useCallback(
|
const applyProjectPath = useCallback(
|
||||||
(projectPath: string, projectName?: string) => {
|
(projectPath: string, projectName?: string) => {
|
||||||
const base = scope ?? defaultScope;
|
const base = scope ?? defaultScope;
|
||||||
@@ -129,6 +148,7 @@ export function WorkspaceProjectPicker({
|
|||||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled || pickingFolder}
|
disabled={disabled || pickingFolder}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
@@ -164,6 +184,7 @@ export function WorkspaceProjectPicker({
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={t("thread.composer.workspace.projectAria")}
|
aria-label={t("thread.composer.workspace.projectAria")}
|
||||||
@@ -221,14 +242,20 @@ export function WorkspaceProjectPicker({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
ref={pathInputRef}
|
||||||
value={pathDraft}
|
value={pathDraft}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setPathDraft(event.target.value);
|
setPathDraft(event.target.value);
|
||||||
setPathError(null);
|
setPathError(null);
|
||||||
}}
|
}}
|
||||||
placeholder={t("workspace.dialog.manualPlaceholder")}
|
placeholder={workspacePathPlaceholder(
|
||||||
|
defaultScope.project_path,
|
||||||
|
t("workspace.dialog.manualPlaceholder"),
|
||||||
|
)}
|
||||||
aria-label={t("workspace.dialog.manual")}
|
aria-label={t("workspace.dialog.manual")}
|
||||||
|
aria-invalid={pathError || error ? true : undefined}
|
||||||
|
aria-describedby={pathError || error ? pathErrorId : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
"h-9 rounded-full border-border/55 bg-background/80 px-3 text-[12.5px]",
|
||||||
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
"focus-visible:ring-1 focus-visible:ring-foreground/10 focus-visible:ring-offset-0",
|
||||||
@@ -243,13 +270,22 @@ export function WorkspaceProjectPicker({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{pathError || error ? (
|
{pathError || error ? (
|
||||||
<p role="alert" className="px-1 text-[11.5px] font-medium text-destructive">
|
<p
|
||||||
|
id={pathErrorId}
|
||||||
|
role="alert"
|
||||||
|
className="px-1 text-[11.5px] font-medium text-destructive"
|
||||||
|
>
|
||||||
{pathError ?? error}
|
{pathError ?? error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{!compact && error && !open ? (
|
||||||
|
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ import {
|
|||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
|
import {
|
||||||
|
closeReasoningStream,
|
||||||
|
filterCoveredFileEditToolEvents,
|
||||||
|
finalizeStreamedTurn,
|
||||||
|
findActiveAssistantPlaceholderIndex,
|
||||||
|
findFileEditTraceIndex,
|
||||||
|
findStreamingAssistantIndex,
|
||||||
|
isReasoningOnlyPlaceholder,
|
||||||
|
matchesTurn,
|
||||||
|
mergeFileEdits,
|
||||||
|
pruneReasoningOnlyPlaceholders,
|
||||||
|
replaceMessageAt,
|
||||||
|
stampLastAssistantCompletion,
|
||||||
|
stripCoveredFileEditToolHintsFromMessages,
|
||||||
|
turnFieldsFromEvent,
|
||||||
|
} from "@/lib/thread-event-projection";
|
||||||
|
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
@@ -19,11 +36,8 @@ import type {
|
|||||||
SessionMention,
|
SessionMention,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
ToolProgressEvent,
|
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
UIFileEdit,
|
|
||||||
UIMessage,
|
UIMessage,
|
||||||
UITurnPhase,
|
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -41,54 +55,9 @@ type PendingStreamEvent =
|
|||||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||||
|
|
||||||
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
|
||||||
|
|
||||||
const FILE_EDIT_TOOL_NAMES = new Set(["write_file", "edit_file", "apply_patch"]);
|
|
||||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
const STREAM_END_IDLE_DELAY_MS = 1000;
|
||||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
function turnFieldsFromEvent(
|
|
||||||
ev: { turn_id?: string; turn_phase?: UITurnPhase; turn_seq?: number },
|
|
||||||
fallbackPhase?: UITurnPhase,
|
|
||||||
): UIMessageTurnFields {
|
|
||||||
const fields: UIMessageTurnFields = {};
|
|
||||||
if (typeof ev.turn_id === "string" && ev.turn_id.length > 0) {
|
|
||||||
fields.turnId = ev.turn_id;
|
|
||||||
}
|
|
||||||
const phase = ev.turn_phase ?? fallbackPhase;
|
|
||||||
if (phase) fields.turnPhase = phase;
|
|
||||||
if (typeof ev.turn_seq === "number" && Number.isFinite(ev.turn_seq)) {
|
|
||||||
fields.turnSeq = ev.turn_seq;
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesTurn(message: UIMessage, turn: UIMessageTurnFields): boolean {
|
|
||||||
return !turn.turnId || !message.turnId || message.turnId === turn.turnId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find a still-open streamed assistant turn. Closed stream segments stay visible
|
|
||||||
* as streaming until ``turn_end`` for visual continuity, but they must not
|
|
||||||
* receive later delta segments. */
|
|
||||||
function findStreamingAssistantIndex(
|
|
||||||
prev: UIMessage[],
|
|
||||||
closedStreamIds: ReadonlySet<string>,
|
|
||||||
turn: UIMessageTurnFields = {},
|
|
||||||
): number | null {
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const m = prev[i];
|
|
||||||
if (m.kind === "trace") continue;
|
|
||||||
if (
|
|
||||||
m.role === "assistant"
|
|
||||||
&& m.isStreaming
|
|
||||||
&& !closedStreamIds.has(m.id)
|
|
||||||
&& matchesTurn(m, turn)
|
|
||||||
) return i;
|
|
||||||
if (m.role === "user") break;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
||||||
*
|
*
|
||||||
@@ -152,102 +121,6 @@ function attachReasoningChunk(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the most recent assistant placeholder that an incoming answer
|
|
||||||
* delta should adopt instead of spawning a parallel row. We look for an
|
|
||||||
* empty-content assistant turn that is still marked ``isStreaming`` —
|
|
||||||
* typically created earlier by ``reasoning_delta``. Anything else means
|
|
||||||
* the model already produced an answer in a previous turn, so the new
|
|
||||||
* delta belongs in a fresh row.
|
|
||||||
*/
|
|
||||||
function findActiveAssistantPlaceholderIndex(
|
|
||||||
prev: UIMessage[],
|
|
||||||
turn: UIMessageTurnFields = {},
|
|
||||||
): number | null {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (!last) return null;
|
|
||||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
|
||||||
if (last.content.length > 0) return null;
|
|
||||||
if (!last.isStreaming) return null;
|
|
||||||
if (!matchesTurn(last, turn)) return null;
|
|
||||||
return prev.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
|
|
||||||
const next = prev.slice();
|
|
||||||
next[index] = message;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the active reasoning stream segment, if any. Idempotent: a
|
|
||||||
* ``reasoning_end`` with no preceding deltas is a harmless no-op.
|
|
||||||
*/
|
|
||||||
function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const candidate = prev[i];
|
|
||||||
if (!candidate.reasoningStreaming) continue;
|
|
||||||
const latencyMs =
|
|
||||||
candidate.latencyMs === undefined
|
|
||||||
&& Number.isFinite(candidate.createdAt)
|
|
||||||
&& candidate.createdAt > 1_000_000_000_000
|
|
||||||
? Math.max(0, Math.round(Date.now() - candidate.createdAt))
|
|
||||||
: candidate.latencyMs;
|
|
||||||
const merged: UIMessage = {
|
|
||||||
...candidate,
|
|
||||||
reasoningStreaming: false,
|
|
||||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
|
||||||
};
|
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
|
|
||||||
return (
|
|
||||||
message.role === "assistant"
|
|
||||||
&& message.kind !== "trace"
|
|
||||||
&& message.content.trim().length === 0
|
|
||||||
&& !!message.reasoning
|
|
||||||
&& !message.reasoningStreaming
|
|
||||||
&& !message.media?.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToolTrace(message: UIMessage | undefined): boolean {
|
|
||||||
return message?.kind === "trace";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
|
||||||
return prev.filter((message, index) => {
|
|
||||||
if (!isReasoningOnlyPlaceholder(message)) return true;
|
|
||||||
// A reasoning-only assistant row immediately followed by tool traces is
|
|
||||||
// the live equivalent of a persisted assistant tool-call message with
|
|
||||||
// empty content, reasoning_content, and tool_calls. Keep it so live render
|
|
||||||
// and history replay stay isomorphic.
|
|
||||||
return isToolTrace(prev[index + 1]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stampLastAssistantCompletion(
|
|
||||||
prev: UIMessage[],
|
|
||||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
|
||||||
turnId?: string,
|
|
||||||
): UIMessage[] {
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const m = prev[i];
|
|
||||||
if (
|
|
||||||
m.role === "assistant"
|
|
||||||
&& m.kind !== "trace"
|
|
||||||
&& (!turnId || !m.turnId || m.turnId === turnId)
|
|
||||||
) {
|
|
||||||
const merged: UIMessage = { ...m, ...completion, isStreaming: false };
|
|
||||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
function absorbCompleteAssistantMessage(
|
function absorbCompleteAssistantMessage(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||||
@@ -275,193 +148,6 @@ function absorbCompleteAssistantMessage(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
|
||||||
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
|
|
||||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
|
||||||
return `${edit.tool}|${edit.path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileEditToolEventKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
|
|
||||||
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
|
|
||||||
return fileEditKey(edit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
|
|
||||||
const fn = (event as { function?: { name?: unknown } }).function;
|
|
||||||
const name = typeof event.name === "string"
|
|
||||||
? event.name
|
|
||||||
: typeof fn?.name === "string"
|
|
||||||
? fn.name
|
|
||||||
: "";
|
|
||||||
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
|
||||||
if (!name || !callId || !FILE_EDIT_TOOL_NAMES.has(name)) return null;
|
|
||||||
return `${callId}|${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent): boolean {
|
|
||||||
const key = toolEventFileEditKey(event);
|
|
||||||
if (!key) return false;
|
|
||||||
return messages.some((message) =>
|
|
||||||
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterCoveredFileEditToolEvents(
|
|
||||||
messages: UIMessage[],
|
|
||||||
events: ToolProgressEvent[],
|
|
||||||
): ToolProgressEvent[] {
|
|
||||||
if (events.length === 0) return events;
|
|
||||||
return events.filter((event) => !hasFileEditForToolEvent(messages, event));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
|
|
||||||
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
|
|
||||||
const events = message.toolEvents ?? [];
|
|
||||||
if (!events.length || incomingKeys.size === 0) return message;
|
|
||||||
|
|
||||||
const removedTraceLines = new Set<string>();
|
|
||||||
const keptEvents: ToolProgressEvent[] = [];
|
|
||||||
let changed = false;
|
|
||||||
for (const event of events) {
|
|
||||||
const key = toolEventFileEditKey(event);
|
|
||||||
if (key && incomingKeys.has(key)) {
|
|
||||||
changed = true;
|
|
||||||
for (const line of toolTraceLinesFromEvents([event])) {
|
|
||||||
removedTraceLines.add(line);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
keptEvents.push(event);
|
|
||||||
}
|
|
||||||
if (!changed) return message;
|
|
||||||
|
|
||||||
const previousTraces = message.traces?.length
|
|
||||||
? message.traces
|
|
||||||
: message.content
|
|
||||||
? [message.content]
|
|
||||||
: [];
|
|
||||||
const nextTraces = previousTraces.filter((line) => !removedTraceLines.has(line));
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
traces: nextTraces,
|
|
||||||
content: nextTraces[nextTraces.length - 1] ?? "",
|
|
||||||
toolEvents: keptEvents.length ? keptEvents : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function traceMessageIsEmpty(message: UIMessage): boolean {
|
|
||||||
const traces = message.traces;
|
|
||||||
const hasTrace = traces?.length
|
|
||||||
? traces.some((line) => line.trim().length > 0)
|
|
||||||
: (message.content ?? "").trim().length > 0;
|
|
||||||
return (
|
|
||||||
message.kind === "trace"
|
|
||||||
&& !hasTrace
|
|
||||||
&& !message.toolEvents?.length
|
|
||||||
&& !message.fileEdits?.length
|
|
||||||
&& !message.media?.length
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCoveredFileEditToolHintsFromMessages(
|
|
||||||
messages: UIMessage[],
|
|
||||||
edits: UIFileEdit[],
|
|
||||||
turn: UIMessageTurnFields,
|
|
||||||
): UIMessage[] {
|
|
||||||
if (edits.length === 0) return messages;
|
|
||||||
let next = messages;
|
|
||||||
for (let i = next.length - 1; i >= 0; i -= 1) {
|
|
||||||
const candidate = next[i];
|
|
||||||
if (candidate.role === "user") break;
|
|
||||||
if (candidate.kind !== "trace") continue;
|
|
||||||
if (!matchesTurn(candidate, turn)) continue;
|
|
||||||
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
|
|
||||||
if (cleaned === candidate) continue;
|
|
||||||
if (next === messages) next = [...messages];
|
|
||||||
if (traceMessageIsEmpty(cleaned)) {
|
|
||||||
next.splice(i, 1);
|
|
||||||
} else {
|
|
||||||
next[i] = cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
|
|
||||||
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
|
|
||||||
const inferredStatus =
|
|
||||||
edit.phase === "error"
|
|
||||||
? "error"
|
|
||||||
: edit.phase === "end"
|
|
||||||
? "done"
|
|
||||||
: "editing";
|
|
||||||
const normalized: UIFileEdit = {
|
|
||||||
...edit,
|
|
||||||
call_id: edit.call_id || `${edit.tool}:${edit.path}`,
|
|
||||||
added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
|
|
||||||
deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
|
|
||||||
status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
|
|
||||||
? edit.status
|
|
||||||
: inferredStatus,
|
|
||||||
};
|
|
||||||
if (edit.pending && !edit.path) normalized.pending = true;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
|
|
||||||
const next = [...(existing ?? [])];
|
|
||||||
const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
|
|
||||||
for (const raw of incoming) {
|
|
||||||
const edit = normalizeFileEdit(raw);
|
|
||||||
if (!edit) continue;
|
|
||||||
const key = fileEditKey(edit);
|
|
||||||
let existingIndex = indexByKey.get(key);
|
|
||||||
if (existingIndex === undefined && edit.path) {
|
|
||||||
const eventKey = fileEditToolEventKey(edit);
|
|
||||||
const pendingIndex = next.findIndex((existing) =>
|
|
||||||
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
|
|
||||||
);
|
|
||||||
if (pendingIndex >= 0) existingIndex = pendingIndex;
|
|
||||||
}
|
|
||||||
if (existingIndex === undefined) {
|
|
||||||
indexByKey.set(key, next.length);
|
|
||||||
next.push(edit);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const merged = { ...next[existingIndex], ...edit };
|
|
||||||
if (edit.path && !edit.pending) delete merged.pending;
|
|
||||||
next[existingIndex] = merged;
|
|
||||||
indexByKey.set(key, existingIndex);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFileEditTraceIndex(
|
|
||||||
prev: UIMessage[],
|
|
||||||
segmentId: string | null,
|
|
||||||
incoming: UIFileEdit[],
|
|
||||||
): number | null {
|
|
||||||
const incomingKeys = new Set(incoming.map(fileEditKey));
|
|
||||||
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
|
|
||||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
|
||||||
const candidate = prev[i];
|
|
||||||
if (candidate.role === "user") break;
|
|
||||||
if (candidate.kind !== "trace") continue;
|
|
||||||
if (segmentId && candidate.activitySegmentId === segmentId) return i;
|
|
||||||
for (const existing of candidate.fileEdits ?? []) {
|
|
||||||
if (
|
|
||||||
incomingKeys.has(fileEditKey(existing))
|
|
||||||
|| (
|
|
||||||
!existing.path
|
|
||||||
&& existing.pending
|
|
||||||
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
|
|
||||||
)
|
|
||||||
) return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||||
@@ -507,17 +193,6 @@ function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
|||||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeStreamedTurn(
|
|
||||||
prev: UIMessage[],
|
|
||||||
turn: UIMessageTurnFields = {},
|
|
||||||
): UIMessage[] {
|
|
||||||
return prev.map((m) =>
|
|
||||||
m.isStreaming && matchesTurn(m, turn)
|
|
||||||
? { ...m, isStreaming: false, reasoningStreaming: false }
|
|
||||||
: m,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||||
}
|
}
|
||||||
@@ -1104,7 +779,7 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
if (ev.event === "reasoning_end") {
|
if (ev.event === "reasoning_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
setMessages((prev) => closeReasoningStream(prev));
|
setMessages((prev) => closeReasoningStream(prev, Date.now()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,12 +849,15 @@ export function useNanobotStream(
|
|||||||
const line = ev.text;
|
const line = ev.text;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
if (fileEditSegmentRef.current) clearActivitySegment();
|
if (fileEditSegmentRef.current) clearActivitySegment();
|
||||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(
|
setMessages((prev) => closeReasoningStream(
|
||||||
|
attachReasoningChunk(
|
||||||
prev,
|
prev,
|
||||||
line,
|
line,
|
||||||
{ ensure: ensureActivitySegmentId },
|
{ ensure: ensureActivitySegmentId },
|
||||||
turnFieldsFromEvent(ev, "reasoning"),
|
turnFieldsFromEvent(ev, "reasoning"),
|
||||||
)));
|
),
|
||||||
|
Date.now(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||||
|
|||||||
@@ -257,13 +257,13 @@ export function useSessions(): {
|
|||||||
|
|
||||||
const deleteChat = useCallback(
|
const deleteChat = useCallback(
|
||||||
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||||
const result = await apiDeleteSession(tokenRef.current, key, options);
|
const result = await apiDeleteSession(client, key, options);
|
||||||
if (!result.deleted) return result;
|
if (!result.deleted) return result;
|
||||||
optimisticKeysRef.current.delete(key);
|
optimisticKeysRef.current.delete(key);
|
||||||
setSessions((prev) => prev.filter((s) => s.key !== key));
|
setSessions((prev) => prev.filter((s) => s.key !== key));
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
[],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSessionAutomations = useCallback(async (key: string) => {
|
const getSessionAutomations = useCallback(async (key: string) => {
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export function useSidebarState(
|
|||||||
const { client, token } = useClient();
|
const { client, token } = useClient();
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
|
||||||
|
const connectionOpenRef = useRef(client.status === "open");
|
||||||
|
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
|
||||||
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
@@ -171,14 +173,32 @@ export function useSidebarState(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const persist = useCallback((next: SidebarStatePayload) => {
|
||||||
|
if (!connectionOpenRef.current) {
|
||||||
|
pendingPersistenceRef.current = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void client.setSidebarState(next).catch(() => {
|
||||||
|
// Sidebar persistence is best-effort; the optimistic local state remains usable.
|
||||||
|
});
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
useEffect(() => client.onStatus((status) => {
|
||||||
|
connectionOpenRef.current = status === "open";
|
||||||
|
if (status !== "open" || pendingPersistenceRef.current === null) return;
|
||||||
|
const pending = pendingPersistenceRef.current;
|
||||||
|
pendingPersistenceRef.current = null;
|
||||||
|
persist(pending);
|
||||||
|
}), [client, persist]);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
|
||||||
const next = normalizeSidebarState(updater(stateRef.current));
|
const next = normalizeSidebarState(updater(stateRef.current));
|
||||||
stateRef.current = next;
|
stateRef.current = next;
|
||||||
setState(next);
|
setState(next);
|
||||||
client.setSidebarState(next);
|
persist(next);
|
||||||
},
|
},
|
||||||
[client],
|
[persist],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const VOICE_MIME_CANDIDATES = [
|
|||||||
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
export type VoiceRecorderState = "idle" | "recording" | "transcribing";
|
||||||
export type VoiceRecorderErrorKey =
|
export type VoiceRecorderErrorKey =
|
||||||
| "failed"
|
| "failed"
|
||||||
|
| "insecureContext"
|
||||||
| "noDevice"
|
| "noDevice"
|
||||||
| "notConfigured"
|
| "notConfigured"
|
||||||
| "permission"
|
| "permission"
|
||||||
@@ -163,6 +164,10 @@ export function useVoiceRecorder({
|
|||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
if (!onTranscribeAudio || state !== "idle" || startPendingRef.current) return;
|
||||||
onClearError();
|
onClearError();
|
||||||
|
if (window.isSecureContext === false) {
|
||||||
|
onError("insecureContext");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mediaDevices = navigator.mediaDevices;
|
const mediaDevices = navigator.mediaDevices;
|
||||||
const MediaRecorderCtor = mediaRecorderConstructor();
|
const MediaRecorderCtor = mediaRecorderConstructor();
|
||||||
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
if (!mediaDevices?.getUserMedia || !MediaRecorderCtor) {
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
"gatewayHint": "Make sure the gateway is running (`nanobot gateway`) and that this page is open on the same machine."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Authentication required",
|
"label": "Password",
|
||||||
"hint": "Enter the secret configured as tokenIssueSecret in your gateway config.",
|
"showPassword": "Show password",
|
||||||
"placeholder": "Password",
|
"hidePassword": "Hide password",
|
||||||
"submit": "Connect",
|
"submit": "Connect",
|
||||||
"invalid": "Invalid password. Try again."
|
"required": "Enter your password.",
|
||||||
|
"invalid": "Incorrect password. Try again."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Account",
|
"section": "Account",
|
||||||
@@ -318,8 +319,8 @@
|
|||||||
"filterInstalled": "Enabled",
|
"filterInstalled": "Enabled",
|
||||||
"filterNotInstalled": "Not enabled",
|
"filterNotInstalled": "Not enabled",
|
||||||
"searchPlaceholder": "Search MCP presets",
|
"searchPlaceholder": "Search MCP presets",
|
||||||
"moreOptions": "Add integration",
|
"moreOptions": "Add MCP server",
|
||||||
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
|
"moreOptionsSubtitle": "Connect a custom MCP server or import an existing configuration.",
|
||||||
"customTitle": "Custom MCP",
|
"customTitle": "Custom MCP",
|
||||||
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
||||||
"customAction": "Custom",
|
"customAction": "Custom",
|
||||||
@@ -327,9 +328,14 @@
|
|||||||
"serverName": "Server name",
|
"serverName": "Server name",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"authNone": "None",
|
||||||
|
"authHeaders": "Headers",
|
||||||
"command": "Command",
|
"command": "Command",
|
||||||
"args": "Args JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Headers JSON",
|
"headers": "Headers JSON",
|
||||||
|
"oauthAfterSave": "Save the server, then select Connect to sign in.",
|
||||||
|
"headersHelp": "Add the request headers used by this server.",
|
||||||
"env": "Env JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "Tool timeout",
|
"timeout": "Tool timeout",
|
||||||
"advancedOptions": "Advanced options",
|
"advancedOptions": "Advanced options",
|
||||||
@@ -356,6 +362,21 @@
|
|||||||
"keepExisting": "Leave blank to keep existing",
|
"keepExisting": "Leave blank to keep existing",
|
||||||
"statusConfigured": "Configured",
|
"statusConfigured": "Configured",
|
||||||
"statusMissingCredentials": "Needs key",
|
"statusMissingCredentials": "Needs key",
|
||||||
|
"connectingAccount": "Connecting {{name}}",
|
||||||
|
"connectingLabel": "Connecting…",
|
||||||
|
"continueSignIn": "Continue sign-in",
|
||||||
|
"preparingSignIn": "Preparing secure sign-in…",
|
||||||
|
"openSignInToContinue": "Open the sign-in page to continue.",
|
||||||
|
"finishSignInInBrowser": "Finish signing in in the browser window.",
|
||||||
|
"manualCallbackRequired": "Finish signing in, then paste the callback URL into nanobot.",
|
||||||
|
"manualCallbackHelp": "After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
|
||||||
|
"finishingConnection": "Finishing connection…",
|
||||||
|
"activatingTools": "Activating tools…",
|
||||||
|
"connected": "Connected.",
|
||||||
|
"connectionFailed": "Connection failed.",
|
||||||
|
"connectionCancelled": "Connection cancelled.",
|
||||||
|
"reloadFailed": "Signed in, but nanobot could not connect the tools. Try restarting nanobot.",
|
||||||
|
"oauthFailed": "Unable to connect. Try signing in again.",
|
||||||
"statusMissingDependency": "Needs dependency",
|
"statusMissingDependency": "Needs dependency",
|
||||||
"statusComingSoon": "Coming soon",
|
"statusComingSoon": "Coming soon",
|
||||||
"comingSoon": "Coming soon",
|
"comingSoon": "Coming soon",
|
||||||
@@ -583,20 +604,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Add tools to nanobot, then @ them in chat.",
|
"description": "Add tools to nanobot, then @ them in chat.",
|
||||||
"cliLabel": "App",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integration",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
"featureLabel": "Feature",
|
"featureLabel": "Feature",
|
||||||
"filterAll": "Ready",
|
"filterAll": "Ready",
|
||||||
"filterPlugins": "Plugins",
|
"filterPlugins": "Plugins",
|
||||||
"filterCli": "Apps",
|
"filterCli": "Apps",
|
||||||
"filterMcp": "Integrations",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} ready",
|
"enabledSummary": "{{count}} ready",
|
||||||
"caption": "{{cli}} apps · {{mcp}} integrations",
|
"caption": "{{cli}} apps · {{mcp}} MCP tools",
|
||||||
"searchPlaceholder": "Search tools",
|
"searchPlaceholder": "Search tools",
|
||||||
"featured": "Tools",
|
"featured": "Tools",
|
||||||
|
"mcpTools": "MCP tools",
|
||||||
"loading": "Loading Apps...",
|
"loading": "Loading Apps...",
|
||||||
"empty": "No tools match this view.",
|
"empty": "No tools match your search.",
|
||||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
"emptyApps": "No apps available.",
|
||||||
|
"emptyIntegrations": "No MCP tools available.",
|
||||||
|
"emptyReady": "No tools are ready yet.",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"browseApps": "Browse apps",
|
||||||
|
"browseIntegrations": "Browse MCP tools",
|
||||||
|
"emptyIntegrationsHint": "Add a custom MCP server below.",
|
||||||
|
"restartRequired": "Restart nanobot to apply updated apps and MCP tools."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
||||||
@@ -698,7 +727,9 @@
|
|||||||
"loading": "Loading automations...",
|
"loading": "Loading automations...",
|
||||||
"noMatches": "No automations match this view.",
|
"noMatches": "No automations match this view.",
|
||||||
"empty": "No automations yet.",
|
"empty": "No automations yet.",
|
||||||
"emptyHint": "Create one from where it should run so nanobot keeps the right context.",
|
"emptyHint": "Create automations in a chat so they keep the right context.",
|
||||||
|
"emptyAction": "Open a chat",
|
||||||
|
"clearFilters": "Clear filters",
|
||||||
"oneShot": "One-time",
|
"oneShot": "One-time",
|
||||||
"systemTask": "System-managed automation",
|
"systemTask": "System-managed automation",
|
||||||
"localTrigger": "Local trigger",
|
"localTrigger": "Local trigger",
|
||||||
@@ -1147,6 +1178,7 @@
|
|||||||
"recordingStatus": "Recording {{time}}"
|
"recordingStatus": "Recording {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "Voice input is not supported in this browser.",
|
||||||
"permission": "Allow microphone access in the address bar, then retry.",
|
"permission": "Allow microphone access in the address bar, then retry.",
|
||||||
"notConfigured": "Configure a transcription provider first.",
|
"notConfigured": "Configure a transcription provider first.",
|
||||||
@@ -1380,7 +1412,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Workspace was not changed",
|
"title": "Workspace was not changed",
|
||||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
"body": "The gateway rejected this project or access mode. Choose an existing project or a different access mode, then try again."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Message was not sent",
|
"title": "Message was not sent",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
"gatewayHint": "Asegúrate de que la gateway esté en ejecución (`nanobot gateway`) y de que esta página esté abierta en la misma máquina."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Autenticación requerida",
|
"label": "Contraseña",
|
||||||
"hint": "Introduce el secreto configurado como tokenIssueSecret en la configuración del gateway.",
|
"showPassword": "Mostrar contraseña",
|
||||||
"placeholder": "Contraseña",
|
"hidePassword": "Ocultar contraseña",
|
||||||
"submit": "Conectar",
|
"submit": "Conectar",
|
||||||
"invalid": "Contraseña no válida. Inténtalo de nuevo."
|
"required": "Introduce la contraseña.",
|
||||||
|
"invalid": "Contraseña incorrecta. Inténtalo de nuevo."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Cuenta",
|
"section": "Cuenta",
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "Habilitados",
|
"filterInstalled": "Habilitados",
|
||||||
"filterNotInstalled": "No habilitados",
|
"filterNotInstalled": "No habilitados",
|
||||||
"searchPlaceholder": "Buscar preajustes MCP",
|
"searchPlaceholder": "Buscar preajustes MCP",
|
||||||
"moreOptions": "Más opciones de MCP",
|
"moreOptions": "Añadir servidor MCP",
|
||||||
"moreOptionsSubtitle": "Añade un servidor personalizado o importa mcp.json.",
|
"moreOptionsSubtitle": "Conecta un servidor MCP personalizado o importa una configuración existente.",
|
||||||
"customTitle": "MCP personalizado",
|
"customTitle": "MCP personalizado",
|
||||||
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
|
"customSubtitle": "Añade cualquier servidor MCP stdio, HTTP o SSE.",
|
||||||
"customAction": "Personalizado",
|
"customAction": "Personalizado",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "Nombre del servidor",
|
"serverName": "Nombre del servidor",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transporte",
|
"transport": "Transporte",
|
||||||
|
"authentication": "Autenticación",
|
||||||
|
"authNone": "Ninguna",
|
||||||
|
"authHeaders": "Encabezados",
|
||||||
"command": "Comando",
|
"command": "Comando",
|
||||||
"args": "Argumentos JSON",
|
"args": "Argumentos JSON",
|
||||||
"headers": "Encabezados JSON",
|
"headers": "Encabezados JSON",
|
||||||
|
"oauthAfterSave": "Guarda el servidor y selecciona Conectar para iniciar sesión.",
|
||||||
|
"headersHelp": "Añade los encabezados de solicitud que utiliza este servidor.",
|
||||||
"env": "Entorno JSON",
|
"env": "Entorno JSON",
|
||||||
"timeout": "Tiempo límite de herramienta",
|
"timeout": "Tiempo límite de herramienta",
|
||||||
"advancedOptions": "Opciones avanzadas",
|
"advancedOptions": "Opciones avanzadas",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "Déjalo en blanco para conservar el valor actual",
|
"keepExisting": "Déjalo en blanco para conservar el valor actual",
|
||||||
"statusConfigured": "Configurado",
|
"statusConfigured": "Configurado",
|
||||||
"statusMissingCredentials": "Necesita clave",
|
"statusMissingCredentials": "Necesita clave",
|
||||||
|
"connectingAccount": "Conectando {{name}}",
|
||||||
|
"connectingLabel": "Conectando…",
|
||||||
|
"continueSignIn": "Continuar inicio de sesión",
|
||||||
|
"preparingSignIn": "Preparando un inicio de sesión seguro…",
|
||||||
|
"openSignInToContinue": "Abre la página de inicio de sesión para continuar.",
|
||||||
|
"finishSignInInBrowser": "Termina de iniciar sesión en la ventana del navegador.",
|
||||||
|
"manualCallbackRequired": "Termina de iniciar sesión y pega la URL de devolución en nanobot.",
|
||||||
|
"manualCallbackHelp": "Después de aprobar el acceso, la página de localhost no se cargará. Copia la URL completa de la barra de direcciones y pégala aquí.",
|
||||||
|
"finishingConnection": "Finalizando la conexión…",
|
||||||
|
"activatingTools": "Activando herramientas…",
|
||||||
|
"connected": "Conectado.",
|
||||||
|
"connectionFailed": "Error de conexión.",
|
||||||
|
"connectionCancelled": "Conexión cancelada.",
|
||||||
|
"reloadFailed": "Has iniciado sesión, pero nanobot no pudo conectar las herramientas. Prueba a reiniciar nanobot.",
|
||||||
|
"oauthFailed": "No se pudo conectar. Intenta iniciar sesión de nuevo.",
|
||||||
"statusMissingDependency": "Necesita dependencia",
|
"statusMissingDependency": "Necesita dependencia",
|
||||||
"statusComingSoon": "Próximamente",
|
"statusComingSoon": "Próximamente",
|
||||||
"comingSoon": "Próximamente",
|
"comingSoon": "Próximamente",
|
||||||
@@ -570,20 +591,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||||
"cliLabel": "Aplicación",
|
"cliLabel": "Aplicación",
|
||||||
"mcpLabel": "Integración",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
"filterAll": "Listo",
|
"filterAll": "Listo",
|
||||||
"filterPlugins": "Complementos",
|
"filterPlugins": "Complementos",
|
||||||
"filterCli": "Aplicaciones",
|
"filterCli": "Aplicaciones",
|
||||||
"filterMcp": "Integraciones",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} listos",
|
"enabledSummary": "{{count}} listos",
|
||||||
"caption": "{{cli}} aplicaciones · {{mcp}} integraciones",
|
"caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP",
|
||||||
"searchPlaceholder": "Buscar aplicaciones",
|
"searchPlaceholder": "Buscar aplicaciones",
|
||||||
"featured": "Herramientas",
|
"featured": "Herramientas",
|
||||||
|
"mcpTools": "Herramientas MCP",
|
||||||
"loading": "Cargando aplicaciones...",
|
"loading": "Cargando aplicaciones...",
|
||||||
"empty": "Ninguna herramienta coincide con esta vista.",
|
"empty": "Ninguna herramienta coincide con tu búsqueda.",
|
||||||
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y funciones actualizadas."
|
"emptyApps": "No hay aplicaciones disponibles.",
|
||||||
|
"emptyIntegrations": "No hay herramientas MCP disponibles.",
|
||||||
|
"emptyReady": "Todavía no hay herramientas listas.",
|
||||||
|
"clearSearch": "Borrar búsqueda",
|
||||||
|
"browseApps": "Explorar aplicaciones",
|
||||||
|
"browseIntegrations": "Explorar herramientas MCP",
|
||||||
|
"emptyIntegrationsHint": "Añade un servidor MCP personalizado abajo.",
|
||||||
|
"restartRequired": "Reinicia nanobot para aplicar las aplicaciones y herramientas MCP actualizadas."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
"description": "Conecta nanobot con aplicaciones de chat. Instalar el soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
||||||
@@ -685,7 +714,9 @@
|
|||||||
"loading": "Cargando automatizaciones...",
|
"loading": "Cargando automatizaciones...",
|
||||||
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
"noMatches": "No hay automatizaciones que coincidan con esta vista.",
|
||||||
"empty": "Aún no hay automatizaciones.",
|
"empty": "Aún no hay automatizaciones.",
|
||||||
"emptyHint": "Créala desde donde debe ejecutarse para que nanobot conserve el contexto correcto.",
|
"emptyHint": "Crea automatizaciones en un chat para que conserven el contexto correcto.",
|
||||||
|
"emptyAction": "Abrir un chat",
|
||||||
|
"clearFilters": "Borrar filtros",
|
||||||
"oneShot": "Una vez",
|
"oneShot": "Una vez",
|
||||||
"systemTask": "Automatización administrada por el sistema",
|
"systemTask": "Automatización administrada por el sistema",
|
||||||
"localTrigger": "Activador local",
|
"localTrigger": "Activador local",
|
||||||
@@ -1134,6 +1165,7 @@
|
|||||||
"recordingStatus": "Grabando {{time}}"
|
"recordingStatus": "Grabando {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "Este navegador no admite entrada de voz.",
|
||||||
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
"permission": "Permite el micrófono en la barra de direcciones y vuelve a intentarlo.",
|
||||||
"notConfigured": "Configura primero un proveedor de transcripción.",
|
"notConfigured": "Configura primero un proveedor de transcripción.",
|
||||||
@@ -1367,7 +1399,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "El espacio de trabajo no cambió",
|
"title": "El espacio de trabajo no cambió",
|
||||||
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
|
"body": "El gateway rechazó este proyecto o modo de acceso. Elige un proyecto existente u otro modo de acceso e inténtalo de nuevo."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "El mensaje no se envió",
|
"title": "El mensaje no se envió",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
"gatewayHint": "Assurez-vous que la gateway est en cours d’exécution (`nanobot gateway`) et que cette page est ouverte sur la même machine."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Authentification requise",
|
"label": "Mot de passe",
|
||||||
"hint": "Saisissez le secret configuré comme tokenIssueSecret dans la configuration de votre gateway.",
|
"showPassword": "Afficher le mot de passe",
|
||||||
"placeholder": "Mot de passe",
|
"hidePassword": "Masquer le mot de passe",
|
||||||
"submit": "Se connecter",
|
"submit": "Se connecter",
|
||||||
"invalid": "Mot de passe invalide. Réessayez."
|
"required": "Saisissez le mot de passe.",
|
||||||
|
"invalid": "Mot de passe incorrect. Réessayez."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Compte",
|
"section": "Compte",
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "Activés",
|
"filterInstalled": "Activés",
|
||||||
"filterNotInstalled": "Non activés",
|
"filterNotInstalled": "Non activés",
|
||||||
"searchPlaceholder": "Rechercher des préréglages MCP",
|
"searchPlaceholder": "Rechercher des préréglages MCP",
|
||||||
"moreOptions": "Plus d'options MCP",
|
"moreOptions": "Ajouter un serveur MCP",
|
||||||
"moreOptionsSubtitle": "Ajoutez un serveur personnalisé ou importez mcp.json.",
|
"moreOptionsSubtitle": "Connectez un serveur MCP personnalisé ou importez une configuration existante.",
|
||||||
"customTitle": "MCP personnalisé",
|
"customTitle": "MCP personnalisé",
|
||||||
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
|
"customSubtitle": "Ajoutez n'importe quel serveur MCP stdio, HTTP ou SSE.",
|
||||||
"customAction": "Personnalisé",
|
"customAction": "Personnalisé",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "Nom du serveur",
|
"serverName": "Nom du serveur",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
|
"authentication": "Authentification",
|
||||||
|
"authNone": "Aucune",
|
||||||
|
"authHeaders": "En-têtes",
|
||||||
"command": "Commande",
|
"command": "Commande",
|
||||||
"args": "Arguments JSON",
|
"args": "Arguments JSON",
|
||||||
"headers": "En-têtes JSON",
|
"headers": "En-têtes JSON",
|
||||||
|
"oauthAfterSave": "Enregistrez le serveur, puis sélectionnez Se connecter pour vous identifier.",
|
||||||
|
"headersHelp": "Ajoutez les en-têtes de requête utilisés par ce serveur.",
|
||||||
"env": "Environnement JSON",
|
"env": "Environnement JSON",
|
||||||
"timeout": "Délai d'outil",
|
"timeout": "Délai d'outil",
|
||||||
"advancedOptions": "Options avancées",
|
"advancedOptions": "Options avancées",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
|
"keepExisting": "Laissez vide pour conserver la valeur actuelle",
|
||||||
"statusConfigured": "Configuré",
|
"statusConfigured": "Configuré",
|
||||||
"statusMissingCredentials": "Clé requise",
|
"statusMissingCredentials": "Clé requise",
|
||||||
|
"connectingAccount": "Connexion à {{name}}",
|
||||||
|
"connectingLabel": "Connexion…",
|
||||||
|
"continueSignIn": "Continuer la connexion",
|
||||||
|
"preparingSignIn": "Préparation d’une connexion sécurisée…",
|
||||||
|
"openSignInToContinue": "Ouvrez la page de connexion pour continuer.",
|
||||||
|
"finishSignInInBrowser": "Terminez la connexion dans la fenêtre du navigateur.",
|
||||||
|
"manualCallbackRequired": "Terminez la connexion, puis collez l’URL de rappel dans nanobot.",
|
||||||
|
"manualCallbackHelp": "Après avoir autorisé l’accès, la page localhost ne se chargera pas. Copiez son URL complète depuis la barre d’adresse et collez-la ici.",
|
||||||
|
"finishingConnection": "Finalisation de la connexion…",
|
||||||
|
"activatingTools": "Activation des outils…",
|
||||||
|
"connected": "Connecté.",
|
||||||
|
"connectionFailed": "Échec de la connexion.",
|
||||||
|
"connectionCancelled": "Connexion annulée.",
|
||||||
|
"reloadFailed": "Connexion réussie, mais nanobot n’a pas pu activer les outils. Essayez de redémarrer nanobot.",
|
||||||
|
"oauthFailed": "Connexion impossible. Essayez de vous reconnecter.",
|
||||||
"statusMissingDependency": "Dépendance requise",
|
"statusMissingDependency": "Dépendance requise",
|
||||||
"statusComingSoon": "Bientôt disponible",
|
"statusComingSoon": "Bientôt disponible",
|
||||||
"comingSoon": "Bientôt disponible",
|
"comingSoon": "Bientôt disponible",
|
||||||
@@ -569,20 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||||
"cliLabel": "Application",
|
"cliLabel": "Application",
|
||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
"filterAll": "Prêts",
|
"filterAll": "Prêts",
|
||||||
"filterPlugins": "Extensions",
|
"filterPlugins": "Extensions",
|
||||||
"filterCli": "Applications",
|
"filterCli": "Applications",
|
||||||
"filterMcp": "Intégrations",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} prêts",
|
"enabledSummary": "{{count}} prêts",
|
||||||
"caption": "{{cli}} applications · {{mcp}} intégrations",
|
"caption": "{{cli}} applications · {{mcp}} outils MCP",
|
||||||
"searchPlaceholder": "Rechercher des applications",
|
"searchPlaceholder": "Rechercher des applications",
|
||||||
"featured": "Outils",
|
"featured": "Outils",
|
||||||
|
"mcpTools": "Outils MCP",
|
||||||
"loading": "Chargement des applications...",
|
"loading": "Chargement des applications...",
|
||||||
"empty": "Aucun outil ne correspond à cette vue.",
|
"empty": "Aucun outil ne correspond à votre recherche.",
|
||||||
"restartRequired": "Redémarrez nanobot pour appliquer les applications et fonctions mises à jour."
|
"emptyApps": "Aucune application disponible.",
|
||||||
|
"emptyIntegrations": "Aucun outil MCP disponible.",
|
||||||
|
"emptyReady": "Aucun outil n’est encore prêt.",
|
||||||
|
"clearSearch": "Effacer la recherche",
|
||||||
|
"browseApps": "Parcourir les applications",
|
||||||
|
"browseIntegrations": "Parcourir les outils MCP",
|
||||||
|
"emptyIntegrationsHint": "Ajoutez un serveur MCP personnalisé ci-dessous.",
|
||||||
|
"restartRequired": "Redémarrez nanobot pour appliquer les applications et outils MCP mis à jour."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
|
"description": "Connectez nanobot aux applications de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des jetons ou des réglages d'espace de travail.",
|
||||||
@@ -684,7 +713,9 @@
|
|||||||
"loading": "Chargement des automatisations...",
|
"loading": "Chargement des automatisations...",
|
||||||
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
"noMatches": "Aucune automatisation ne correspond à cette vue.",
|
||||||
"empty": "Aucune automatisation pour le moment.",
|
"empty": "Aucune automatisation pour le moment.",
|
||||||
"emptyHint": "Créez-la depuis son point d'exécution pour que nanobot conserve le bon contexte.",
|
"emptyHint": "Créez les automatisations dans un chat afin de conserver le bon contexte.",
|
||||||
|
"emptyAction": "Ouvrir un chat",
|
||||||
|
"clearFilters": "Effacer les filtres",
|
||||||
"oneShot": "Ponctuelle",
|
"oneShot": "Ponctuelle",
|
||||||
"systemTask": "Automatisation gérée par le système",
|
"systemTask": "Automatisation gérée par le système",
|
||||||
"localTrigger": "Déclencheur local",
|
"localTrigger": "Déclencheur local",
|
||||||
@@ -1133,6 +1164,7 @@
|
|||||||
"recordingStatus": "Enregistrement {{time}}"
|
"recordingStatus": "Enregistrement {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "Pour des raisons de sécurité, Chrome et d’autres navigateurs bloquent l’accè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.",
|
"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.",
|
"permission": "Autorisez le microphone dans la barre d'adresse, puis réessayez.",
|
||||||
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
"notConfigured": "Configurez d'abord un fournisseur de transcription.",
|
||||||
@@ -1366,7 +1398,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "L’espace de travail n’a pas changé",
|
"title": "L’espace de travail n’a pas changé",
|
||||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
"body": "La passerelle a refusé ce projet ou ce mode d’accès. Choisissez un projet existant ou un autre mode d’accès, puis réessayez."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Le message n’a pas été envoyé",
|
"title": "Le message n’a pas été envoyé",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
"gatewayHint": "Pastikan gateway sedang berjalan (`nanobot gateway`) dan halaman ini dibuka pada mesin yang sama."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Autentikasi diperlukan",
|
"label": "Kata sandi",
|
||||||
"hint": "Masukkan secret yang dikonfigurasi sebagai tokenIssueSecret di konfigurasi gateway.",
|
"showPassword": "Tampilkan kata sandi",
|
||||||
"placeholder": "Kata sandi",
|
"hidePassword": "Sembunyikan kata sandi",
|
||||||
"submit": "Hubungkan",
|
"submit": "Hubungkan",
|
||||||
"invalid": "Kata sandi tidak valid. Coba lagi."
|
"required": "Masukkan kata sandi.",
|
||||||
|
"invalid": "Kata sandi salah. Coba lagi."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Akun",
|
"section": "Akun",
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "Aktif",
|
"filterInstalled": "Aktif",
|
||||||
"filterNotInstalled": "Tidak aktif",
|
"filterNotInstalled": "Tidak aktif",
|
||||||
"searchPlaceholder": "Cari prasetel MCP",
|
"searchPlaceholder": "Cari prasetel MCP",
|
||||||
"moreOptions": "Opsi MCP lainnya",
|
"moreOptions": "Tambahkan server MCP",
|
||||||
"moreOptionsSubtitle": "Tambahkan server khusus atau impor mcp.json.",
|
"moreOptionsSubtitle": "Hubungkan server MCP khusus atau impor konfigurasi yang ada.",
|
||||||
"customTitle": "MCP khusus",
|
"customTitle": "MCP khusus",
|
||||||
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
|
"customSubtitle": "Tambahkan server MCP stdio, HTTP, atau SSE apa pun.",
|
||||||
"customAction": "Khusus",
|
"customAction": "Khusus",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "Nama server",
|
"serverName": "Nama server",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
|
"authentication": "Autentikasi",
|
||||||
|
"authNone": "Tidak ada",
|
||||||
|
"authHeaders": "Header",
|
||||||
"command": "Perintah",
|
"command": "Perintah",
|
||||||
"args": "Argumen JSON",
|
"args": "Argumen JSON",
|
||||||
"headers": "Header JSON",
|
"headers": "Header JSON",
|
||||||
|
"oauthAfterSave": "Simpan server, lalu pilih Hubungkan untuk masuk.",
|
||||||
|
"headersHelp": "Tambahkan header permintaan yang digunakan server ini.",
|
||||||
"env": "Lingkungan JSON",
|
"env": "Lingkungan JSON",
|
||||||
"timeout": "Batas waktu alat",
|
"timeout": "Batas waktu alat",
|
||||||
"advancedOptions": "Opsi lanjutan",
|
"advancedOptions": "Opsi lanjutan",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
|
"keepExisting": "Biarkan kosong untuk mempertahankan nilai saat ini",
|
||||||
"statusConfigured": "Terkonfigurasi",
|
"statusConfigured": "Terkonfigurasi",
|
||||||
"statusMissingCredentials": "Butuh kunci",
|
"statusMissingCredentials": "Butuh kunci",
|
||||||
|
"connectingAccount": "Menghubungkan {{name}}",
|
||||||
|
"connectingLabel": "Menghubungkan…",
|
||||||
|
"continueSignIn": "Lanjutkan masuk",
|
||||||
|
"preparingSignIn": "Menyiapkan proses masuk yang aman…",
|
||||||
|
"openSignInToContinue": "Buka halaman masuk untuk melanjutkan.",
|
||||||
|
"finishSignInInBrowser": "Selesaikan proses masuk di jendela browser.",
|
||||||
|
"manualCallbackRequired": "Selesaikan proses masuk, lalu tempel URL callback ke nanobot.",
|
||||||
|
"manualCallbackHelp": "Setelah menyetujui akses, halaman localhost tidak akan terbuka. Salin URL lengkap dari bilah alamat lalu tempel di sini.",
|
||||||
|
"finishingConnection": "Menyelesaikan koneksi…",
|
||||||
|
"activatingTools": "Mengaktifkan alat…",
|
||||||
|
"connected": "Terhubung.",
|
||||||
|
"connectionFailed": "Koneksi gagal.",
|
||||||
|
"connectionCancelled": "Koneksi dibatalkan.",
|
||||||
|
"reloadFailed": "Anda sudah masuk, tetapi nanobot tidak dapat menghubungkan alat. Coba mulai ulang nanobot.",
|
||||||
|
"oauthFailed": "Tidak dapat terhubung. Coba masuk lagi.",
|
||||||
"statusMissingDependency": "Butuh dependensi",
|
"statusMissingDependency": "Butuh dependensi",
|
||||||
"statusComingSoon": "Segera hadir",
|
"statusComingSoon": "Segera hadir",
|
||||||
"comingSoon": "Segera hadir",
|
"comingSoon": "Segera hadir",
|
||||||
@@ -569,20 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||||
"cliLabel": "Aplikasi",
|
"cliLabel": "Aplikasi",
|
||||||
"mcpLabel": "Integrasi",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
"featureLabel": "Fitur",
|
"featureLabel": "Fitur",
|
||||||
"filterAll": "Siap",
|
"filterAll": "Siap",
|
||||||
"filterPlugins": "Plugin",
|
"filterPlugins": "Plugin",
|
||||||
"filterCli": "Aplikasi",
|
"filterCli": "Aplikasi",
|
||||||
"filterMcp": "Integrasi",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} siap",
|
"enabledSummary": "{{count}} siap",
|
||||||
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
|
"caption": "{{cli}} aplikasi · {{mcp}} alat MCP",
|
||||||
"searchPlaceholder": "Cari aplikasi",
|
"searchPlaceholder": "Cari aplikasi",
|
||||||
"featured": "Alat",
|
"featured": "Alat",
|
||||||
|
"mcpTools": "Alat MCP",
|
||||||
"loading": "Memuat aplikasi...",
|
"loading": "Memuat aplikasi...",
|
||||||
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
|
"empty": "Tidak ada alat yang cocok dengan pencarian Anda.",
|
||||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
"emptyApps": "Tidak ada aplikasi yang tersedia.",
|
||||||
|
"emptyIntegrations": "Tidak ada alat MCP yang tersedia.",
|
||||||
|
"emptyReady": "Belum ada alat yang siap.",
|
||||||
|
"clearSearch": "Hapus pencarian",
|
||||||
|
"browseApps": "Jelajahi aplikasi",
|
||||||
|
"browseIntegrations": "Jelajahi alat MCP",
|
||||||
|
"emptyIntegrationsHint": "Tambahkan server MCP khusus di bawah.",
|
||||||
|
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan alat MCP yang diperbarui."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
|
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan ruang kerja.",
|
||||||
@@ -684,7 +713,9 @@
|
|||||||
"loading": "Memuat otomasi...",
|
"loading": "Memuat otomasi...",
|
||||||
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
"noMatches": "Tidak ada otomasi yang cocok dengan tampilan ini.",
|
||||||
"empty": "Belum ada otomasi.",
|
"empty": "Belum ada otomasi.",
|
||||||
"emptyHint": "Buat dari tempat tugas ini berjalan agar nanobot menyimpan konteks yang tepat.",
|
"emptyHint": "Buat otomatisasi di chat agar konteks yang tepat tetap tersimpan.",
|
||||||
|
"emptyAction": "Buka chat",
|
||||||
|
"clearFilters": "Hapus filter",
|
||||||
"oneShot": "Satu kali",
|
"oneShot": "Satu kali",
|
||||||
"systemTask": "Automasi yang dikelola sistem",
|
"systemTask": "Automasi yang dikelola sistem",
|
||||||
"localTrigger": "Pemicu lokal",
|
"localTrigger": "Pemicu lokal",
|
||||||
@@ -1133,6 +1164,7 @@
|
|||||||
"recordingStatus": "Merekam {{time}}"
|
"recordingStatus": "Merekam {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "Input suara tidak didukung di browser ini.",
|
||||||
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
"permission": "Izinkan mikrofon di bilah alamat, lalu coba lagi.",
|
||||||
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
"notConfigured": "Konfigurasikan penyedia transkripsi terlebih dahulu.",
|
||||||
@@ -1366,7 +1398,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Ruang kerja tidak berubah",
|
"title": "Ruang kerja tidak berubah",
|
||||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai ruang kerja sebelumnya."
|
"body": "Gateway menolak proyek atau mode akses ini. Pilih proyek yang sudah ada atau mode akses lain, lalu coba lagi."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Pesan tidak terkirim",
|
"title": "Pesan tidak terkirim",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
"gatewayHint": "gateway(`nanobot gateway`)が起動しており、このページが同じマシン上で開かれていることを確認してください。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "認証が必要です",
|
"label": "パスワード",
|
||||||
"hint": "gateway 設定の tokenIssueSecret に指定されたシークレットを入力してください。",
|
"showPassword": "パスワードを表示",
|
||||||
"placeholder": "パスワード",
|
"hidePassword": "パスワードを隠す",
|
||||||
"submit": "接続",
|
"submit": "接続",
|
||||||
"invalid": "パスワードが無効です。もう一度お試しください。"
|
"required": "パスワードを入力してください。",
|
||||||
|
"invalid": "パスワードが正しくありません。もう一度お試しください。"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "アカウント",
|
"section": "アカウント",
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "有効",
|
"filterInstalled": "有効",
|
||||||
"filterNotInstalled": "未有効",
|
"filterNotInstalled": "未有効",
|
||||||
"searchPlaceholder": "MCP プリセットを検索",
|
"searchPlaceholder": "MCP プリセットを検索",
|
||||||
"moreOptions": "その他の MCP オプション",
|
"moreOptions": "MCP サーバーを追加",
|
||||||
"moreOptionsSubtitle": "カスタムサーバーを追加するか mcp.json をインポートします。",
|
"moreOptionsSubtitle": "カスタム MCP サーバーを接続するか、既存の設定をインポートします。",
|
||||||
"customTitle": "カスタム MCP",
|
"customTitle": "カスタム MCP",
|
||||||
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
|
"customSubtitle": "任意の stdio、HTTP、SSE MCP サーバーを追加します。",
|
||||||
"customAction": "カスタム",
|
"customAction": "カスタム",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "サーバー名",
|
"serverName": "サーバー名",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "トランスポート",
|
"transport": "トランスポート",
|
||||||
|
"authentication": "認証",
|
||||||
|
"authNone": "なし",
|
||||||
|
"authHeaders": "ヘッダー",
|
||||||
"command": "コマンド",
|
"command": "コマンド",
|
||||||
"args": "引数 JSON",
|
"args": "引数 JSON",
|
||||||
"headers": "ヘッダー JSON",
|
"headers": "ヘッダー JSON",
|
||||||
|
"oauthAfterSave": "サーバーを保存してから、[接続]を選択してサインインします。",
|
||||||
|
"headersHelp": "このサーバーで使用するリクエストヘッダーを追加します。",
|
||||||
"env": "環境変数 JSON",
|
"env": "環境変数 JSON",
|
||||||
"timeout": "ツールのタイムアウト",
|
"timeout": "ツールのタイムアウト",
|
||||||
"advancedOptions": "詳細オプション",
|
"advancedOptions": "詳細オプション",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "既存の値を維持するには空欄のままにします",
|
"keepExisting": "既存の値を維持するには空欄のままにします",
|
||||||
"statusConfigured": "設定済み",
|
"statusConfigured": "設定済み",
|
||||||
"statusMissingCredentials": "キーが必要",
|
"statusMissingCredentials": "キーが必要",
|
||||||
|
"connectingAccount": "{{name}} に接続しています",
|
||||||
|
"connectingLabel": "接続中…",
|
||||||
|
"continueSignIn": "サインインを続ける",
|
||||||
|
"preparingSignIn": "安全なサインインを準備しています…",
|
||||||
|
"openSignInToContinue": "サインインページを開いて続行してください。",
|
||||||
|
"finishSignInInBrowser": "ブラウザウィンドウでサインインを完了してください。",
|
||||||
|
"manualCallbackRequired": "サインインを完了し、コールバック URL を nanobot に貼り付けてください。",
|
||||||
|
"manualCallbackHelp": "アクセスを承認すると localhost ページは開きません。アドレスバーから完全な URL をコピーして、ここに貼り付けてください。",
|
||||||
|
"finishingConnection": "接続を完了しています…",
|
||||||
|
"activatingTools": "ツールを有効にしています…",
|
||||||
|
"connected": "接続しました。",
|
||||||
|
"connectionFailed": "接続に失敗しました。",
|
||||||
|
"connectionCancelled": "接続をキャンセルしました。",
|
||||||
|
"reloadFailed": "サインインしましたが、nanobot はツールに接続できませんでした。nanobot を再起動してください。",
|
||||||
|
"oauthFailed": "接続できません。もう一度サインインしてください。",
|
||||||
"statusMissingDependency": "依存関係が必要",
|
"statusMissingDependency": "依存関係が必要",
|
||||||
"statusComingSoon": "近日公開",
|
"statusComingSoon": "近日公開",
|
||||||
"comingSoon": "近日公開",
|
"comingSoon": "近日公開",
|
||||||
@@ -569,20 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||||
"cliLabel": "アプリ",
|
"cliLabel": "アプリ",
|
||||||
"mcpLabel": "連携",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
"featureLabel": "機能",
|
"featureLabel": "機能",
|
||||||
"filterAll": "使用可能",
|
"filterAll": "使用可能",
|
||||||
"filterPlugins": "プラグイン",
|
"filterPlugins": "プラグイン",
|
||||||
"filterCli": "アプリ",
|
"filterCli": "アプリ",
|
||||||
"filterMcp": "連携",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} 件使用可能",
|
"enabledSummary": "{{count}} 件使用可能",
|
||||||
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
|
"caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件",
|
||||||
"searchPlaceholder": "アプリを検索",
|
"searchPlaceholder": "アプリを検索",
|
||||||
"featured": "ツール",
|
"featured": "ツール",
|
||||||
|
"mcpTools": "MCP ツール",
|
||||||
"loading": "アプリを読み込み中...",
|
"loading": "アプリを読み込み中...",
|
||||||
"empty": "この表示に一致するツールはありません。",
|
"empty": "検索条件に一致するツールはありません。",
|
||||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
"emptyApps": "利用できるアプリはありません。",
|
||||||
|
"emptyIntegrations": "利用できる MCP ツールはありません。",
|
||||||
|
"emptyReady": "使用可能なツールはまだありません。",
|
||||||
|
"clearSearch": "検索をクリア",
|
||||||
|
"browseApps": "アプリを見る",
|
||||||
|
"browseIntegrations": "MCP ツールを見る",
|
||||||
|
"emptyIntegrationsHint": "下からカスタム MCP サーバーを追加できます。",
|
||||||
|
"restartRequired": "更新したアプリと MCP ツールを反映するには nanobot を再起動してください。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
||||||
@@ -684,7 +713,9 @@
|
|||||||
"loading": "自動タスクを読み込み中...",
|
"loading": "自動タスクを読み込み中...",
|
||||||
"noMatches": "この表示に一致する自動タスクはありません。",
|
"noMatches": "この表示に一致する自動タスクはありません。",
|
||||||
"empty": "自動タスクはまだありません。",
|
"empty": "自動タスクはまだありません。",
|
||||||
"emptyHint": "実行元から作成すると、nanobot が正しいコンテキストを保持できます。",
|
"emptyHint": "正しいコンテキストを保持するには、チャットで自動化を作成してください。",
|
||||||
|
"emptyAction": "チャットを開く",
|
||||||
|
"clearFilters": "フィルターをクリア",
|
||||||
"oneShot": "一回限り",
|
"oneShot": "一回限り",
|
||||||
"systemTask": "システム管理の自動タスク",
|
"systemTask": "システム管理の自動タスク",
|
||||||
"localTrigger": "ローカルトリガー",
|
"localTrigger": "ローカルトリガー",
|
||||||
@@ -1133,6 +1164,7 @@
|
|||||||
"recordingStatus": "録音中 {{time}}"
|
"recordingStatus": "録音中 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "セキュリティ上、Chrome などのブラウザーはリモートの HTTP ページからのマイクアクセスをブロックします。音声入力を使用するには、この WebUI を HTTPS で開いてください。",
|
||||||
"unsupported": "このブラウザーは音声入力に対応していません。",
|
"unsupported": "このブラウザーは音声入力に対応していません。",
|
||||||
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
"permission": "アドレスバーでマイクを許可してから再試行してください。",
|
||||||
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
"notConfigured": "先に文字起こしプロバイダーを設定してください。",
|
||||||
@@ -1366,7 +1398,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "ワークスペースは変更されませんでした",
|
"title": "ワークスペースは変更されませんでした",
|
||||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
"body": "このプロジェクトまたはアクセスモードはゲートウェイに拒否されました。既存のプロジェクトまたは別のアクセスモードを選択して、もう一度お試しください。"
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "メッセージは送信されませんでした",
|
"title": "メッセージは送信されませんでした",
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
"gatewayHint": "gateway(`nanobot gateway`)가 실행 중인지, 그리고 이 페이지가 같은 머신에서 열려 있는지 확인하세요."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "인증이 필요합니다",
|
"label": "비밀번호",
|
||||||
"hint": "gateway 설정의 tokenIssueSecret에 구성된 비밀 값을 입력하세요.",
|
"showPassword": "비밀번호 표시",
|
||||||
"placeholder": "비밀번호",
|
"hidePassword": "비밀번호 숨기기",
|
||||||
"submit": "연결",
|
"submit": "연결",
|
||||||
|
"required": "비밀번호를 입력하세요.",
|
||||||
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
"invalid": "비밀번호가 올바르지 않습니다. 다시 시도하세요."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "활성화됨",
|
"filterInstalled": "활성화됨",
|
||||||
"filterNotInstalled": "비활성",
|
"filterNotInstalled": "비활성",
|
||||||
"searchPlaceholder": "MCP 프리셋 검색",
|
"searchPlaceholder": "MCP 프리셋 검색",
|
||||||
"moreOptions": "추가 MCP 옵션",
|
"moreOptions": "MCP 서버 추가",
|
||||||
"moreOptionsSubtitle": "사용자 지정 서버를 추가하거나 mcp.json을 가져옵니다.",
|
"moreOptionsSubtitle": "사용자 지정 MCP 서버를 연결하거나 기존 구성을 가져옵니다.",
|
||||||
"customTitle": "사용자 지정 MCP",
|
"customTitle": "사용자 지정 MCP",
|
||||||
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
|
"customSubtitle": "stdio, HTTP 또는 SSE MCP 서버를 추가합니다.",
|
||||||
"customAction": "사용자 지정",
|
"customAction": "사용자 지정",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "서버 이름",
|
"serverName": "서버 이름",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "전송 방식",
|
"transport": "전송 방식",
|
||||||
|
"authentication": "인증",
|
||||||
|
"authNone": "없음",
|
||||||
|
"authHeaders": "헤더",
|
||||||
"command": "명령",
|
"command": "명령",
|
||||||
"args": "인자 JSON",
|
"args": "인자 JSON",
|
||||||
"headers": "헤더 JSON",
|
"headers": "헤더 JSON",
|
||||||
|
"oauthAfterSave": "서버를 저장한 다음 연결을 선택하여 로그인하세요.",
|
||||||
|
"headersHelp": "이 서버에서 사용하는 요청 헤더를 추가하세요.",
|
||||||
"env": "환경 변수 JSON",
|
"env": "환경 변수 JSON",
|
||||||
"timeout": "도구 제한 시간",
|
"timeout": "도구 제한 시간",
|
||||||
"advancedOptions": "고급 옵션",
|
"advancedOptions": "고급 옵션",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "기존 값을 유지하려면 비워 두세요",
|
"keepExisting": "기존 값을 유지하려면 비워 두세요",
|
||||||
"statusConfigured": "구성됨",
|
"statusConfigured": "구성됨",
|
||||||
"statusMissingCredentials": "키 필요",
|
"statusMissingCredentials": "키 필요",
|
||||||
|
"connectingAccount": "{{name}} 연결 중",
|
||||||
|
"connectingLabel": "연결 중…",
|
||||||
|
"continueSignIn": "로그인 계속",
|
||||||
|
"preparingSignIn": "안전한 로그인을 준비하는 중…",
|
||||||
|
"openSignInToContinue": "계속하려면 로그인 페이지를 여세요.",
|
||||||
|
"finishSignInInBrowser": "브라우저 창에서 로그인을 완료하세요.",
|
||||||
|
"manualCallbackRequired": "로그인을 완료한 다음 콜백 URL을 nanobot에 붙여 넣으세요.",
|
||||||
|
"manualCallbackHelp": "접근을 승인하면 localhost 페이지가 열리지 않습니다. 주소 표시줄에서 전체 URL을 복사해 여기에 붙여 넣으세요.",
|
||||||
|
"finishingConnection": "연결을 마무리하는 중…",
|
||||||
|
"activatingTools": "도구를 활성화하는 중…",
|
||||||
|
"connected": "연결됨.",
|
||||||
|
"connectionFailed": "연결에 실패했습니다.",
|
||||||
|
"connectionCancelled": "연결을 취소했습니다.",
|
||||||
|
"reloadFailed": "로그인했지만 nanobot에서 도구를 연결하지 못했습니다. nanobot을 다시 시작해 보세요.",
|
||||||
|
"oauthFailed": "연결할 수 없습니다. 다시 로그인해 보세요.",
|
||||||
"statusMissingDependency": "의존성 필요",
|
"statusMissingDependency": "의존성 필요",
|
||||||
"statusComingSoon": "곧 제공",
|
"statusComingSoon": "곧 제공",
|
||||||
"comingSoon": "곧 제공",
|
"comingSoon": "곧 제공",
|
||||||
@@ -569,20 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||||
"cliLabel": "앱",
|
"cliLabel": "앱",
|
||||||
"mcpLabel": "연동",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
"featureLabel": "기능",
|
"featureLabel": "기능",
|
||||||
"filterAll": "사용 가능",
|
"filterAll": "사용 가능",
|
||||||
"filterPlugins": "플러그인",
|
"filterPlugins": "플러그인",
|
||||||
"filterCli": "앱",
|
"filterCli": "앱",
|
||||||
"filterMcp": "연동",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}}개 사용 가능",
|
"enabledSummary": "{{count}}개 사용 가능",
|
||||||
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
|
"caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개",
|
||||||
"searchPlaceholder": "앱 검색",
|
"searchPlaceholder": "앱 검색",
|
||||||
"featured": "도구",
|
"featured": "도구",
|
||||||
|
"mcpTools": "MCP 도구",
|
||||||
"loading": "앱을 불러오는 중...",
|
"loading": "앱을 불러오는 중...",
|
||||||
"empty": "이 보기에 일치하는 도구가 없습니다.",
|
"empty": "검색과 일치하는 도구가 없습니다.",
|
||||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
"emptyApps": "사용 가능한 앱이 없습니다.",
|
||||||
|
"emptyIntegrations": "사용 가능한 MCP 도구가 없습니다.",
|
||||||
|
"emptyReady": "아직 준비된 도구가 없습니다.",
|
||||||
|
"clearSearch": "검색 지우기",
|
||||||
|
"browseApps": "앱 둘러보기",
|
||||||
|
"browseIntegrations": "MCP 도구 둘러보기",
|
||||||
|
"emptyIntegrationsHint": "아래에서 사용자 지정 MCP 서버를 추가하세요.",
|
||||||
|
"restartRequired": "업데이트된 앱과 MCP 도구를 적용하려면 nanobot을 다시 시작하세요."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
||||||
@@ -684,7 +713,9 @@
|
|||||||
"loading": "자동화를 불러오는 중...",
|
"loading": "자동화를 불러오는 중...",
|
||||||
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
"noMatches": "이 보기와 일치하는 자동화가 없습니다.",
|
||||||
"empty": "아직 자동화가 없습니다.",
|
"empty": "아직 자동화가 없습니다.",
|
||||||
"emptyHint": "실행될 위치에서 만들면 nanobot이 올바른 컨텍스트를 유지합니다.",
|
"emptyHint": "올바른 컨텍스트를 유지하려면 채팅에서 자동화를 만드세요.",
|
||||||
|
"emptyAction": "채팅 열기",
|
||||||
|
"clearFilters": "필터 지우기",
|
||||||
"oneShot": "일회성",
|
"oneShot": "일회성",
|
||||||
"systemTask": "시스템 관리 자동화",
|
"systemTask": "시스템 관리 자동화",
|
||||||
"localTrigger": "로컬 트리거",
|
"localTrigger": "로컬 트리거",
|
||||||
@@ -1133,6 +1164,7 @@
|
|||||||
"recordingStatus": "녹음 중 {{time}}"
|
"recordingStatus": "녹음 중 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "보안을 위해 Chrome 및 기타 브라우저는 원격 HTTP 페이지의 마이크 접근을 차단합니다. 음성 입력을 사용하려면 HTTPS로 이 WebUI를 여세요.",
|
||||||
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
"unsupported": "이 브라우저는 음성 입력을 지원하지 않습니다.",
|
||||||
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
"permission": "주소 표시줄에서 마이크를 허용한 후 다시 시도하세요.",
|
||||||
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
"notConfigured": "먼저 음성 변환 제공업체를 설정하세요.",
|
||||||
@@ -1366,7 +1398,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "작업공간이 변경되지 않았습니다",
|
"title": "작업공간이 변경되지 않았습니다",
|
||||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
"body": "게이트웨이가 이 프로젝트 또는 접근 모드를 거부했습니다. 기존 프로젝트나 다른 접근 모드를 선택한 후 다시 시도하세요."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "메시지가 전송되지 않았습니다",
|
"title": "메시지가 전송되지 않았습니다",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
|
"gatewayHint": "Verifique se o gateway está em execução (`nanobot gateway`) e se esta página está aberta na mesma máquina."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Autenticação necessária",
|
"label": "Senha",
|
||||||
"hint": "Informe o segredo configurado como tokenIssueSecret na configuração do gateway.",
|
"showPassword": "Mostrar senha",
|
||||||
"placeholder": "Senha",
|
"hidePassword": "Ocultar senha",
|
||||||
"submit": "Conectar",
|
"submit": "Conectar",
|
||||||
"invalid": "Senha inválida. Tente novamente."
|
"required": "Digite a senha.",
|
||||||
|
"invalid": "Senha incorreta. Tente novamente."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Conta",
|
"section": "Conta",
|
||||||
@@ -318,8 +319,8 @@
|
|||||||
"filterInstalled": "Habilitadas",
|
"filterInstalled": "Habilitadas",
|
||||||
"filterNotInstalled": "Não habilitadas",
|
"filterNotInstalled": "Não habilitadas",
|
||||||
"searchPlaceholder": "Buscar predefinições MCP",
|
"searchPlaceholder": "Buscar predefinições MCP",
|
||||||
"moreOptions": "Adicionar integração",
|
"moreOptions": "Adicionar servidor MCP",
|
||||||
"moreOptionsSubtitle": "Conecte um servidor de ferramentas personalizado ou importe uma configuração existente.",
|
"moreOptionsSubtitle": "Conecte um servidor MCP personalizado ou importe uma configuração existente.",
|
||||||
"customTitle": "MCP personalizado",
|
"customTitle": "MCP personalizado",
|
||||||
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
|
"customSubtitle": "Adicione qualquer servidor MCP stdio, HTTP ou SSE.",
|
||||||
"customAction": "Personalizado",
|
"customAction": "Personalizado",
|
||||||
@@ -327,9 +328,14 @@
|
|||||||
"serverName": "Nome do servidor",
|
"serverName": "Nome do servidor",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Transporte",
|
"transport": "Transporte",
|
||||||
|
"authentication": "Autenticação",
|
||||||
|
"authNone": "Nenhuma",
|
||||||
|
"authHeaders": "Cabeçalhos",
|
||||||
"command": "Comando",
|
"command": "Comando",
|
||||||
"args": "Argumentos JSON",
|
"args": "Argumentos JSON",
|
||||||
"headers": "Cabeçalhos JSON",
|
"headers": "Cabeçalhos JSON",
|
||||||
|
"oauthAfterSave": "Salve o servidor e selecione Conectar para entrar.",
|
||||||
|
"headersHelp": "Adicione os cabeçalhos de solicitação usados por este servidor.",
|
||||||
"env": "Ambiente JSON",
|
"env": "Ambiente JSON",
|
||||||
"timeout": "Tempo limite da ferramenta",
|
"timeout": "Tempo limite da ferramenta",
|
||||||
"advancedOptions": "Opções avançadas",
|
"advancedOptions": "Opções avançadas",
|
||||||
@@ -356,6 +362,21 @@
|
|||||||
"keepExisting": "Deixe em branco para manter o valor atual",
|
"keepExisting": "Deixe em branco para manter o valor atual",
|
||||||
"statusConfigured": "Configurado",
|
"statusConfigured": "Configurado",
|
||||||
"statusMissingCredentials": "Precisa de chave",
|
"statusMissingCredentials": "Precisa de chave",
|
||||||
|
"connectingAccount": "Conectando {{name}}",
|
||||||
|
"connectingLabel": "Conectando…",
|
||||||
|
"continueSignIn": "Continuar login",
|
||||||
|
"preparingSignIn": "Preparando login seguro…",
|
||||||
|
"openSignInToContinue": "Abra a página de login para continuar.",
|
||||||
|
"finishSignInInBrowser": "Conclua o login na janela do navegador.",
|
||||||
|
"manualCallbackRequired": "Conclua o login e cole a URL de callback no nanobot.",
|
||||||
|
"manualCallbackHelp": "Depois de autorizar o acesso, a página localhost não será carregada. Copie a URL completa da barra de endereços e cole-a aqui.",
|
||||||
|
"finishingConnection": "Finalizando conexão…",
|
||||||
|
"activatingTools": "Ativando ferramentas…",
|
||||||
|
"connected": "Conectado.",
|
||||||
|
"connectionFailed": "Falha na conexão.",
|
||||||
|
"connectionCancelled": "Conexão cancelada.",
|
||||||
|
"reloadFailed": "Login concluído, mas o nanobot não conseguiu conectar as ferramentas. Tente reiniciar o nanobot.",
|
||||||
|
"oauthFailed": "Não foi possível conectar. Tente fazer login novamente.",
|
||||||
"statusMissingDependency": "Precisa de dependência",
|
"statusMissingDependency": "Precisa de dependência",
|
||||||
"statusComingSoon": "Em breve",
|
"statusComingSoon": "Em breve",
|
||||||
"comingSoon": "Em breve",
|
"comingSoon": "Em breve",
|
||||||
@@ -583,20 +604,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||||
"cliLabel": "Aplicativo",
|
"cliLabel": "Aplicativo",
|
||||||
"mcpLabel": "Integração",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
"filterAll": "Prontos",
|
"filterAll": "Prontos",
|
||||||
"filterPlugins": "Complementos",
|
"filterPlugins": "Complementos",
|
||||||
"filterCli": "Aplicativos",
|
"filterCli": "Aplicativos",
|
||||||
"filterMcp": "Integrações",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} prontos",
|
"enabledSummary": "{{count}} prontos",
|
||||||
"caption": "{{cli}} aplicativos · {{mcp}} integrações",
|
"caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP",
|
||||||
"searchPlaceholder": "Buscar ferramentas",
|
"searchPlaceholder": "Buscar ferramentas",
|
||||||
"featured": "Ferramentas",
|
"featured": "Ferramentas",
|
||||||
|
"mcpTools": "Ferramentas MCP",
|
||||||
"loading": "Carregando aplicativos...",
|
"loading": "Carregando aplicativos...",
|
||||||
"empty": "Nenhuma ferramenta corresponde a esta visualização.",
|
"empty": "Nenhuma ferramenta corresponde à sua busca.",
|
||||||
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e integrações atualizados."
|
"emptyApps": "Nenhum aplicativo disponível.",
|
||||||
|
"emptyIntegrations": "Nenhuma ferramenta MCP disponível.",
|
||||||
|
"emptyReady": "Ainda não há ferramentas prontas.",
|
||||||
|
"clearSearch": "Limpar busca",
|
||||||
|
"browseApps": "Explorar aplicativos",
|
||||||
|
"browseIntegrations": "Explorar ferramentas MCP",
|
||||||
|
"emptyIntegrationsHint": "Adicione um servidor MCP personalizado abaixo.",
|
||||||
|
"restartRequired": "Reinicie o nanobot para aplicar os aplicativos e ferramentas MCP atualizados."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
|
"description": "Conecte aplicativos de chat, e-mail e WebUI ao nanobot.",
|
||||||
@@ -698,7 +727,9 @@
|
|||||||
"loading": "Carregando automações...",
|
"loading": "Carregando automações...",
|
||||||
"noMatches": "Nenhuma automação corresponde a esta visualização.",
|
"noMatches": "Nenhuma automação corresponde a esta visualização.",
|
||||||
"empty": "Nenhuma automação ainda.",
|
"empty": "Nenhuma automação ainda.",
|
||||||
"emptyHint": "Crie uma de onde ela deve rodar para que o nanobot mantenha o contexto correto.",
|
"emptyHint": "Crie automações em uma conversa para que mantenham o contexto correto.",
|
||||||
|
"emptyAction": "Abrir uma conversa",
|
||||||
|
"clearFilters": "Limpar filtros",
|
||||||
"oneShot": "Uma vez",
|
"oneShot": "Uma vez",
|
||||||
"systemTask": "Automação gerenciada pelo sistema",
|
"systemTask": "Automação gerenciada pelo sistema",
|
||||||
"localTrigger": "Gatilho local",
|
"localTrigger": "Gatilho local",
|
||||||
@@ -1147,6 +1178,7 @@
|
|||||||
"recordingStatus": "Gravando {{time}}"
|
"recordingStatus": "Gravando {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"unsupported": "A entrada de voz não é compatível com este navegador.",
|
||||||
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
"permission": "Permita o microfone na barra de endereço e tente novamente.",
|
||||||
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
"notConfigured": "Configure primeiro um provedor de transcrição.",
|
||||||
@@ -1380,7 +1412,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "O espaço de trabalho não foi alterado",
|
"title": "O espaço de trabalho não foi alterado",
|
||||||
"body": "O nanobot manteve o espaço de trabalho anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
"body": "O gateway rejeitou este projeto ou modo de acesso. Escolha um projeto existente ou outro modo de acesso e tente novamente."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "A mensagem não foi enviada",
|
"title": "A mensagem não foi enviada",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
"gatewayHint": "Hãy chắc chắn gateway đang chạy (`nanobot gateway`) và trang này được mở trên cùng máy."
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Cần xác thực",
|
"label": "Mật khẩu",
|
||||||
"hint": "Nhập secret được cấu hình là tokenIssueSecret trong cấu hình gateway.",
|
"showPassword": "Hiện mật khẩu",
|
||||||
"placeholder": "Mật khẩu",
|
"hidePassword": "Ẩn mật khẩu",
|
||||||
"submit": "Kết nối",
|
"submit": "Kết nối",
|
||||||
"invalid": "Mật khẩu không hợp lệ. Hãy thử lại."
|
"required": "Nhập mật khẩu.",
|
||||||
|
"invalid": "Mật khẩu không đúng. Hãy thử lại."
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "Tài khoản",
|
"section": "Tài khoản",
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "Đã bật",
|
"filterInstalled": "Đã bật",
|
||||||
"filterNotInstalled": "Chưa bật",
|
"filterNotInstalled": "Chưa bật",
|
||||||
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
|
"searchPlaceholder": "Tìm cấu hình đặt trước MCP",
|
||||||
"moreOptions": "Tùy chọn MCP khác",
|
"moreOptions": "Thêm máy chủ MCP",
|
||||||
"moreOptionsSubtitle": "Thêm máy chủ tùy chỉnh hoặc nhập mcp.json.",
|
"moreOptionsSubtitle": "Kết nối máy chủ MCP tùy chỉnh hoặc nhập cấu hình hiện có.",
|
||||||
"customTitle": "MCP tùy chỉnh",
|
"customTitle": "MCP tùy chỉnh",
|
||||||
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
|
"customSubtitle": "Thêm bất kỳ máy chủ MCP stdio, HTTP hoặc SSE nào.",
|
||||||
"customAction": "Tùy chỉnh",
|
"customAction": "Tùy chỉnh",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "Tên máy chủ",
|
"serverName": "Tên máy chủ",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "Giao thức truyền",
|
"transport": "Giao thức truyền",
|
||||||
|
"authentication": "Xác thực",
|
||||||
|
"authNone": "Không có",
|
||||||
|
"authHeaders": "Header",
|
||||||
"command": "Lệnh",
|
"command": "Lệnh",
|
||||||
"args": "Đối số JSON",
|
"args": "Đối số JSON",
|
||||||
"headers": "Header JSON",
|
"headers": "Header JSON",
|
||||||
|
"oauthAfterSave": "Lưu máy chủ, sau đó chọn Kết nối để đăng nhập.",
|
||||||
|
"headersHelp": "Thêm các header yêu cầu mà máy chủ này sử dụng.",
|
||||||
"env": "Môi trường JSON",
|
"env": "Môi trường JSON",
|
||||||
"timeout": "Thời gian chờ công cụ",
|
"timeout": "Thời gian chờ công cụ",
|
||||||
"advancedOptions": "Tùy chọn nâng cao",
|
"advancedOptions": "Tùy chọn nâng cao",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "Để trống để giữ giá trị hiện tại",
|
"keepExisting": "Để trống để giữ giá trị hiện tại",
|
||||||
"statusConfigured": "Đã cấu hình",
|
"statusConfigured": "Đã cấu hình",
|
||||||
"statusMissingCredentials": "Cần khóa",
|
"statusMissingCredentials": "Cần khóa",
|
||||||
|
"connectingAccount": "Đang kết nối {{name}}",
|
||||||
|
"connectingLabel": "Đang kết nối…",
|
||||||
|
"continueSignIn": "Tiếp tục đăng nhập",
|
||||||
|
"preparingSignIn": "Đang chuẩn bị đăng nhập an toàn…",
|
||||||
|
"openSignInToContinue": "Mở trang đăng nhập để tiếp tục.",
|
||||||
|
"finishSignInInBrowser": "Hoàn tất đăng nhập trong cửa sổ trình duyệt.",
|
||||||
|
"manualCallbackRequired": "Hoàn tất đăng nhập, rồi dán URL callback vào nanobot.",
|
||||||
|
"manualCallbackHelp": "Sau khi phê duyệt quyền truy cập, trang localhost sẽ không tải được. Hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào đây.",
|
||||||
|
"finishingConnection": "Đang hoàn tất kết nối…",
|
||||||
|
"activatingTools": "Đang kích hoạt công cụ…",
|
||||||
|
"connected": "Đã kết nối.",
|
||||||
|
"connectionFailed": "Kết nối thất bại.",
|
||||||
|
"connectionCancelled": "Đã hủy kết nối.",
|
||||||
|
"reloadFailed": "Đã đăng nhập nhưng nanobot không thể kết nối các công cụ. Hãy thử khởi động lại nanobot.",
|
||||||
|
"oauthFailed": "Không thể kết nối. Hãy thử đăng nhập lại.",
|
||||||
"statusMissingDependency": "Cần phụ thuộc",
|
"statusMissingDependency": "Cần phụ thuộc",
|
||||||
"statusComingSoon": "Sắp ra mắt",
|
"statusComingSoon": "Sắp ra mắt",
|
||||||
"comingSoon": "Sắp ra mắt",
|
"comingSoon": "Sắp ra mắt",
|
||||||
@@ -569,20 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||||
"cliLabel": "Ứng dụng",
|
"cliLabel": "Ứng dụng",
|
||||||
"mcpLabel": "Tích hợp",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
"featureLabel": "Tính năng",
|
"featureLabel": "Tính năng",
|
||||||
"filterAll": "Sẵn sàng",
|
"filterAll": "Sẵn sàng",
|
||||||
"filterPlugins": "Plugin",
|
"filterPlugins": "Plugin",
|
||||||
"filterCli": "Ứng dụng",
|
"filterCli": "Ứng dụng",
|
||||||
"filterMcp": "Tích hợp",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} sẵn sàng",
|
"enabledSummary": "{{count}} sẵn sàng",
|
||||||
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
|
"caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP",
|
||||||
"searchPlaceholder": "Tìm ứng dụng",
|
"searchPlaceholder": "Tìm ứng dụng",
|
||||||
"featured": "Công cụ",
|
"featured": "Công cụ",
|
||||||
|
"mcpTools": "Công cụ MCP",
|
||||||
"loading": "Đang tải ứng dụng...",
|
"loading": "Đang tải ứng dụng...",
|
||||||
"empty": "Không có công cụ phù hợp với chế độ xem này.",
|
"empty": "Không có công cụ phù hợp với tìm kiếm của bạn.",
|
||||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
"emptyApps": "Không có ứng dụng nào.",
|
||||||
|
"emptyIntegrations": "Không có công cụ MCP nào.",
|
||||||
|
"emptyReady": "Chưa có công cụ nào sẵn sàng.",
|
||||||
|
"clearSearch": "Xóa tìm kiếm",
|
||||||
|
"browseApps": "Xem ứng dụng",
|
||||||
|
"browseIntegrations": "Xem công cụ MCP",
|
||||||
|
"emptyIntegrationsHint": "Thêm máy chủ MCP tùy chỉnh ở bên dưới.",
|
||||||
|
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và công cụ MCP đã cập nhật."
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
|
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình không gian làm việc.",
|
||||||
@@ -684,7 +713,9 @@
|
|||||||
"loading": "Đang tải tự động hóa...",
|
"loading": "Đang tải tự động hóa...",
|
||||||
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
"noMatches": "Không có tự động hóa phù hợp với chế độ xem này.",
|
||||||
"empty": "Chưa có tự động hóa.",
|
"empty": "Chưa có tự động hóa.",
|
||||||
"emptyHint": "Tạo từ nơi tác vụ sẽ chạy để nanobot giữ đúng ngữ cảnh.",
|
"emptyHint": "Tạo tác vụ tự động trong cuộc trò chuyện để giữ đúng ngữ cảnh.",
|
||||||
|
"emptyAction": "Mở cuộc trò chuyện",
|
||||||
|
"clearFilters": "Xóa bộ lọc",
|
||||||
"oneShot": "Một lần",
|
"oneShot": "Một lần",
|
||||||
"systemTask": "Tự động hóa do hệ thống quản lý",
|
"systemTask": "Tự động hóa do hệ thống quản lý",
|
||||||
"localTrigger": "Trình kích hoạt cục bộ",
|
"localTrigger": "Trình kích hoạt cục bộ",
|
||||||
@@ -1133,6 +1164,7 @@
|
|||||||
"recordingStatus": "Đang ghi {{time}}"
|
"recordingStatus": "Đang ghi {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"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.",
|
"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.",
|
"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.",
|
"notConfigured": "Hãy cấu hình nhà cung cấp chép lời trước.",
|
||||||
@@ -1366,7 +1398,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Không gian làm việc không thay đổi",
|
"title": "Không gian làm việc không thay đổi",
|
||||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ không gian làm việc trước đó."
|
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập này. Chọn dự án hiện có hoặc chế độ truy cập khác rồi thử lại."
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "Tin nhắn chưa được gửi",
|
"title": "Tin nhắn chưa được gửi",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
"gatewayHint": "请确认网关已启动(`nanobot gateway`),并且当前页面与网关运行在同一台机器上。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "需要验证",
|
"label": "密码",
|
||||||
"hint": "请输入网关配置中的 tokenIssueSecret。",
|
"showPassword": "显示密码",
|
||||||
"placeholder": "密码",
|
"hidePassword": "隐藏密码",
|
||||||
"submit": "连接",
|
"submit": "连接",
|
||||||
"invalid": "密码无效,请重试。"
|
"required": "请输入密码。",
|
||||||
|
"invalid": "密码错误,请重试。"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "账户",
|
"section": "账户",
|
||||||
@@ -318,8 +319,8 @@
|
|||||||
"filterInstalled": "已启用",
|
"filterInstalled": "已启用",
|
||||||
"filterNotInstalled": "未启用",
|
"filterNotInstalled": "未启用",
|
||||||
"searchPlaceholder": "搜索 MCP 预设",
|
"searchPlaceholder": "搜索 MCP 预设",
|
||||||
"moreOptions": "添加集成",
|
"moreOptions": "添加 MCP 服务",
|
||||||
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
|
"moreOptionsSubtitle": "连接自定义 MCP 服务,或导入已有配置。",
|
||||||
"customTitle": "自定义 MCP",
|
"customTitle": "自定义 MCP",
|
||||||
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
||||||
"customAction": "自定义",
|
"customAction": "自定义",
|
||||||
@@ -327,9 +328,14 @@
|
|||||||
"serverName": "服务名",
|
"serverName": "服务名",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "传输方式",
|
"transport": "传输方式",
|
||||||
|
"authentication": "身份验证",
|
||||||
|
"authNone": "无",
|
||||||
|
"authHeaders": "请求头",
|
||||||
"command": "命令",
|
"command": "命令",
|
||||||
"args": "Args JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Headers JSON",
|
"headers": "请求头 JSON",
|
||||||
|
"oauthAfterSave": "保存服务器后,选择“连接”以完成登录。",
|
||||||
|
"headersHelp": "添加此服务器要求的请求头。",
|
||||||
"env": "Env JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "工具超时",
|
"timeout": "工具超时",
|
||||||
"advancedOptions": "高级选项",
|
"advancedOptions": "高级选项",
|
||||||
@@ -356,6 +362,21 @@
|
|||||||
"keepExisting": "留空则保留当前值",
|
"keepExisting": "留空则保留当前值",
|
||||||
"statusConfigured": "已配置",
|
"statusConfigured": "已配置",
|
||||||
"statusMissingCredentials": "需要密钥",
|
"statusMissingCredentials": "需要密钥",
|
||||||
|
"connectingAccount": "正在连接 {{name}}",
|
||||||
|
"connectingLabel": "正在连接…",
|
||||||
|
"continueSignIn": "继续登录",
|
||||||
|
"preparingSignIn": "正在准备安全登录…",
|
||||||
|
"openSignInToContinue": "打开登录页面以继续。",
|
||||||
|
"finishSignInInBrowser": "请在浏览器窗口中完成登录。",
|
||||||
|
"manualCallbackRequired": "完成登录后,将回调 URL 粘贴到 nanobot。",
|
||||||
|
"manualCallbackHelp": "授权后,localhost 页面将无法打开。请复制地址栏中的完整 URL 并粘贴到这里。",
|
||||||
|
"finishingConnection": "正在完成连接…",
|
||||||
|
"activatingTools": "正在启用工具…",
|
||||||
|
"connected": "已连接。",
|
||||||
|
"connectionFailed": "连接失败。",
|
||||||
|
"connectionCancelled": "已取消连接。",
|
||||||
|
"reloadFailed": "已登录,但 nanobot 无法连接这些工具。请尝试重启 nanobot。",
|
||||||
|
"oauthFailed": "无法连接,请重新登录。",
|
||||||
"statusMissingDependency": "缺少依赖",
|
"statusMissingDependency": "缺少依赖",
|
||||||
"statusComingSoon": "暂不支持",
|
"statusComingSoon": "暂不支持",
|
||||||
"comingSoon": "即将推出",
|
"comingSoon": "即将推出",
|
||||||
@@ -583,20 +604,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "集成",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
"featureLabel": "能力",
|
"featureLabel": "能力",
|
||||||
"filterAll": "可用",
|
"filterAll": "可用",
|
||||||
"filterPlugins": "插件",
|
"filterPlugins": "插件",
|
||||||
"filterCli": "应用",
|
"filterCli": "应用",
|
||||||
"filterMcp": "集成",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} 个可用",
|
"enabledSummary": "{{count}} 个可用",
|
||||||
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
|
"caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具",
|
||||||
"searchPlaceholder": "搜索工具",
|
"searchPlaceholder": "搜索工具",
|
||||||
"featured": "工具",
|
"featured": "工具",
|
||||||
|
"mcpTools": "MCP 工具",
|
||||||
"loading": "正在加载应用...",
|
"loading": "正在加载应用...",
|
||||||
"empty": "当前视图没有匹配的工具。",
|
"empty": "没有与搜索条件匹配的工具。",
|
||||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
"emptyApps": "暂无可用应用。",
|
||||||
|
"emptyIntegrations": "暂无可用 MCP 工具。",
|
||||||
|
"emptyReady": "还没有就绪的工具。",
|
||||||
|
"clearSearch": "清除搜索",
|
||||||
|
"browseApps": "浏览应用",
|
||||||
|
"browseIntegrations": "浏览 MCP 工具",
|
||||||
|
"emptyIntegrationsHint": "可在下方添加自定义 MCP 服务器。",
|
||||||
|
"restartRequired": "重启 nanobot 以应用更新后的应用和 MCP 工具。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
||||||
@@ -698,7 +727,9 @@
|
|||||||
"loading": "正在加载自动任务...",
|
"loading": "正在加载自动任务...",
|
||||||
"noMatches": "当前视图没有匹配的自动任务。",
|
"noMatches": "当前视图没有匹配的自动任务。",
|
||||||
"empty": "暂无自动任务。",
|
"empty": "暂无自动任务。",
|
||||||
"emptyHint": "请从它应该运行的来源处创建,这样 nanobot 才能保留正确上下文。",
|
"emptyHint": "请在对话中创建自动任务,以便保留正确的上下文。",
|
||||||
|
"emptyAction": "打开对话",
|
||||||
|
"clearFilters": "清除筛选",
|
||||||
"oneShot": "一次性",
|
"oneShot": "一次性",
|
||||||
"systemTask": "系统管理的自动任务",
|
"systemTask": "系统管理的自动任务",
|
||||||
"localTrigger": "本地触发器",
|
"localTrigger": "本地触发器",
|
||||||
@@ -1146,6 +1177,7 @@
|
|||||||
"recordingStatus": "正在录音 {{time}}"
|
"recordingStatus": "正在录音 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "出于安全考虑,Chrome 等浏览器会阻止远程 HTTP 页面访问麦克风。请通过 HTTPS 打开此 WebUI 以使用语音输入。",
|
||||||
"unsupported": "当前浏览器不支持语音输入。",
|
"unsupported": "当前浏览器不支持语音输入。",
|
||||||
"permission": "请在地址栏允许麦克风后重试。",
|
"permission": "请在地址栏允许麦克风后重试。",
|
||||||
"notConfigured": "请先配置转写提供商。",
|
"notConfigured": "请先配置转写提供商。",
|
||||||
@@ -1380,7 +1412,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "工作区未更改",
|
"title": "工作区未更改",
|
||||||
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
"body": "网关拒绝了此项目或访问权限。请选择已存在的项目或其他访问权限,然后重试。"
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "消息未发送",
|
"title": "消息未发送",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
|
"gatewayHint": "請確認閘道已啟動(`nanobot gateway`),並且目前頁面與閘道在同一台機器上開啟。"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "需要驗證",
|
"label": "密碼",
|
||||||
"hint": "請輸入閘道設定中 tokenIssueSecret 所設定的金鑰。",
|
"showPassword": "顯示密碼",
|
||||||
"placeholder": "密碼",
|
"hidePassword": "隱藏密碼",
|
||||||
"submit": "連線",
|
"submit": "連線",
|
||||||
"invalid": "密碼無效,請再試一次。"
|
"required": "請輸入密碼。",
|
||||||
|
"invalid": "密碼錯誤,請再試一次。"
|
||||||
},
|
},
|
||||||
"account": {
|
"account": {
|
||||||
"section": "帳戶",
|
"section": "帳戶",
|
||||||
@@ -503,8 +504,8 @@
|
|||||||
"filterInstalled": "已啟用",
|
"filterInstalled": "已啟用",
|
||||||
"filterNotInstalled": "未啟用",
|
"filterNotInstalled": "未啟用",
|
||||||
"searchPlaceholder": "搜尋 MCP 預設",
|
"searchPlaceholder": "搜尋 MCP 預設",
|
||||||
"moreOptions": "新增整合",
|
"moreOptions": "新增 MCP 服務",
|
||||||
"moreOptionsSubtitle": "連線自訂工具伺服器,或匯入現有設定。",
|
"moreOptionsSubtitle": "連線自訂 MCP 服務,或匯入現有設定。",
|
||||||
"customTitle": "自訂 MCP",
|
"customTitle": "自訂 MCP",
|
||||||
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
|
"customSubtitle": "新增任何 stdio、HTTP 或 SSE MCP 伺服器。",
|
||||||
"customAction": "自訂",
|
"customAction": "自訂",
|
||||||
@@ -512,9 +513,14 @@
|
|||||||
"serverName": "伺服器名稱",
|
"serverName": "伺服器名稱",
|
||||||
"serverUrl": "URL",
|
"serverUrl": "URL",
|
||||||
"transport": "傳輸方式",
|
"transport": "傳輸方式",
|
||||||
|
"authentication": "驗證方式",
|
||||||
|
"authNone": "無",
|
||||||
|
"authHeaders": "請求標頭",
|
||||||
"command": "指令",
|
"command": "指令",
|
||||||
"args": "Args JSON",
|
"args": "Args JSON",
|
||||||
"headers": "Headers JSON",
|
"headers": "請求標頭 JSON",
|
||||||
|
"oauthAfterSave": "儲存伺服器後,選擇「連線」以登入。",
|
||||||
|
"headersHelp": "新增此伺服器使用的請求標頭。",
|
||||||
"env": "Env JSON",
|
"env": "Env JSON",
|
||||||
"timeout": "工具逾時",
|
"timeout": "工具逾時",
|
||||||
"advancedOptions": "進階選項",
|
"advancedOptions": "進階選項",
|
||||||
@@ -541,6 +547,21 @@
|
|||||||
"keepExisting": "留空以保留目前值",
|
"keepExisting": "留空以保留目前值",
|
||||||
"statusConfigured": "已設定",
|
"statusConfigured": "已設定",
|
||||||
"statusMissingCredentials": "需要金鑰",
|
"statusMissingCredentials": "需要金鑰",
|
||||||
|
"connectingAccount": "正在連接 {{name}}",
|
||||||
|
"connectingLabel": "正在連線…",
|
||||||
|
"continueSignIn": "繼續登入",
|
||||||
|
"preparingSignIn": "正在準備安全登入…",
|
||||||
|
"openSignInToContinue": "開啟登入頁面以繼續。",
|
||||||
|
"finishSignInInBrowser": "請在瀏覽器視窗中完成登入。",
|
||||||
|
"manualCallbackRequired": "完成登入後,將回呼 URL 貼到 nanobot。",
|
||||||
|
"manualCallbackHelp": "授權後,localhost 頁面將無法開啟。請複製網址列中的完整 URL 並貼到這裡。",
|
||||||
|
"finishingConnection": "正在完成連線…",
|
||||||
|
"activatingTools": "正在啟用工具…",
|
||||||
|
"connected": "已連線。",
|
||||||
|
"connectionFailed": "連線失敗。",
|
||||||
|
"connectionCancelled": "已取消連線。",
|
||||||
|
"reloadFailed": "已登入,但 nanobot 無法連接這些工具。請嘗試重新啟動 nanobot。",
|
||||||
|
"oauthFailed": "無法連線,請重新登入。",
|
||||||
"statusMissingDependency": "需要相依項",
|
"statusMissingDependency": "需要相依項",
|
||||||
"statusComingSoon": "即將推出",
|
"statusComingSoon": "即將推出",
|
||||||
"comingSoon": "即將推出",
|
"comingSoon": "即將推出",
|
||||||
@@ -569,20 +590,28 @@
|
|||||||
"apps": {
|
"apps": {
|
||||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||||
"cliLabel": "應用程式",
|
"cliLabel": "應用程式",
|
||||||
"mcpLabel": "整合",
|
"mcpLabel": "MCP",
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
"featureLabel": "功能",
|
"featureLabel": "功能",
|
||||||
"filterAll": "就緒",
|
"filterAll": "就緒",
|
||||||
"filterPlugins": "外掛程式",
|
"filterPlugins": "外掛程式",
|
||||||
"filterCli": "應用程式",
|
"filterCli": "應用程式",
|
||||||
"filterMcp": "整合",
|
"filterMcp": "MCP",
|
||||||
"enabledSummary": "{{count}} 個就緒",
|
"enabledSummary": "{{count}} 個就緒",
|
||||||
"caption": "{{cli}} 個應用程式 · {{mcp}} 個整合服務",
|
"caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具",
|
||||||
"searchPlaceholder": "搜尋工具",
|
"searchPlaceholder": "搜尋工具",
|
||||||
"featured": "工具",
|
"featured": "工具",
|
||||||
|
"mcpTools": "MCP 工具",
|
||||||
"loading": "正在載入應用程式…",
|
"loading": "正在載入應用程式…",
|
||||||
"empty": "沒有符合條件的工具。",
|
"empty": "沒有符合搜尋條件的工具。",
|
||||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與整合服務。"
|
"emptyApps": "沒有可用的應用程式。",
|
||||||
|
"emptyIntegrations": "沒有可用的 MCP 工具。",
|
||||||
|
"emptyReady": "尚無就緒的工具。",
|
||||||
|
"clearSearch": "清除搜尋",
|
||||||
|
"browseApps": "瀏覽應用程式",
|
||||||
|
"browseIntegrations": "瀏覽 MCP 工具",
|
||||||
|
"emptyIntegrationsHint": "可在下方新增自訂 MCP 伺服器。",
|
||||||
|
"restartRequired": "重新啟動 nanobot 以套用更新後的應用程式與 MCP 工具。"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
|
"description": "將聊天應用程式、電子郵件與 WebUI 連線至 nanobot。",
|
||||||
@@ -684,7 +713,9 @@
|
|||||||
"loading": "正在載入自動任務…",
|
"loading": "正在載入自動任務…",
|
||||||
"noMatches": "目前沒有符合條件的自動任務。",
|
"noMatches": "目前沒有符合條件的自動任務。",
|
||||||
"empty": "尚無自動任務。",
|
"empty": "尚無自動任務。",
|
||||||
"emptyHint": "請從自動任務預定執行的對話中建立,讓 nanobot 保留正確的對話脈絡。",
|
"emptyHint": "請在聊天中建立自動任務,以保留正確的對話脈絡。",
|
||||||
|
"emptyAction": "開啟聊天",
|
||||||
|
"clearFilters": "清除篩選",
|
||||||
"oneShot": "單次",
|
"oneShot": "單次",
|
||||||
"systemTask": "系統管理的自動任務",
|
"systemTask": "系統管理的自動任務",
|
||||||
"localTrigger": "本機觸發器",
|
"localTrigger": "本機觸發器",
|
||||||
@@ -1133,6 +1164,7 @@
|
|||||||
"recordingStatus": "正在錄音 {{time}}"
|
"recordingStatus": "正在錄音 {{time}}"
|
||||||
},
|
},
|
||||||
"voiceErrors": {
|
"voiceErrors": {
|
||||||
|
"insecureContext": "基於安全考量,Chrome 等瀏覽器會阻止遠端 HTTP 頁面存取麥克風。請透過 HTTPS 開啟此 WebUI 以使用語音輸入。",
|
||||||
"unsupported": "目前瀏覽器不支援語音輸入。",
|
"unsupported": "目前瀏覽器不支援語音輸入。",
|
||||||
"permission": "請在網址列允許麥克風後重試。",
|
"permission": "請在網址列允許麥克風後重試。",
|
||||||
"notConfigured": "請先設定轉寫供應商。",
|
"notConfigured": "請先設定轉寫供應商。",
|
||||||
@@ -1366,7 +1398,7 @@
|
|||||||
},
|
},
|
||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "工作區未變更",
|
"title": "工作區未變更",
|
||||||
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
"body": "閘道拒絕了此專案或存取模式。請選擇現有專案或其他存取模式,然後再試一次。"
|
||||||
},
|
},
|
||||||
"turnRejected": {
|
"turnRejected": {
|
||||||
"title": "訊息未傳送",
|
"title": "訊息未傳送",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user