Compare commits

..
Author SHA1 Message Date
Xubin Ren 3f29b10f0d Merge remote-tracking branch 'origin/main' into codex/unified-extension-platform 2026-07-27 14:37:18 +08:00
Xubin Ren bed0db4922 refactor(extensions): simplify the native extension platform 2026-07-27 14:37:05 +08:00
chengyongruandGitHub 281b4b7f0b chore: remove expired v0.3.1 compatibility shims (#5106) 2026-07-27 13:53:04 +08:00
chengyongruandchengyongru 39348dfafe refactor(agent): remove dead lifecycle scaffolding 2026-07-27 12:00:06 +08:00
Xubin Ren d68857bb2d Merge remote-tracking branch 'origin/main' into codex/unified-extension-platform
# Conflicts:
#	docs/configuration.md
2026-07-27 10:36:20 +08:00
Xubin Ren a521cf31d9 test(extensions): make marker coverage platform-neutral 2026-07-26 21:35:53 +08:00
Xubin Ren 0b81858378 fix(extensions): harden compatibility and lifecycle 2026-07-26 21:19:29 +08:00
Xubin Ren bbaafd0f4f docs(extensions): document management and compatibility 2026-07-26 21:19:29 +08:00
Xubin Ren 41bebdcdb5 feat(extensions): add first-class WebUI management 2026-07-26 21:19:29 +08:00
Xubin Ren 55e497be14 feat(extensions): add management CLI 2026-07-26 21:19:29 +08:00
Xubin Ren 83b54212ae feat(extensions): wire runtime management and marketplace 2026-07-26 21:19:29 +08:00
Xubin Ren 6e0950833a feat(extensions): enforce dependencies and permission grants 2026-07-26 21:19:29 +08:00
Xubin Ren 012c7ce034 feat(extensions): load published OpenClaw runtimes 2026-07-26 21:19:29 +08:00
Xubin Ren 76d7a33b3b feat(extensions): install compatible packages atomically 2026-07-26 21:19:29 +08:00
Xubin Ren 0cd091ba93 feat(extensions): activate packages transactionally 2026-07-26 21:19:29 +08:00
Xubin Ren 029f9bc53b feat(extensions): add Pi and OpenClaw sidecar protocol 2026-07-26 21:19:29 +08:00
Xubin Ren 1e573c75ae feat(extensions): project native capabilities into catalog 2026-07-26 21:19:29 +08:00
Xubin Ren 863b02d215 feat(extensions): add manifest discovery and policy config 2026-07-26 21:19:29 +08:00
Xubin Ren 336b2876d4 feat(extensions): add unified manifest and registry 2026-07-26 21:19:29 +08:00
108 changed files with 5171 additions and 820 deletions
+2
View File
@@ -33,6 +33,7 @@ Pick the row that matches what you want to accomplish next:
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) | | Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) | | Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) | | Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
| Install and govern an extension | [Extensions](./extensions.md) |
| Generate images | [Image Generation](./image-generation.md) | | Generate images | [Image Generation](./image-generation.md) |
| Schedule work or create a local trigger | [Automations](./automations.md) | | Schedule work or create a local trigger | [Automations](./automations.md) |
| Understand and manage long-term memory | [Memory](./memory.md) | | Understand and manage long-term memory | [Memory](./memory.md) |
@@ -79,6 +80,7 @@ These pages explain implementation and extension points. You do not need them to
| Understand source ownership and runtime flow | [Architecture](./architecture.md) | | Understand source ownership and runtime flow | [Architecture](./architecture.md) |
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) | | Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) | | Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
| Publish an extension package | [Extension Authoring](./extension-authoring.md) |
| Build the WebUI source | [WebUI Development](../webui/README.md) | | Build the WebUI source | [WebUI Development](../webui/README.md) |
If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction. If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
-7
View File
@@ -137,13 +137,6 @@ message. Copy the `nanobot trigger ...` command from the WebUI and replace
Automation delivery is workspace-local. Scheduled jobs and local trigger Automation delivery is workspace-local. Scheduled jobs and local trigger
deliveries use the same workspace as the gateway. deliveries use the same workspace as the gateway.
WebUI automation replies are written to the linked topic even when no browser
is connected. When the WebUI is opened again, it replays the stored reply and
compares the topic's durable activity time with its persisted read position to
show **New activity**. A successful automation `lastStatus` means the agent turn
completed; it does not mean a browser had a live WebSocket connection or that
the user already read the reply.
Local trigger messages are written to a durable queue. If the gateway is not Local trigger messages are written to a durable queue. If the gateway is not
running yet, the message waits in that workspace. If the linked topic is running yet, the message waits in that workspace. If the linked topic is
already running a turn, the trigger waits until the session becomes idle instead already running a turn, the trigger waits until the session becomes idle instead
+34
View File
@@ -18,6 +18,7 @@ Use this page when you know what you want to run and need the command shape. For
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session | | Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` | | Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Manage extension packages | `nanobot extensions list` | Install, inspect, trust, enable, and remove native nanobot packages |
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on | | Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat | | Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers | | Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
@@ -248,6 +249,39 @@ nanobot channels status
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup. See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
## Extensions
Extension installation, trust, permission grants, and enablement are separate
operations:
| Command | Description |
|---|---|
| `nanobot extensions list` | Show installed packages and activation policy |
| `nanobot extensions inspect <id>` | Show identity, dependencies, requested permissions, and diagnostics |
| `nanobot extensions install <url> --kind git [--ref <ref>]` | Install from a Git branch, tag, or commit |
| `nanobot extensions install <path> --kind local` | Install from a local package directory |
| `nanobot extensions permissions <id> [permissions...]` | Replace the exact granted permission set; omit values to revoke all |
| `nanobot extensions trust <id>` | Approve executing the installed package |
| `nanobot extensions untrust <id>` | Revoke trust and stop activation |
| `nanobot extensions enable <id>` | Allow activation when every other gate passes |
| `nanobot extensions disable <id>` | Stop activation without uninstalling |
| `nanobot extensions uninstall <id>` | Remove the user-scope package after confirmation |
| `nanobot extensions uninstall <id> --yes` | Remove without an interactive confirmation |
Example:
```bash
nanobot extensions install https://github.com/acme/nanobot-review.git
nanobot extensions inspect acme.review
nanobot extensions permissions acme.review workspace.read
nanobot extensions trust acme.review
nanobot extensions enable acme.review
```
Installed packages live under `~/.nanobot/extensions/`. They do not execute
until trusted. See [Extensions](./extensions.md) for the safety model and
[Extension Authoring](./extension-authoring.md) for the native package contract.
## Optional Features ## Optional Features
Use these commands when you want nanobot to add or remove a built-in capability Use these commands when you want nanobot to add or remove a built-in capability
+28 -1
View File
@@ -27,6 +27,7 @@ the focused guides first and come back here for exact fields and defaults.
| Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) | | Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) |
| Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) | | Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) |
| Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) | | Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) |
| Install and govern extensions | [`extensions.md`](./extensions.md) |
| Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) | | Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) |
| Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) | | Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
@@ -45,6 +46,7 @@ the focused guides first and come back here for exact fields and defaults.
| Configure web search and fetch | [Web Tools](#web-tools) | | Configure web search and fetch | [Web Tools](#web-tools) |
| Enable image generation | [Image Generation](#image-generation) | | Enable image generation | [Image Generation](#image-generation) |
| Add MCP servers | [MCP](#mcp-model-context-protocol) | | Add MCP servers | [MCP](#mcp-model-context-protocol) |
| Enable or disable external extensions | [Extensions](#extensions) |
| Review shell, workspace, and SSRF controls | [Security](#security) | | Review shell, workspace, and SSRF controls | [Security](#security) |
| Control access and pairing | [Pairing](#pairing) | | Control access and pairing | [Pairing](#pairing) |
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) | | Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
@@ -64,6 +66,7 @@ If the WebUI does not expose the option you need, start from the task below. Mos
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) | | Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) | | Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) | | Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
| Enable external extension packages | `extensions.enabled` | `nanobot extensions list`, then inspect the package | [Extensions](#extensions), [Extension guide](./extensions.md) |
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) | | Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) | | Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) | | Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
@@ -1997,7 +2000,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. | | `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. |
| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. | | `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. |
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. | | `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install optional support or extension packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin may install packages into this environment. |
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. | | `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
@@ -2233,6 +2236,30 @@ When enabled, all incoming messages — regardless of which channel they arrive
> This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change. > This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change.
## Extensions
Use the WebUI **Extensions** page or `nanobot extensions` commands for normal
installation and trust decisions. Extension support can be disabled globally:
```json
{
"extensions": {
"enabled": true
}
}
```
| Option | Default | Description |
|---|---|---|
| `extensions.enabled` | `true` | Enable external extension discovery and activation |
Installed packages and their trust, permission, and activation state live
under `~/.nanobot/extensions/`. Managing an extension does not rewrite
`config.json`.
See [Extensions](./extensions.md) for the safe install flow and
[Extension Authoring](./extension-authoring.md) for the package contract.
## Disabled Skills ## Disabled Skills
nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names: nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
+147
View File
@@ -0,0 +1,147 @@
# Extension Authoring
A native nanobot extension is a directory containing:
```text
nanobot-review/
├── nanobot.extension.json
└── extension.py
```
The manifest describes identity, activation prerequisites, and requested
permissions. The Python entry point performs the real registration. This keeps
one authoritative source for tool, command, and hook ownership.
## Manifest
```json
{
"id": "acme.review",
"name": "Acme Review",
"version": "1.0.0",
"entry": "extension:register",
"description": "Adds repository review tools.",
"apiVersion": 1,
"license": "MIT",
"homepage": "https://github.com/acme/nanobot-review",
"dependencies": [
{
"kind": "executable",
"name": "git"
}
],
"permissions": [
{
"name": "workspace.read",
"reason": "Read files selected for review."
}
]
}
```
Required fields are `id`, `name`, and `version`. `entry` defaults to
`"extension:register"` and `apiVersion` defaults to `1`.
IDs use lowercase letters, digits, dots, underscores, and hyphens. Entry points
use `module:function` syntax and must resolve inside the package.
### Dependencies
| Kind | Meaning |
|---|---|
| `python` | Installed Python distribution; `specifier` accepts a version constraint |
| `executable` | Command available on `PATH` |
| `environment` | Non-empty environment variable |
Set `"optional": true` when a missing dependency should not block activation.
### Permissions
Permissions are lowercase namespaced identifiers chosen by the package, such
as `workspace.read` or `network`. Give each permission a concrete reason.
Activation waits until every requested permission is granted.
The host currently uses permissions as explicit user consent. They do not
sandbox Python code, so do not describe a permission as stronger isolation
than it provides.
## Registration API
The entry point receives `PythonExtensionApi` and must return `None`:
```python
from typing import Any
from nanobot.agent.tools.base import Tool
class ReviewTool(Tool):
@property
def name(self) -> str:
return "review_repository"
@property
def description(self) -> str:
return "Review the current repository."
@property
def parameters(self) -> dict[str, Any]:
return {"type": "object", "properties": {}}
async def execute(self, **kwargs: Any) -> str:
return "No findings."
def register(api) -> None:
api.register_tool(ReviewTool())
```
The API has three stable methods:
```python
api.register_tool(tool)
api.register_command("review", handler)
api.register_hook_factory(factory)
```
Command handlers use nanobot's `CommandContext` and return an
`OutboundMessage` or `None`. Hook factories receive `AgentTurnHookContext` and
return an `AgentHook` or `None`.
Do not modify `AgentLoop` or global registries directly. The API tags every
registration with the extension ID so reload, failure rollback, and uninstall
can remove exactly what the package owns.
## Collision and failure behavior
Tool and command names are unique across core and active extensions. If an
extension registers a duplicate name, activation fails for that extension and
all of its partial registrations are rolled back.
Missing dependencies are reported as diagnostics instead of crashing the
gateway.
## Develop locally
1. Create the manifest and entry module.
2. Install the directory with `--kind local`.
3. Inspect and grant its permissions.
4. Trust it.
5. Reinstall after editing so nanobot records a new integrity digest.
```bash
nanobot extensions install "$PWD" --kind local
nanobot extensions inspect acme.review
nanobot extensions permissions acme.review workspace.read
nanobot extensions trust acme.review
```
Keep tests in the extension repository. At minimum, test registration,
duplicate-name failure, and behavior when each required dependency is missing.
## Distribution
Publish the directory in a Git repository. Users can pin a release tag or
commit with `--ref`. The repository root must contain
`nanobot.extension.json`; install scripts and generated compatibility manifests
are not part of the native contract.
+89
View File
@@ -0,0 +1,89 @@
# Extensions
Extensions add native tools, slash commands, or lifecycle hooks without
changing nanobot core. An extension is a Python package with one manifest and
one registration entry point.
Use an extension when a capability needs executable integration with nanobot.
Use a [skill](./skills.md) when instructions alone are enough, an App when the
agent should call an external CLI, and MCP when a service already exposes an
MCP server.
## Install
Install from a Git repository:
```bash
nanobot extensions install https://github.com/acme/nanobot-review.git
```
Install a local package while developing it:
```bash
nanobot extensions install /absolute/path/to/nanobot-review --kind local
```
Git installs may select a branch, tag, or commit:
```bash
nanobot extensions install https://github.com/acme/nanobot-review.git \
--ref v1.2.0
```
The WebUI **Extensions** page exposes the same Git and local installation
flows. Local paths are accepted only from a browser running on the nanobot
host.
## Review before activation
New packages are installed enabled but untrusted. They cannot execute until
you review the manifest, grant every requested permission, and trust them:
```bash
nanobot extensions inspect acme.review
nanobot extensions permissions acme.review workspace.read
nanobot extensions trust acme.review
```
Use `list` to check the result:
```bash
nanobot extensions list
```
Disable, untrust, or remove a package at any time:
```bash
nanobot extensions disable acme.review
nanobot extensions untrust acme.review
nanobot extensions uninstall acme.review
```
Changes made in the WebUI reload its gateway extension host immediately.
Changes made by the standalone CLI take effect the next time the gateway or
agent process starts. Failed registrations are rolled back and reported as
diagnostics.
## Safety model
Extensions are executable Python code. nanobot provides these controls:
- packages are copied into `~/.nanobot/extensions/` with an integrity digest;
- package symlinks and special files are rejected;
- installation, permission grants, trust, and activation are separate steps;
- changed package contents invalidate trust;
- registration is transactional, so a failed extension does not leave tools,
commands, or hooks behind;
- remote WebUI clients cannot grant trust or permissions.
Permission declarations are consent gates, not an operating-system sandbox.
Only install code you are willing to run with the same account as nanobot.
## Package compatibility
The core runtime intentionally executes only the native nanobot Python
contract. Pi and OpenClaw packages are not loaded directly. Compatibility
adapters can be distributed as separate nanobot extensions later without
adding JavaScript runtimes or package-market policy to the agent core.
See [Extension Authoring](./extension-authoring.md) to build a package.
+1 -6
View File
@@ -186,9 +186,7 @@ Dream is configured under `agents.defaults.dream`:
"defaults": { "defaults": {
"dream": { "dream": {
"intervalH": 2, "intervalH": 2,
"modelOverride": null, "modelOverride": null
"maxBatchSize": 20,
"maxIterations": 10
} }
} }
} }
@@ -200,15 +198,12 @@ Dream is configured under `agents.defaults.dream`:
| `intervalH` | How often Dream runs, in hours | | `intervalH` | How often Dream runs, in hours |
| `cron` | Cron expression override (takes precedence over `intervalH`) | | `cron` | Cron expression override (takes precedence over `intervalH`) |
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* | | `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
| `maxBatchSize` | *(Deprecated — not used)* |
| `maxIterations` | *(Deprecated — not used)* |
In practical terms: In practical terms:
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule. - `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`). - `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent. - `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
## In Practice ## In Practice
+10 -7
View File
@@ -285,9 +285,9 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
form. form.
Remote WebUI clients with a valid token can view and use Apps. Actions that Remote WebUI clients with a valid token can view and use Apps. Actions that
install missing nanobot support packages, such as adding a channel dependency, install missing nanobot support packages or first-class extension packages are
are blocked by default. To let trusted remote administrators change the Python blocked by default. To let trusted remote administrators place packages into
environment through the WebUI, opt in explicitly: this nanobot installation through the WebUI, opt in explicitly:
```json ```json
{ {
@@ -298,12 +298,15 @@ 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 the nanobot installation. A remotely installed extension
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it remains untrusted and inactive: trust, permission grants, activation, disabling,
as remote access and leave package installs disabled unless that is intentional. and removal stay restricted to a browser on the nanobot host. If you publish the
WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it as
remote access and leave package installs disabled unless that is intentional.
Optional feature installs use pip's configured package index, including Optional feature installs use pip's configured package index, including
`PIP_INDEX_URL`. `PIP_INDEX_URL`. Extension packages install from an explicit Git repository or
local directory.
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.
+2 -6
View File
@@ -69,7 +69,7 @@ class ContextBuilder:
def build_system_prompt( def build_system_prompt(
self, self,
skill_names: list[str] | None = None, *,
channel: str | None = None, channel: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
workspace: Path | None = None, workspace: Path | None = None,
@@ -196,14 +196,11 @@ class ContextBuilder:
self, self,
history: list[dict[str, Any]], history: list[dict[str, Any]],
current_message: str, current_message: str,
skill_names: list[str] | None = None, *,
media: list[str] | None = None, media: list[str] | None = None,
channel: str | None = None, channel: str | None = None,
chat_id: str | None = None,
current_role: str = "user", current_role: str = "user",
sender_id: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None, runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None, workspace: Path | None = None,
include_memory_recent_history: bool = True, include_memory_recent_history: bool = True,
@@ -219,7 +216,6 @@ class ContextBuilder:
{ {
"role": "system", "role": "system",
"content": self.build_system_prompt( "content": self.build_system_prompt(
skill_names,
channel=channel, channel=channel,
session_summary=session_summary, session_summary=session_summary,
workspace=root, workspace=root,
+50 -118
View File
@@ -13,7 +13,7 @@ from dataclasses import dataclass, field
from enum import Enum, auto from enum import Enum, auto
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
from loguru import logger from loguru import logger
@@ -103,15 +103,7 @@ if TYPE_CHECKING:
) )
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
class TurnState(Enum): _T = TypeVar("_T")
RESTORE = auto()
COMPACT = auto()
COMMAND = auto()
BUILD = auto()
RUN = auto()
SAVE = auto()
RESPOND = auto()
DONE = auto()
class TurnKind(Enum): class TurnKind(Enum):
@@ -119,20 +111,10 @@ class TurnKind(Enum):
SYSTEM = auto() SYSTEM = auto()
@dataclass
class StateTraceEntry:
state: TurnState
started_at: float
duration_ms: float
event: str
error: str | None = None
@dataclass @dataclass
class TurnContext: class TurnContext:
msg: InboundMessage msg: InboundMessage
session_key: str session_key: str
state: TurnState
turn_id: str turn_id: str
runtime: LLMRuntime | None runtime: LLMRuntime | None
kind: TurnKind kind: TurnKind
@@ -146,7 +128,6 @@ class TurnContext:
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list) runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
final_content: str | None = None final_content: str | None = None
tools_used: list[str] = field(default_factory=list)
all_messages: list[dict[str, Any]] = field(default_factory=list) all_messages: list[dict[str, Any]] = field(default_factory=list)
stop_reason: str = "" stop_reason: str = ""
had_injections: bool = False had_injections: bool = False
@@ -178,8 +159,6 @@ class TurnContext:
visible_run_started_at: float | None = None visible_run_started_at: float | None = None
turn_latency_ms: int | None = None turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list)
class AgentLoop: class AgentLoop:
""" """
@@ -244,19 +223,6 @@ class AgentLoop:
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn" _PENDING_USER_TURN_KEY = "pending_user_turn"
# Event-driven state transition table.
# Handlers return an event string; the driver looks up the next state here.
_TRANSITIONS: dict[tuple[TurnState, str], TurnState] = {
(TurnState.RESTORE, "ok"): TurnState.COMPACT,
(TurnState.COMPACT, "ok"): TurnState.COMMAND,
(TurnState.COMMAND, "dispatch"): TurnState.BUILD,
(TurnState.COMMAND, "shortcut"): TurnState.DONE,
(TurnState.BUILD, "ok"): TurnState.RUN,
(TurnState.RUN, "ok"): TurnState.SAVE,
(TurnState.SAVE, "ok"): TurnState.RESPOND,
(TurnState.RESPOND, "ok"): TurnState.DONE,
}
def __init__( def __init__(
self, self,
bus: MessageBus, bus: MessageBus,
@@ -712,13 +678,8 @@ class AgentLoop:
current_message=ctx.msg.content, current_message=ctx.msg.content,
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None, media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
channel=ctx.delivery.route.channel, channel=ctx.delivery.route.channel,
chat_id=str(
ctx.msg.metadata.get("context_chat_id") or ctx.delivery.route.chat_id
),
current_role="user", current_role="user",
sender_id=ctx.msg.sender_id,
session_summary=ctx.pending_summary, session_summary=ctx.pending_summary,
session_metadata=ctx.session.metadata,
workspace=scope.project_path, workspace=scope.project_path,
runtime_context_blocks=ctx.runtime_context_blocks, runtime_context_blocks=ctx.runtime_context_blocks,
include_memory_recent_history=not ctx.ephemeral, include_memory_recent_history=not ctx.ephemeral,
@@ -1379,7 +1340,6 @@ class AgentLoop:
msg=msg, msg=msg,
session=None, session=None,
session_key=key, session_key=key,
state=TurnState.RESTORE,
turn_id=f"{key}:{time.time_ns()}", turn_id=f"{key}:{time.time_ns()}",
runtime=runtime, runtime=runtime,
kind=kind, kind=kind,
@@ -1449,65 +1409,47 @@ class AgentLoop:
ctx.on_stream = _tracked_stream ctx.on_stream = _tracked_stream
ctx.on_stream_end = _tracked_stream_end ctx.on_stream_end = _tracked_stream_end
while ctx.state is not TurnState.DONE: await self._run_turn_stage(ctx, "restore", self._restore_turn)
handler_name = f"_state_{ctx.state.name.lower()}" await self._run_turn_stage(ctx, "compact", self._compact_session)
handler = getattr(self, handler_name, None) if await self._run_turn_stage(ctx, "command", self._dispatch_command):
if handler is None: return ctx.outbound
raise RuntimeError(f"Missing state handler for {ctx.state}") await self._run_turn_stage(ctx, "build", self._build_turn)
await self._run_turn_stage(ctx, "run", self._run_turn)
t0 = time.perf_counter() await self._run_turn_stage(ctx, "save", self._persist_turn)
try: await self._run_turn_stage(ctx, "respond", self._prepare_outbound)
event = await handler(ctx)
except Exception:
duration = (time.perf_counter() - t0) * 1000
ctx.trace.append(
StateTraceEntry(
state=ctx.state,
started_at=t0,
duration_ms=duration,
event="",
error="exception",
)
)
raise
duration = (time.perf_counter() - t0) * 1000
ctx.trace.append(
StateTraceEntry(
state=ctx.state,
started_at=t0,
duration_ms=duration,
event=event,
)
)
logger.debug(
"[turn {}] State {} took {:.1f}ms -> event {}",
ctx.turn_id,
ctx.state.name,
duration,
event,
)
next_state = self._TRANSITIONS.get((ctx.state, event))
if next_state is None:
raise RuntimeError(
f"[turn {ctx.turn_id}] No transition from {ctx.state} "
f"on event {event!r}"
)
ctx.state = next_state
logger.debug(
"[turn {}] Turn completed after {} states",
ctx.turn_id,
len(ctx.trace),
)
return ctx.outbound return ctx.outbound
async def _run_turn_stage(
self,
ctx: TurnContext,
name: str,
handler: Callable[[TurnContext], Awaitable[_T]],
) -> _T:
started_at = time.perf_counter()
try:
result = await handler(ctx)
except Exception:
duration_ms = (time.perf_counter() - started_at) * 1000
logger.debug(
"[turn {}] Stage {} failed after {:.1f}ms",
ctx.turn_id,
name,
duration_ms,
)
raise
duration_ms = (time.perf_counter() - started_at) * 1000
logger.debug(
"[turn {}] Stage {} completed in {:.1f}ms",
ctx.turn_id,
name,
duration_ms,
)
return result
def _assemble_outbound( def _assemble_outbound(
self, self,
msg: InboundMessage, msg: InboundMessage,
final_content: str, final_content: str,
all_msgs: list[dict[str, Any]],
stop_reason: str, stop_reason: str,
had_injections: bool, had_injections: bool,
streamed_content: bool, streamed_content: bool,
@@ -1538,7 +1480,7 @@ class AgentLoop:
metadata=meta, metadata=meta,
) )
async def _state_restore(self, ctx: TurnContext) -> TurnState: async def _restore_turn(self, ctx: TurnContext) -> None:
"""Restore checkpoint / pending user turn; extract documents.""" """Restore checkpoint / pending user turn; extract documents."""
msg = ctx.msg msg = ctx.msg
@@ -1571,8 +1513,6 @@ class AgentLoop:
if self._restore_pending_user_turn(ctx.session): if self._restore_pending_user_turn(ctx.session):
self.sessions.save(ctx.session) self.sessions.save(ctx.session)
return "ok"
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]: def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
if self._should_extract_document_text(): if self._should_extract_document_text():
return extract_documents(content, media) return extract_documents(content, media)
@@ -1583,14 +1523,13 @@ class AgentLoop:
return True return True
return self.channels_config.extract_document_text return self.channels_config.extract_document_text
async def _state_compact(self, ctx: TurnContext) -> str: async def _compact_session(self, ctx: TurnContext) -> None:
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key) ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
ctx.pending_summary = pending ctx.pending_summary = pending
return "ok"
async def _state_command(self, ctx: TurnContext) -> str: async def _dispatch_command(self, ctx: TurnContext) -> bool:
if ctx.kind is TurnKind.SYSTEM: if ctx.kind is TurnKind.SYSTEM:
return "dispatch" return False
raw = ctx.msg.content.strip() raw = ctx.msg.content.strip()
_, automation_metadata = automation_history_overrides(ctx.msg.metadata) _, automation_metadata = automation_history_overrides(ctx.msg.metadata)
is_user_turn = ( is_user_turn = (
@@ -1626,10 +1565,10 @@ class AgentLoop:
) )
self.sessions.save(ctx.session) self.sessions.save(ctx.session)
self._clear_pending_user_turn(ctx.session) self._clear_pending_user_turn(ctx.session)
return "shortcut" return True
return "dispatch" return False
async def _state_build(self, ctx: TurnContext) -> str: async def _build_turn(self, ctx: TurnContext) -> None:
runtime = ctx.runtime runtime = ctx.runtime
if runtime is None: if runtime is None:
runtime = self.runtime_for_session(ctx.session) runtime = self.runtime_for_session(ctx.session)
@@ -1685,9 +1624,7 @@ class AgentLoop:
if ctx.on_retry_wait is None: if ctx.on_retry_wait is None:
ctx.on_retry_wait = ctx.delivery.retry_wait_callback() ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
return "ok" async def _run_turn(self, ctx: TurnContext) -> None:
async def _state_run(self, ctx: TurnContext) -> str:
if ctx.visible_run_started_at is None: if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time() ctx.visible_run_started_at = time.time()
await ctx.delivery.running(started_at=ctx.visible_run_started_at) await ctx.delivery.running(started_at=ctx.visible_run_started_at)
@@ -1714,17 +1651,15 @@ class AgentLoop:
tools=ctx.tools, tools=ctx.tools,
request_context=ctx.request_context, request_context=ctx.request_context,
) )
final_content, tools_used, all_msgs, stop_reason, had_injections = result final_content, _, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content ctx.final_content = final_content
ctx.tools_used = tools_used
ctx.all_messages = all_msgs ctx.all_messages = all_msgs
ctx.stop_reason = stop_reason ctx.stop_reason = stop_reason
ctx.had_injections = had_injections ctx.had_injections = had_injections
if ctx.kind is TurnKind.USER: if ctx.kind is TurnKind.USER:
await turn_continuation.maybe_continue_turn(ctx) await turn_continuation.maybe_continue_turn(ctx)
return "ok"
async def _state_save(self, ctx: TurnContext) -> str: async def _persist_turn(self, ctx: TurnContext) -> None:
turn_continuation.prepare_save_boundary(ctx) turn_continuation.prepare_save_boundary(ctx)
if ( if (
@@ -1765,12 +1700,11 @@ class AgentLoop:
self._clear_pending_user_turn(ctx.session) self._clear_pending_user_turn(ctx.session)
self._clear_runtime_checkpoint(ctx.session) self._clear_runtime_checkpoint(ctx.session)
self.sessions.save(ctx.session) self.sessions.save(ctx.session)
return "ok"
async def _state_respond(self, ctx: TurnContext) -> str: async def _prepare_outbound(self, ctx: TurnContext) -> None:
if ctx.suppress_response: if ctx.suppress_response:
ctx.outbound = None ctx.outbound = None
return "ok" return
if ctx.kind is TurnKind.SYSTEM: if ctx.kind is TurnKind.SYSTEM:
ctx.outbound = ctx.delivery.background_response( ctx.outbound = ctx.delivery.background_response(
ctx.final_content, ctx.final_content,
@@ -1778,11 +1712,10 @@ class AgentLoop:
streamed=ctx.streamed_content, streamed=ctx.streamed_content,
latency_ms=ctx.turn_latency_ms, latency_ms=ctx.turn_latency_ms,
) )
return "ok" return
ctx.outbound = self._assemble_outbound( ctx.outbound = self._assemble_outbound(
ctx.msg, ctx.msg,
ctx.final_content, ctx.final_content,
ctx.all_messages,
ctx.stop_reason, ctx.stop_reason,
ctx.had_injections, ctx.had_injections,
ctx.streamed_content, ctx.streamed_content,
@@ -1790,7 +1723,6 @@ class AgentLoop:
) )
if ctx.ephemeral and ctx.outbound is not None: if ctx.ephemeral and ctx.outbound is not None:
ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason
return "ok"
def _sanitize_persisted_blocks( def _sanitize_persisted_blocks(
self, self,
+1 -4
View File
@@ -912,7 +912,7 @@ class Consolidator:
) -> tuple[int, str]: ) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail.""" """Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session) history = self._full_unconsolidated_history(session)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None)) channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it. # Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None) summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
@@ -920,10 +920,7 @@ class Consolidator:
history=history, history=history,
current_message="[token-probe]", current_message="[token-probe]",
channel=channel, channel=channel,
chat_id=chat_id,
sender_id=None,
session_summary=summary, session_summary=summary,
session_metadata=session.metadata,
session_key=session.key, session_key=session.key,
unified_session=self.unified_session, unified_session=self.unified_session,
) )
+1 -1
View File
@@ -1327,7 +1327,7 @@ class AgentRunner:
return payload, event, exc return payload, event, exc
return payload, event, None return payload, event, None
if is_tool_error_result(tool_call.name, result): if is_tool_error_result(result):
await hook.on_execute_tool_error(context, tool_call, tool, params, result) await hook.on_execute_tool_error(context, tool_call, tool, params, result)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
-6
View File
@@ -39,12 +39,6 @@ def _validate_patch_path(path: str) -> str:
return normalized return normalized
def _lines_to_text(lines: list[str]) -> str:
if not lines:
return ""
return "\n".join(lines) + "\n"
def _text_line_count(text: str) -> int: def _text_line_count(text: str) -> int:
if not text: if not text:
return 0 return 0
+1 -3
View File
@@ -28,7 +28,7 @@ _CRON_PARAMETERS = tool_parameters_schema(
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). " "(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
"Not used for action='list' or action='remove'." "Not used for action='list' or action='remove'."
), ),
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"), every_seconds=IntegerSchema(description="Interval in seconds (for recurring tasks)"),
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"), cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
tz=StringSchema( tz=StringSchema(
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). " "Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
@@ -138,8 +138,6 @@ class CronTool(Tool):
tz: str | None = None, tz: str | None = None,
at: str | None = None, at: str | None = None,
job_id: str | None = None, job_id: str | None = None,
deliver: bool = True,
**kwargs: Any,
) -> str: ) -> str:
if action == "add": if action == "add":
if self._in_cron_context.get(): if self._in_cron_context.get():
-4
View File
@@ -447,7 +447,6 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
default=False, default=False,
), ),
yield_time_ms=IntegerSchema( yield_time_ms=IntegerSchema(
DEFAULT_YIELD_MS,
description="Milliseconds to wait before returning recent output (default 1000, max 30000).", description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0, minimum=0,
maximum=MAX_YIELD_MS, maximum=MAX_YIELD_MS,
@@ -458,20 +457,17 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
nullable=True, nullable=True,
), ),
wait_timeout_ms=IntegerSchema( wait_timeout_ms=IntegerSchema(
DEFAULT_WAIT_FOR_MS,
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).", description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
minimum=0, minimum=0,
maximum=MAX_WAIT_FOR_MS, maximum=MAX_WAIT_FOR_MS,
nullable=True, nullable=True,
), ),
max_output_chars=IntegerSchema( max_output_chars=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Maximum output characters to return from this poll (default 10000, max 50000).", description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000, minimum=1000,
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
), ),
max_output_tokens=IntegerSchema( max_output_tokens=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.", description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000, minimum=1000,
maximum=MAX_OUTPUT_CHARS, maximum=MAX_OUTPUT_CHARS,
-6
View File
@@ -226,12 +226,10 @@ def _builtin_skill_read_path(path: str) -> Path | None:
tool_parameters_schema( tool_parameters_schema(
path=StringSchema("The file path to read"), path=StringSchema("The file path to read"),
offset=IntegerSchema( offset=IntegerSchema(
1,
description="Line number to start reading from (1-indexed, default 1)", description="Line number to start reading from (1-indexed, default 1)",
minimum=1, minimum=1,
), ),
limit=IntegerSchema( limit=IntegerSchema(
2000,
description="Maximum number of lines to read (default 2000)", description="Maximum number of lines to read (default 2000)",
minimum=1, minimum=1,
), ),
@@ -790,13 +788,11 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
new_text=StringSchema("The text to replace with"), new_text=StringSchema("The text to replace with"),
replace_all=BooleanSchema(description="Replace all occurrences (default false)"), replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
occurrence=IntegerSchema( occurrence=IntegerSchema(
1,
description="Optional 1-based occurrence to replace when old_text appears multiple times.", description="Optional 1-based occurrence to replace when old_text appears multiple times.",
minimum=1, minimum=1,
nullable=True, nullable=True,
), ),
line_hint=IntegerSchema( line_hint=IntegerSchema(
1,
description=( description=(
"Optional exact 1-based target line copied from read_file. " "Optional exact 1-based target line copied from read_file. "
"The selected old_text match must cover this line." "The selected old_text match must cover this line."
@@ -805,7 +801,6 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
nullable=True, nullable=True,
), ),
expected_replacements=IntegerSchema( expected_replacements=IntegerSchema(
1,
description="Optional guard for the number of replacements that must be made.", description="Optional guard for the number of replacements that must be made.",
minimum=1, minimum=1,
nullable=True, nullable=True,
@@ -1036,7 +1031,6 @@ class EditFileTool(_FsTool):
path=StringSchema("The directory path to list"), path=StringSchema("The directory path to list"),
recursive=BooleanSchema(description="Recursively list all files (default false)"), recursive=BooleanSchema(description="Recursively list all files (default false)"),
max_entries=IntegerSchema( max_entries=IntegerSchema(
200,
description="Maximum entries to return (default 200)", description="Maximum entries to return (default 200)",
minimum=1, minimum=1,
), ),
+26 -6
View File
@@ -915,6 +915,23 @@ class MCPPromptWrapper(_MCPWrapperBase):
return "\n".join(parts) or "(no output)" return "\n".join(parts) or "(no output)"
def _register_mcp_capability(
registry: ToolRegistry,
capability: Tool,
server_name: str,
) -> bool:
owner = f"nanobot.mcp.{server_name}"
if registry.register_if_absent(capability, owner=owner):
return True
logger.warning(
"MCP: skipping capability '{}' from server '{}' because it is already registered by '{}'",
capability.name,
server_name,
registry.owner(capability.name),
)
return False
async def connect_mcp_servers( async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry mcp_servers: dict, registry: ToolRegistry
) -> dict[str, MCPConnection]: ) -> dict[str, MCPConnection]:
@@ -1048,7 +1065,8 @@ async def connect_mcp_servers(
) )
continue continue
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout) wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
registry.register(wrapper) if not _register_mcp_capability(registry, wrapper, name):
continue
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
registered_count += 1 registered_count += 1
if enabled_tools: if enabled_tools:
@@ -1085,7 +1103,8 @@ async def connect_mcp_servers(
wrapper = MCPResourceWrapper( wrapper = MCPResourceWrapper(
session, name, resource, resource_timeout=cfg.tool_timeout session, name, resource, resource_timeout=cfg.tool_timeout
) )
registry.register(wrapper) if not _register_mcp_capability(registry, wrapper, name):
continue
registered_count += 1 registered_count += 1
logger.debug( logger.debug(
"MCP: registered resource '{}' from server '{}'", "MCP: registered resource '{}' from server '{}'",
@@ -1103,7 +1122,8 @@ async def connect_mcp_servers(
wrapper = MCPPromptWrapper( wrapper = MCPPromptWrapper(
session, name, prompt, prompt_timeout=cfg.tool_timeout session, name, prompt, prompt_timeout=cfg.tool_timeout
) )
registry.register(wrapper) if not _register_mcp_capability(registry, wrapper, name):
continue
registered_count += 1 registered_count += 1
logger.debug( logger.debug(
"MCP: registered prompt '{}' from server '{}'", "MCP: registered prompt '{}' from server '{}'",
@@ -1273,7 +1293,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
tools_removed = 0 tools_removed = 0
for name in [*removed, *changed]: for name in [*removed, *changed]:
tools_removed += _unregister_server_tools(state, registry, name) tools_removed += _unregister_server_tools(registry, name)
await _close_server(state, name) await _close_server(state, name)
state._mcp_servers = next_servers state._mcp_servers = next_servers
@@ -1447,7 +1467,7 @@ async def _refresh_terminated_server(
return current_tool return current_tool
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name) logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
_unregister_server_tools(state, registry, server_name) _unregister_server_tools(registry, server_name)
await _close_server(state, server_name) await _close_server(state, server_name)
connected = await connect_mcp_servers({server_name: cfg}, registry) connected = await connect_mcp_servers({server_name: cfg}, registry)
@@ -1479,7 +1499,7 @@ def _tool_belongs_to_server(tool: Tool | None, tool_name: str, server_name: str)
return tool_name.startswith(_tool_prefix(server_name)) return tool_name.startswith(_tool_prefix(server_name))
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int: def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
removed = 0 removed = 0
for tool_name in list(registry.tool_names): for tool_name in list(registry.tool_names):
tool = registry.get(tool_name) tool = registry.get(tool_name)
+25 -3
View File
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
from nanobot.runtime_context import RuntimeContextProvider from nanobot.runtime_context import RuntimeContextProvider
def is_tool_error_result(name: str, result: Any) -> bool: def is_tool_error_result(result: Any) -> bool:
return isinstance(result, ToolResult) and result.is_error return isinstance(result, ToolResult) and result.is_error
@@ -25,22 +25,44 @@ class ToolRegistry:
def __init__(self): def __init__(self):
self._tools: dict[str, Tool] = {} self._tools: dict[str, Tool] = {}
self._owners: dict[str, str] = {}
self._cached_definitions: list[dict[str, Any]] | None = None self._cached_definitions: list[dict[str, Any]] | None = None
def register(self, tool: Tool) -> None: def register(self, tool: Tool, *, owner: str = "nanobot.core") -> None:
"""Register a tool.""" """Register a tool."""
self._tools[tool.name] = tool self._tools[tool.name] = tool
self._owners[tool.name] = owner
self._cached_definitions = None self._cached_definitions = None
def register_if_absent(self, tool: Tool, *, owner: str = "nanobot.core") -> bool:
"""Register a tool without replacing an existing capability."""
if tool.name in self._tools:
return False
self.register(tool, owner=owner)
return True
def unregister(self, name: str) -> None: def unregister(self, name: str) -> None:
"""Unregister a tool by name.""" """Unregister a tool by name."""
self._tools.pop(name, None) self._tools.pop(name, None)
self._owners.pop(name, None)
self._cached_definitions = None self._cached_definitions = None
def unregister_owner(self, owner: str) -> None:
"""Remove all tools registered by one extension."""
for name in [
name for name, registered_owner in self._owners.items()
if registered_owner == owner
]:
self.unregister(name)
def get(self, name: str) -> Tool | None: def get(self, name: str) -> Tool | None:
"""Get a tool by name.""" """Get a tool by name."""
return self._tools.get(name) return self._tools.get(name)
def owner(self, name: str) -> str | None:
"""Return the extension ID that registered a tool."""
return self._owners.get(name)
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]: def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
"""Return tool-owned providers in stable tool-name order.""" """Return tool-owned providers in stable tool-name order."""
providers: list[RuntimeContextProvider] = [] providers: list[RuntimeContextProvider] = []
@@ -193,7 +215,7 @@ class ToolRegistry:
try: try:
assert tool is not None # guarded by prepare_call() assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params) result = await tool.execute(**params)
if is_tool_error_result(name, result): if is_tool_error_result(result):
return ToolResult.error(str(result) + hint) return ToolResult.error(str(result) + hint)
return result return result
except Exception as e: except Exception as e:
+1 -5
View File
@@ -52,11 +52,10 @@ class StringSchema(Schema):
class IntegerSchema(Schema): class IntegerSchema(Schema):
"""Integer parameter: optional placeholder int (legacy ctor signature), description, and bounds.""" """Integer parameter with a description and optional bounds."""
def __init__( def __init__(
self, self,
value: int = 0,
*, *,
description: str = "", description: str = "",
minimum: int | None = None, minimum: int | None = None,
@@ -64,7 +63,6 @@ class IntegerSchema(Schema):
enum: tuple[int, ...] | list[int] | None = None, enum: tuple[int, ...] | list[int] | None = None,
nullable: bool = False, nullable: bool = False,
) -> None: ) -> None:
self._value = value
self._description = description self._description = description
self._minimum = minimum self._minimum = minimum
self._maximum = maximum self._maximum = maximum
@@ -92,7 +90,6 @@ class NumberSchema(Schema):
def __init__( def __init__(
self, self,
value: float = 0.0,
*, *,
description: str = "", description: str = "",
minimum: float | None = None, minimum: float | None = None,
@@ -100,7 +97,6 @@ class NumberSchema(Schema):
enum: tuple[float, ...] | list[float] | None = None, enum: tuple[float, ...] | list[float] | None = None,
nullable: bool = False, nullable: bool = False,
) -> None: ) -> None:
self._value = value
self._description = description self._description = description
self._minimum = minimum self._minimum = minimum
self._maximum = maximum self._maximum = maximum
-1
View File
@@ -108,7 +108,6 @@ class _PreparedCommand:
working_dir=StringSchema("Optional working directory for the command"), working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"), workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema( timeout=IntegerSchema(
60,
description=( description=(
"Timeout in seconds. Increase for long-running commands " "Timeout in seconds. Increase for long-running commands "
"like compilation or installation (default 60, max 600)." "like compilation or installation (default 60, max 600)."
+2 -3
View File
@@ -271,13 +271,12 @@ def _normalize_volcengine_auth_level(value: Any) -> int | None:
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
query=StringSchema("Search query"), query=StringSchema("Search query"),
count=IntegerSchema(1, description="Results (1-10)", minimum=1, maximum=10), count=IntegerSchema(description="Results (1-10)", minimum=1, maximum=10),
timeRange=StringSchema( timeRange=StringSchema(
"Optional time filter for providers that support it: " "Optional time filter for providers that support it: "
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD", "OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
), ),
authLevel=IntegerSchema( authLevel=IntegerSchema(
0,
description="Optional authority filter for providers that support it: 0=all, 1=authoritative", description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
minimum=0, minimum=0,
maximum=1, maximum=1,
@@ -939,7 +938,7 @@ class WebSearchTool(Tool):
"enum": ["markdown", "text"], "enum": ["markdown", "text"],
"default": "markdown", "default": "markdown",
}, },
maxChars=IntegerSchema(0, minimum=100), maxChars=IntegerSchema(minimum=100),
required=["url"], required=["url"],
) )
) )
+6
View File
@@ -98,6 +98,7 @@ class ChannelManager:
webui_static_dist: bool = True, webui_static_dist: bool = True,
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_extension_service: Any | None = None,
): ):
self.config = config self.config = config
self.bus = bus self.bus = bus
@@ -110,6 +111,7 @@ class ChannelManager:
self._webui_static_dist = webui_static_dist self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self._webui_extension_service = webui_extension_service
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {} self._channel_owners: dict[str, str] = {}
self._channel_runtime_specs: dict[str, tuple[str, str]] = {} self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
@@ -176,6 +178,10 @@ class ChannelManager:
local_trigger_pending_ids=self._webui_local_trigger_pending_ids, local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action, channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status, channel_runtime_status=self.get_status,
extension_service=self._webui_extension_service,
allow_remote_package_install=(
self.config.tools.webui_allow_remote_package_install
),
logger=logger, logger=logger,
) )
kwargs["gateway"] = gateway kwargs["gateway"] = gateway
-19
View File
@@ -3,8 +3,6 @@
from __future__ import annotations from __future__ import annotations
import pkgutil import pkgutil
from functools import cache
from importlib.metadata import entry_points
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
@@ -19,22 +17,6 @@ if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
@cache
def _warn_legacy_channel_entry_points() -> None:
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final
# migration window for installed legacy channel entry points.
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
if not names:
return
logger.warning(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
", ".join(names),
"nanobot.channels",
)
def _channel_package_names() -> list[str]: def _channel_package_names() -> list[str]:
import nanobot.channels as package import nanobot.channels as package
@@ -49,7 +31,6 @@ def discover_plugins(
enabled_names: set[str] | None = None, enabled_names: set[str] | None = None,
) -> dict[str, ChannelPlugin]: ) -> dict[str, ChannelPlugin]:
"""Load dependency-free descriptors from self-contained channel packages.""" """Load dependency-free descriptors from self-contained channel packages."""
_warn_legacy_channel_entry_points()
plugins: dict[str, ChannelPlugin] = {} plugins: dict[str, ChannelPlugin] = {}
for name in _channel_package_names(): for name in _channel_package_names():
if enabled_names is not None and name not in enabled_names: if enabled_names is not None and name not in enabled_names:
@@ -1582,49 +1582,6 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert body["messages"][-1]["latencyMs"] == 42 assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio
async def test_cron_reply_persists_for_replay_without_subscribers() -> None:
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
from nanobot.webui.transcript import build_webui_thread_response
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
metadata = cron_proactive_delivery_metadata(
"websocket",
None,
turn_seed="cron:daily-digest",
source_label="Daily digest",
)
await channel.send(OutboundMessage(
channel="websocket",
chat_id="cron-offline",
content="The scheduled digest is ready.",
metadata=metadata,
))
await channel.send(OutboundMessage(
channel="websocket",
chat_id="cron-offline",
content="",
event=TurnEndEvent(),
metadata=metadata,
))
assert channel._subs == {}
body = build_webui_thread_response("websocket:cron-offline")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
assert body["messages"][-1]["content"] == "The scheduled digest is ready."
assert body["messages"][-1]["source"] == {
"kind": "cron",
"label": "Daily digest",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None: async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock() bus = MagicMock()
@@ -1891,15 +1891,11 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
assert initial.status_code == 200 assert initial.status_code == 200
assert initial.json()["schema_version"] == 1 assert initial.json()["schema_version"] == 1
assert initial.json()["pinned_keys"] == [] assert initial.json()["pinned_keys"] == []
assert initial.json()["activity_seen_at_by_key"] == {}
payload = { payload = {
"pinned_keys": ["websocket:sidebar"], "pinned_keys": ["websocket:sidebar"],
"archived_keys": ["websocket:old"], "archived_keys": ["websocket:old"],
"title_overrides": {"websocket:sidebar": "Pinned work"}, "title_overrides": {"websocket:sidebar": "Pinned work"},
"activity_seen_at_by_key": {
"websocket:sidebar": "2026-07-27T08:30:00Z"
},
"view": {"density": "compact", "show_archived": True}, "view": {"density": "compact", "show_archived": True},
} }
query = urlencode({"state": json.dumps(payload)}) query = urlencode({"state": json.dumps(payload)})
@@ -1911,9 +1907,6 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
body = updated.json() body = updated.json()
assert body["pinned_keys"] == ["websocket:sidebar"] assert body["pinned_keys"] == ["websocket:sidebar"]
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"} assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
assert body["activity_seen_at_by_key"] == {
"websocket:sidebar": "2026-07-27T08:30:00Z"
}
assert body["view"]["density"] == "compact" assert body["view"]["density"] == "compact"
state_path = tmp_path / "webui" / "sidebar-state.json" state_path = tmp_path / "webui" / "sidebar-state.json"
@@ -1921,9 +1914,6 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [ assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [
"websocket:sidebar" "websocket:sidebar"
] ]
assert json.loads(state_path.read_text(encoding="utf-8"))[
"activity_seen_at_by_key"
] == {"websocket:sidebar": "2026-07-27T08:30:00Z"}
finally: finally:
await channel.stop() await channel.stop()
await server_task await server_task
+55 -44
View File
@@ -72,6 +72,7 @@ from nanobot.bus.outbound_events import ( # noqa: E402
StreamEndEvent, StreamEndEvent,
outbound_event_from_message, outbound_event_from_message,
) )
from nanobot.cli.extensions import create_extensions_app # noqa: E402
from nanobot.cli.gateway import create_gateway_app # noqa: E402 from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402 from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402 from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
@@ -815,7 +816,6 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
except ValueError as e: except ValueError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) raise typer.Exit(1)
_warn_deprecated_config_keys(config_path)
if workspace: if workspace:
loaded.agents.defaults.workspace = workspace loaded.agents.defaults.workspace = workspace
return loaded return loaded
@@ -836,24 +836,6 @@ def _read_trigger_cli_message(message: str | None) -> str:
raise typer.Exit(1) raise typer.Exit(1)
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
"""Hint users to remove obsolete keys from their config file."""
import json
from nanobot.config.loader import get_config_path
path = config_path or get_config_path()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
console.print(
"[dim]Hint: `memoryWindow` in your config is no longer used "
"and can be safely removed.[/dim]"
)
def _load_inspection_config( def _load_inspection_config(
config: str | None = None, config: str | None = None,
workspace: str | None = None, workspace: str | None = None,
@@ -873,7 +855,6 @@ def _load_inspection_config(
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc raise typer.Exit(1) from exc
_warn_deprecated_config_keys(display_path)
if workspace: if workspace:
loaded.agents.defaults.workspace = workspace loaded.agents.defaults.workspace = workspace
return display_path, loaded return display_path, loaded
@@ -1348,6 +1329,7 @@ def serve(
from nanobot.api.server import create_app from nanobot.api.server import create_app
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.extensions.host import ExtensionHost
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@@ -1396,12 +1378,17 @@ def serve(
agent_loop, model_name=model_name, request_timeout=timeout, agent_loop, model_name=model_name, request_timeout=timeout,
api_key=api_key, api_key=api_key,
) )
extension_host = ExtensionHost(agent_loop, lambda: runtime_config)
async def on_startup(_app): async def on_startup(_app):
await agent_loop._connect_mcp() await agent_loop._connect_mcp()
await extension_host.reload()
async def on_cleanup(_app): async def on_cleanup(_app):
await agent_loop.close_mcp() try:
await extension_host.close()
finally:
await agent_loop.close_mcp()
api_app.on_startup.append(on_startup) api_app.on_startup.append(on_startup)
api_app.on_cleanup.append(on_cleanup) api_app.on_cleanup.append(on_cleanup)
@@ -1645,6 +1632,8 @@ def _run_gateway(
from nanobot.cron.service import CronJobSkippedError, CronService from nanobot.cron.service import CronJobSkippedError, CronService
from nanobot.cron.session_turns import is_bound_cron_job from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob from nanobot.cron.types import CronJob
from nanobot.extensions.host import ExtensionHost
from nanobot.extensions.service import ExtensionService
from nanobot.providers.factory import ( from nanobot.providers.factory import (
build_provider_snapshot, build_provider_snapshot,
build_unconfigured_provider_snapshot, build_unconfigured_provider_snapshot,
@@ -1764,6 +1753,8 @@ def _run_gateway(
local_trigger_store=trigger_store, local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
) )
extension_host = ExtensionHost(agent, lambda: config)
extension_service = ExtensionService(host=extension_host)
webui_turn_coordinator = WebuiTurnCoordinator( webui_turn_coordinator = WebuiTurnCoordinator(
bus=bus, bus=bus,
sessions=session_manager, sessions=session_manager,
@@ -1998,6 +1989,7 @@ def _run_gateway(
webui_static_dist=webui_static_dist, webui_static_dist=webui_static_dist,
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_extension_service=extension_service,
) )
def _pick_heartbeat_target() -> tuple[str, str]: def _pick_heartbeat_target() -> tuple[str, str]:
@@ -2153,6 +2145,7 @@ def _run_gateway(
console.print, console.print,
) )
try: try:
await extension_host.reload()
await cron.start() await cron.start()
# Re-read once on first admission to close the watcher subscription window. # Re-read once on first admission to close the watcher subscription window.
agent.runtime_resolver.invalidate() agent.runtime_resolver.invalidate()
@@ -2232,7 +2225,10 @@ def _run_gateway(
if flushed: if flushed:
logger.info("Shutdown: flushed {} session(s) to disk", flushed) logger.info("Shutdown: flushed {} session(s) to disk", flushed)
finally: finally:
restore_shutdown_handlers() try:
await extension_host.close()
finally:
restore_shutdown_handlers()
asyncio.run(run()) asyncio.run(run())
@@ -2269,6 +2265,7 @@ def agent(
"""Interact with the agent directly.""" """Interact with the agent directly."""
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.extensions.host import ExtensionHost
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
config = _load_runtime_config(config, workspace) config = _load_runtime_config(config, workspace)
@@ -2296,6 +2293,7 @@ def agent(
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc raise typer.Exit(1) from exc
extension_host = ExtensionHost(agent_loop, lambda: config)
restart_notice = consume_restart_notice_from_env() restart_notice = consume_restart_notice_from_env()
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
_print_agent_response( _print_agent_response(
@@ -2337,29 +2335,36 @@ def agent(
if message: if message:
# Single message mode — direct call, no bus needed # Single message mode — direct call, no bus needed
async def run_once(): async def run_once():
renderer = StreamRenderer( try:
render_markdown=markdown, await extension_host.reload()
bot_name=config.agents.defaults.bot_name, renderer = StreamRenderer(
bot_icon=config.agents.defaults.bot_icon,
)
response = await agent_loop.process_direct(
message, session_id,
on_progress=_make_progress(renderer),
on_stream=renderer.on_delta,
on_stream_end=renderer.on_end,
)
if not renderer.streamed:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer.header_printed:
print_kwargs["show_header"] = False
_print_agent_response(
response.content if response else "",
render_markdown=markdown, render_markdown=markdown,
metadata=response.metadata if response else None, bot_name=config.agents.defaults.bot_name,
**print_kwargs, bot_icon=config.agents.defaults.bot_icon,
) )
await agent_loop.close_mcp() response = await agent_loop.process_direct(
message,
session_id,
on_progress=_make_progress(renderer),
on_stream=renderer.on_delta,
on_stream_end=renderer.on_end,
)
if not renderer.streamed:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer.header_printed:
print_kwargs["show_header"] = False
_print_agent_response(
response.content if response else "",
render_markdown=markdown,
metadata=response.metadata if response else None,
**print_kwargs,
)
finally:
try:
await agent_loop.close_mcp()
finally:
await extension_host.close()
asyncio.run(run_once()) asyncio.run(run_once())
else: else:
@@ -2392,6 +2397,7 @@ def agent(
signal.signal(signal.SIGPIPE, signal.SIG_IGN) signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive(): async def run_interactive():
await extension_host.reload()
bus_task = asyncio.create_task(agent_loop.run()) bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event() turn_done = asyncio.Event()
turn_done.set() turn_done.set()
@@ -2523,7 +2529,10 @@ def agent(
agent_loop.stop() agent_loop.stop()
outbound_task.cancel() outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True) await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await agent_loop.close_mcp() try:
await agent_loop.close_mcp()
finally:
await extension_host.close()
asyncio.run(run_interactive()) asyncio.run(run_interactive())
@@ -2533,6 +2542,8 @@ def agent(
# ============================================================================ # ============================================================================
app.add_typer(create_extensions_app(console=console), name="extensions")
channels_app = typer.Typer(help="Manage channels") channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels") app.add_typer(channels_app, name="channels")
+180
View File
@@ -0,0 +1,180 @@
"""Typer commands for installing and governing extensions."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
import typer
from rich.console import Console
from rich.table import Table
from nanobot.extensions.service import ExtensionService
ServiceFactory = Callable[[], ExtensionService]
def create_extensions_app(
*,
console: Console,
service_factory: ServiceFactory = ExtensionService,
) -> typer.Typer:
"""Build the extension command group around the transport-neutral service."""
app = typer.Typer(help="Install, inspect, and govern native extensions.")
def service() -> ExtensionService:
return service_factory()
def run(awaitable: Any) -> dict[str, Any]:
try:
return asyncio.run(awaitable)
except (KeyError, RuntimeError, ValueError) as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
@app.command("list")
def list_extensions() -> None:
"""List installed extensions and their activation policy."""
payload = run(service().status())
table = Table(show_header=True, header_style="bold")
table.add_column("Extension")
table.add_column("State")
table.add_column("Trust")
table.add_column("Version")
for item in payload["extensions"]:
state = "active" if item["active"] else ("enabled" if item["enabled"] else "disabled")
table.add_row(
item["name"],
state,
"trusted" if item["trusted"] else "untrusted",
item["version"],
)
console.print(table)
if not payload["extensions"]:
console.print("[dim]No extensions installed.[/dim]")
if payload["diagnostics"]:
console.print(f"[yellow]{len(payload['diagnostics'])} diagnostic(s)[/yellow]")
@app.command("inspect")
def inspect_extension(extension_id: str = typer.Argument(..., help="Extension ID")) -> None:
"""Show manifest, dependencies, permissions, and diagnostics."""
payload = run(service().status())
item = next(
(candidate for candidate in payload["extensions"] if candidate["id"] == extension_id),
None,
)
if item is None:
console.print(f"[red]Extension not found: {extension_id}[/red]")
raise typer.Exit(1)
console.print(f"[bold]{item['name']}[/bold] [dim]{item['version']}[/dim]")
console.print(item["description"] or "[dim]No description.[/dim]")
console.print(
f"State: {'active' if item['active'] else 'inactive'} "
f"Trust: {'trusted' if item['trusted'] else 'untrusted'}"
)
_print_named_rows(console, "Dependencies", item["dependencies"], "kind", "name")
_print_permissions(console, item["permissions"], set(item["granted_permissions"]))
diagnostics = [
diagnostic
for diagnostic in payload["diagnostics"]
if diagnostic["extension_id"] == extension_id
]
if diagnostics:
console.print("\n[bold]Diagnostics[/bold]")
for diagnostic in diagnostics:
console.print(
f" [yellow]{diagnostic['code']}[/yellow] {diagnostic['message']}"
)
@app.command("install")
def install_extension(
source: str = typer.Argument(..., help="Git URL or local package path"),
kind: str = typer.Option("git", "--kind", help="git or local"),
ref: str = typer.Option("", "--ref", help="Git branch, tag, or commit"),
) -> None:
"""Install an extension without granting trust or permissions."""
payload = run(service().install(source, kind=kind, ref=ref, trusted=False))
record = payload["record"]
console.print(
f"[green]Installed {record['id']} {record['version']}[/green] "
"[yellow](untrusted)[/yellow]"
)
console.print(
f"Review with [bold]nanobot extensions inspect {record['id']}[/bold], "
"then grant permissions and trust it explicitly."
)
def policy_command(name: str, value: bool, label: str, help_text: str) -> None:
@app.command(name, help=help_text)
def update(extension_id: str = typer.Argument(..., help="Extension ID")) -> None:
payload = run(
service().set_enabled(extension_id, value)
if name in {"enable", "disable"}
else service().set_trusted(extension_id, value)
)
console.print(f"[green]{label}: {payload['record']['id']}[/green]")
policy_command("enable", True, "Enabled", "Allow an installed extension to activate.")
policy_command("disable", False, "Disabled", "Prevent an installed extension from activating.")
policy_command("trust", True, "Trusted", "Trust an installed extension's executable code.")
policy_command("untrust", False, "Trust revoked", "Revoke trust and stop extension activation.")
@app.command("permissions")
def set_permissions(
extension_id: str = typer.Argument(..., help="Extension ID"),
permissions: list[str] = typer.Argument(
None,
help="Exact permissions to grant; omit all to revoke every grant",
),
) -> None:
"""Replace the extension's granted host permissions."""
payload = run(service().set_permissions(extension_id, set(permissions or [])))
granted = payload["record"]["granted_permissions"]
console.print(
f"[green]Updated permissions for {extension_id}:[/green] "
+ (", ".join(granted) if granted else "none")
)
@app.command("uninstall")
def uninstall_extension(
extension_id: str = typer.Argument(..., help="Extension ID"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
) -> None:
"""Remove an installed extension."""
if not yes and not typer.confirm(f"Uninstall extension '{extension_id}'?"):
raise typer.Abort()
run(service().uninstall(extension_id))
console.print(f"[green]Uninstalled {extension_id}[/green]")
return app
def _print_named_rows(
console: Console,
title: str,
rows: list[dict[str, Any]],
category_key: str,
name_key: str,
) -> None:
console.print(f"\n[bold]{title}[/bold]")
if not rows:
console.print(" [dim]None[/dim]")
return
for row in rows:
console.print(f" {row[category_key]}: {row[name_key]}")
def _print_permissions(
console: Console,
permissions: list[dict[str, str]],
granted: set[str],
) -> None:
console.print("\n[bold]Permissions[/bold]")
if not permissions:
console.print(" [dim]None requested[/dim]")
return
for permission in permissions:
status = "[green]granted[/green]" if permission["name"] in granted else "[yellow]pending[/yellow]"
reason = f"{permission['reason']}" if permission["reason"] else ""
console.print(f" {permission['name']} ({status}){reason}")
+44 -3
View File
@@ -64,16 +64,57 @@ class CommandRouter:
self._priority: dict[str, Handler] = {} self._priority: dict[str, Handler] = {}
self._exact: dict[str, Handler] = {} self._exact: dict[str, Handler] = {}
self._prefix: list[tuple[str, Handler]] = [] self._prefix: list[tuple[str, Handler]] = []
self._owners: dict[tuple[str, str], str] = {}
def priority(self, cmd: str, handler: Handler) -> None: def priority(
self,
cmd: str,
handler: Handler,
*,
owner: str = "nanobot.core",
) -> None:
self._priority[cmd] = handler self._priority[cmd] = handler
self._owners[("priority", cmd)] = owner
def exact(self, cmd: str, handler: Handler) -> None: def exact(
self,
cmd: str,
handler: Handler,
*,
owner: str = "nanobot.core",
) -> None:
self._exact[cmd] = handler self._exact[cmd] = handler
self._owners[("exact", cmd)] = owner
def prefix(self, pfx: str, handler: Handler) -> None: def prefix(
self,
pfx: str,
handler: Handler,
*,
owner: str = "nanobot.core",
) -> None:
self._prefix.append((pfx, handler)) self._prefix.append((pfx, handler))
self._prefix.sort(key=lambda p: len(p[0]), reverse=True) self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
self._owners[("prefix", pfx)] = owner
def owner(self, tier: str, command: str) -> str | None:
"""Return the extension that owns one command registration."""
return self._owners.get((tier, command))
def unregister_owner(self, owner: str) -> None:
"""Remove all command tiers registered by one extension."""
for (tier, command), registered_owner in list(self._owners.items()):
if registered_owner != owner:
continue
if tier == "priority":
self._priority.pop(command, None)
elif tier == "exact":
self._exact.pop(command, None)
else:
self._prefix = [
item for item in self._prefix if item[0] != command
]
self._owners.pop((tier, command), None)
def is_priority(self, text: str) -> bool: def is_priority(self, text: str) -> bool:
return normalize_command_text(text).lower() in self._priority return normalize_command_text(text).lower() in self._priority
-18
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import Any from typing import Any
import pydantic import pydantic
from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs from nanobot.config.schema import Config, _resolve_tool_config_refs
@@ -200,23 +199,6 @@ def _env_replace(match: re.Match[str]) -> str:
def _migrate_config(data: dict) -> dict: def _migrate_config(data: dict) -> dict:
"""Migrate old config formats to current.""" """Migrate old config formats to current."""
agents = data.get("agents", {})
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
if isinstance(defaults, dict):
had_legacy_max_messages = (
"maxMessages" in defaults or "max_messages" in defaults
)
defaults.pop("maxMessages", None)
defaults.pop("max_messages", None)
if had_legacy_max_messages:
# TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the
# final release that warns before the schema silently ignores the field.
logger.warning(
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
"replay max messages is now an internal safety cap. Remove it from "
"config. This compatibility warning will be removed in the next version."
)
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace # Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
tools = data.get("tools", {}) tools = data.get("tools", {})
exec_cfg = tools.get("exec", {}) exec_cfg = tools.get("exec", {})
+8 -4
View File
@@ -64,9 +64,6 @@ class DreamConfig(Base):
default=None, default=None,
validation_alias=AliasChoices("modelOverride", "model", "model_override"), validation_alias=AliasChoices("modelOverride", "model", "model_override"),
) # Override model for Dream sessions (pending implementation) ) # Override model for Dream sessions (pending implementation)
max_batch_size: int = Field(default=20, ge=1) # Deprecated: no longer used
max_iterations: int = Field(default=15, ge=1) # Deprecated: no longer used
annotate_line_ages: bool = True # Deprecated: no longer used
def build_schedule(self, timezone: str) -> CronSchedule: def build_schedule(self, timezone: str) -> CronSchedule:
"""Build the runtime schedule, preferring the legacy cron override if present.""" """Build the runtime schedule, preferring the legacy cron override if present."""
@@ -406,11 +403,17 @@ class ToolsConfig(Base):
"webuiAllowRemotePackageInstall", "webuiAllowRemotePackageInstall",
"webui_allow_remote_package_install", "webui_allow_remote_package_install",
), ),
) # allow non-local WebUI clients to install optional Python packages ) # allow non-local WebUI clients to install optional support and extension packages
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
class ExtensionsConfig(Base):
"""Global switch for external extension activation."""
enabled: bool = True
class Config(BaseSettings): class Config(BaseSettings):
"""Root configuration for nanobot.""" """Root configuration for nanobot."""
@@ -421,6 +424,7 @@ class Config(BaseSettings):
api: ApiConfig = Field(default_factory=ApiConfig) api: ApiConfig = Field(default_factory=ApiConfig)
gateway: GatewayConfig = Field(default_factory=GatewayConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig)
extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig)
model_presets: dict[str, ModelPresetConfig] = Field( model_presets: dict[str, ModelPresetConfig] = Field(
default_factory=dict, default_factory=dict,
validation_alias=AliasChoices("modelPresets", "model_presets"), validation_alias=AliasChoices("modelPresets", "model_presets"),
+19
View File
@@ -0,0 +1,19 @@
"""Stable author-facing API for native nanobot extensions."""
from nanobot.extensions.manifest import (
EXTENSION_API_VERSION,
DependencyKind,
ExtensionDependency,
ExtensionManifest,
ExtensionPermission,
)
from nanobot.extensions.runtime import PythonExtensionApi
__all__ = [
"EXTENSION_API_VERSION",
"DependencyKind",
"ExtensionDependency",
"ExtensionManifest",
"ExtensionPermission",
"PythonExtensionApi",
]
+64
View File
@@ -0,0 +1,64 @@
"""Discover installed extensions and resolve one activation snapshot."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from nanobot.extensions.preflight import evaluate_dependencies
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
ExtensionRegistry,
ExtensionSnapshot,
)
from nanobot.extensions.store import ExtensionStore
if TYPE_CHECKING:
from nanobot.config.schema import Config
@dataclass(frozen=True, slots=True)
class ExtensionCatalog:
"""Discovered candidates plus the active, policy-resolved snapshot."""
candidates: tuple[ExtensionCandidate, ...]
snapshot: ExtensionSnapshot
diagnostics: tuple[ExtensionDiagnostic, ...]
def build_extension_catalog(
config: Config,
*,
user_root: Path | None = None,
) -> ExtensionCatalog:
"""Build the authoritative extension view without executing package code."""
if not config.extensions.enabled:
return ExtensionCatalog((), ExtensionSnapshot((), ()), ())
discovery = ExtensionStore(user_root).discover()
candidates, dependency_diagnostics = evaluate_dependencies(
discovery.candidates
)
registry = ExtensionRegistry()
registry_diagnostics: list[ExtensionDiagnostic] = []
for candidate in candidates:
try:
registry.register(candidate)
except ValueError as exc:
registry_diagnostics.append(
ExtensionDiagnostic(
code="duplicate_installation",
extension_id=candidate.manifest.id,
message=str(exc),
)
)
snapshot = registry.snapshot()
diagnostics = (
discovery.diagnostics
+ dependency_diagnostics
+ tuple(registry_diagnostics)
+ snapshot.diagnostics
)
return ExtensionCatalog(candidates, snapshot, diagnostics)
+56
View File
@@ -0,0 +1,56 @@
"""JSON persistence for extension manifests."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pydantic import ValidationError
from nanobot.extensions.manifest import ExtensionManifest
MANIFEST_FILENAME = "nanobot.extension.json"
class ManifestFormatError(ValueError):
"""Raised when a manifest cannot be decoded unambiguously."""
def load_manifest(path: Path) -> ExtensionManifest:
"""Read and validate one canonical JSON manifest."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ManifestFormatError(f"cannot read extension manifest {path}: {exc}") from exc
return manifest_from_mapping(data)
def dump_manifest(manifest: ExtensionManifest, path: Path) -> None:
"""Write one canonical JSON manifest."""
path.write_text(
json.dumps(manifest_to_mapping(manifest), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def manifest_from_mapping(data: object) -> ExtensionManifest:
"""Decode a manifest and reject unknown or invalid fields."""
try:
return ExtensionManifest.model_validate(data)
except ValidationError as exc:
unknown = sorted(
".".join(str(part) for part in error["loc"])
for error in exc.errors()
if error["type"] == "extra_forbidden"
)
if unknown:
raise ManifestFormatError(
f"extension manifest has unknown fields: {', '.join(unknown)}"
) from exc
raise ManifestFormatError(f"invalid extension manifest: {exc}") from exc
def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
"""Return the canonical JSON representation."""
return manifest.model_dump(mode="json", by_alias=True)
+71
View File
@@ -0,0 +1,71 @@
"""Side-effect-free discovery of extension manifests on disk."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
)
@dataclass(frozen=True, slots=True)
class ExtensionDiscoveryResult:
candidates: tuple[ExtensionCandidate, ...] = ()
diagnostics: tuple[ExtensionDiagnostic, ...] = ()
def discover_manifest_root(
root: Path,
) -> ExtensionDiscoveryResult:
"""Discover direct children containing ``nanobot.extension.json``."""
if not root.exists():
return ExtensionDiscoveryResult()
if not root.is_dir():
return ExtensionDiscoveryResult(
diagnostics=(
ExtensionDiagnostic(
code="invalid_extension_root",
extension_id="",
message=f"extension root is not a directory: {root}",
),
)
)
manifests = []
direct_manifest = root / MANIFEST_FILENAME
if direct_manifest.is_file():
manifests.append(direct_manifest)
manifests.extend(
sorted(
path / MANIFEST_FILENAME
for path in root.iterdir()
if not path.name.startswith(".")
and path.is_dir()
and (path / MANIFEST_FILENAME).is_file()
)
)
candidates: list[ExtensionCandidate] = []
diagnostics: list[ExtensionDiagnostic] = []
for path in manifests:
try:
manifest = load_manifest(path)
candidates.append(
ExtensionCandidate(
manifest=manifest,
location=path.parent.resolve(),
)
)
except Exception as exc:
diagnostics.append(
ExtensionDiagnostic(
code="invalid_manifest",
extension_id=path.parent.name,
message=str(exc),
)
)
return ExtensionDiscoveryResult(tuple(candidates), tuple(diagnostics))
+89
View File
@@ -0,0 +1,89 @@
"""Agent-side lifecycle for first-class extensions."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.extensions.catalog import ExtensionCatalog, build_extension_catalog
from nanobot.extensions.registry import ExtensionDiagnostic
from nanobot.extensions.runtime import ActivationResult, ExtensionRuntimeManager
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
from nanobot.config.schema import Config
@dataclass(frozen=True, slots=True)
class ExtensionHostSnapshot:
"""Current discovery and activation result."""
catalog: ExtensionCatalog
activation: ActivationResult
@property
def diagnostics(self) -> tuple[ExtensionDiagnostic, ...]:
return self.catalog.diagnostics + self.activation.diagnostics
class ExtensionHost:
"""Reload external extensions without coupling their lifecycle to AgentLoop."""
def __init__(
self,
agent: AgentLoop,
config_loader: Callable[[], Config],
*,
user_root: Path | None = None,
) -> None:
self._agent = agent
self._config_loader = config_loader
self._user_root = user_root
self._manager: ExtensionRuntimeManager | None = None
self._snapshot: ExtensionHostSnapshot | None = None
self._lock = asyncio.Lock()
@property
def snapshot(self) -> ExtensionHostSnapshot | None:
return self._snapshot
async def reload(self) -> ExtensionHostSnapshot:
async with self._lock:
await self._close_manager()
self._snapshot = None
config = self._config_loader()
catalog = build_extension_catalog(
config,
user_root=self._user_root,
)
manager = ExtensionRuntimeManager(
tools=self._agent.tools,
commands=self._agent.commands,
hook_factories=self._agent._hook_factories,
)
activation = await manager.activate(catalog.snapshot)
self._manager = manager
self._snapshot = ExtensionHostSnapshot(catalog, activation)
for diagnostic in self._snapshot.diagnostics:
logger.warning(
"Extension {} [{}]: {}",
diagnostic.extension_id,
diagnostic.code,
diagnostic.message,
)
return self._snapshot
async def close(self) -> None:
async with self._lock:
await self._close_manager()
self._snapshot = None
async def _close_manager(self) -> None:
if self._manager is not None:
await self._manager.close()
self._manager = None
+126
View File
@@ -0,0 +1,126 @@
"""Strict schema for native nanobot extension packages."""
from __future__ import annotations
import re
from enum import Enum
from pathlib import Path
from typing import Literal, Self
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
EXTENSION_API_VERSION = 1
_IDENTIFIER = re.compile(r"[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?")
_PERMISSION = re.compile(r"[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*")
class DependencyKind(str, Enum):
"""Kinds of prerequisites resolved before activation."""
PYTHON = "python"
EXECUTABLE = "executable"
ENVIRONMENT = "environment"
class _ManifestModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
class ExtensionDependency(_ManifestModel):
"""One activation prerequisite declared by an extension."""
kind: DependencyKind
name: str
specifier: str = ""
optional: bool = False
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
return _require_text(value, "extension dependency name")
class ExtensionPermission(_ManifestModel):
"""A privileged host capability requested by an extension."""
name: str
reason: str = ""
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if _PERMISSION.fullmatch(value) is None:
raise ValueError(
"extension permission must be a lowercase namespaced identifier"
)
return value
class ExtensionManifest(_ManifestModel):
"""Identity, prerequisites, and consent declarations for one extension."""
id: str
name: str
version: str
entry: str = "extension:register"
description: str = ""
dependencies: tuple[ExtensionDependency, ...] = ()
permissions: tuple[ExtensionPermission, ...] = ()
api_version: Literal[EXTENSION_API_VERSION] = Field(
default=EXTENSION_API_VERSION,
alias="apiVersion",
)
homepage: str = ""
license: str = ""
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
return _require_identifier(value, "extension id")
@field_validator("name", "version")
@classmethod
def validate_required_text(cls, value: str, info) -> str:
return _require_text(value, f"extension {info.field_name}")
@field_validator("entry")
@classmethod
def validate_entry(cls, value: str) -> str:
value = _require_text(value, "extension entry")
module_name = value.partition(":")[0]
if Path(module_name).is_absolute() or ".." in Path(module_name).parts:
raise ValueError("extension entry cannot escape the package root")
return value
@model_validator(mode="after")
def reject_duplicates(self) -> Self:
dependencies = [(item.kind, item.name) for item in self.dependencies]
if len(set(dependencies)) != len(dependencies):
raise ValueError("extension manifest contains duplicate dependencies")
permissions = [item.name for item in self.permissions]
if len(set(permissions)) != len(permissions):
raise ValueError("extension manifest contains duplicate permissions")
return self
def _require_text(value: str, label: str) -> str:
if not value.strip():
raise ValueError(f"{label} must be a non-empty string")
return value
def _require_identifier(value: str, label: str) -> str:
value = _require_text(value, label)
if _IDENTIFIER.fullmatch(value) is None:
raise ValueError(
f"{label} must use lowercase letters, digits, dots, underscores, or hyphens"
)
return value
def validate_extension_id(value: object) -> str:
"""Validate and return one portable extension identifier."""
if not isinstance(value, str):
raise ValueError("extension id must be a string")
return _require_identifier(value, "extension id")
+59
View File
@@ -0,0 +1,59 @@
"""Activation preflight for extension runtime prerequisites."""
from __future__ import annotations
import importlib.metadata
import os
import shutil
from dataclasses import replace
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
from nanobot.extensions.versioning import dependency_version_failure
def evaluate_dependencies(
candidates: tuple[ExtensionCandidate, ...],
) -> tuple[tuple[ExtensionCandidate, ...], tuple[ExtensionDiagnostic, ...]]:
"""Disable candidates with missing required software and explain why."""
checked: list[ExtensionCandidate] = []
diagnostics: list[ExtensionDiagnostic] = []
for candidate in candidates:
failures = [
message
for dependency in candidate.manifest.dependencies
if not dependency.optional
if (message := _dependency_failure(dependency))
]
if failures:
candidate = replace(candidate, enabled=False)
diagnostics.extend(
ExtensionDiagnostic(
code="dependency_missing",
extension_id=candidate.manifest.id,
message=message,
)
for message in failures
)
checked.append(candidate)
return tuple(checked), tuple(diagnostics)
def _dependency_failure(
dependency: ExtensionDependency,
) -> str:
if dependency.kind is DependencyKind.EXECUTABLE:
if shutil.which(dependency.name) is None:
return f"Required executable is not installed: {dependency.name}"
return ""
if dependency.kind is DependencyKind.ENVIRONMENT:
if not os.getenv(dependency.name):
return f"Required environment variable is not set: {dependency.name}"
return ""
if dependency.kind is DependencyKind.PYTHON:
try:
version = importlib.metadata.version(dependency.name)
except importlib.metadata.PackageNotFoundError:
return f"Required Python package is not installed: {dependency.name}"
return dependency_version_failure(dependency, version, "Python package")
return f"Unsupported dependency kind: {dependency.kind.value}"
+82
View File
@@ -0,0 +1,82 @@
"""Deterministic extension activation planning."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from nanobot.extensions.manifest import ExtensionManifest
@dataclass(frozen=True, slots=True)
class ExtensionCandidate:
"""One discovered extension package and its activation state."""
manifest: ExtensionManifest
location: Path | None = None
enabled: bool = True
trusted: bool = False
integrity_valid: bool = True
granted_permissions: frozenset[str] = frozenset()
@dataclass(frozen=True, slots=True)
class ExtensionDiagnostic:
"""A non-fatal discovery or activation problem."""
code: str
extension_id: str
message: str
severity: str = "warning"
@dataclass(frozen=True, slots=True)
class ExtensionSnapshot:
"""Immutable activation plan consumed by the runtime."""
extensions: tuple[ExtensionCandidate, ...]
diagnostics: tuple[ExtensionDiagnostic, ...]
class ExtensionRegistry:
"""Select trusted candidates and report missing permission grants."""
def __init__(self) -> None:
self._candidates: dict[str, ExtensionCandidate] = {}
def register(self, candidate: ExtensionCandidate) -> None:
extension_id = candidate.manifest.id
if extension_id in self._candidates:
raise ValueError(f"extension '{extension_id}' is already installed")
self._candidates[extension_id] = candidate
def snapshot(self) -> ExtensionSnapshot:
active: list[ExtensionCandidate] = []
diagnostics: list[ExtensionDiagnostic] = []
for candidate in sorted(
self._candidates.values(),
key=lambda item: item.manifest.id,
):
requested = {
permission.name for permission in candidate.manifest.permissions
}
missing = sorted(requested - candidate.granted_permissions)
if (
candidate.enabled
and candidate.integrity_valid
and candidate.trusted
and not missing
):
active.append(candidate)
elif candidate.enabled and candidate.trusted and missing:
diagnostics.append(
ExtensionDiagnostic(
code="permission_required",
extension_id=candidate.manifest.id,
message=(
"Grant required extension permissions: "
+ ", ".join(missing)
),
)
)
return ExtensionSnapshot(tuple(active), tuple(diagnostics))
+218
View File
@@ -0,0 +1,218 @@
"""Transactional activation at nanobot's tool, command, and hook seams."""
from __future__ import annotations
import importlib
import shutil
import sys
from dataclasses import dataclass
from importlib.machinery import ModuleSpec
from pathlib import Path
from types import ModuleType
from typing import Any
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.command.router import CommandRouter, Handler
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
ExtensionSnapshot,
)
@dataclass(frozen=True, slots=True)
class ActivationResult:
"""Immutable activation outcome consumed by the agent assembly layer."""
extensions: tuple[ExtensionCandidate, ...]
hook_factories: tuple[AgentTurnHookFactory, ...]
diagnostics: tuple[ExtensionDiagnostic, ...]
class PythonExtensionApi:
"""Small native API; extensions register into existing nanobot interfaces."""
def __init__(
self,
*,
owner: str,
tools: ToolRegistry,
commands: CommandRouter,
hook_factories: list[AgentTurnHookFactory],
) -> None:
self.owner = owner
self._tools = tools
self._commands = commands
self._hook_factories = hook_factories
def register_tool(self, tool: Tool) -> None:
if not self._tools.register_if_absent(tool, owner=self.owner):
existing = self._tools.owner(tool.name) or "unknown"
raise ValueError(
f"tool '{tool.name}' is already registered by '{existing}'"
)
def register_command(
self,
command: str,
handler: Handler,
*,
prefix: bool = False,
) -> None:
command = f"/{command.lstrip('/')}"
if prefix:
command = f"{command} "
register = self._commands.prefix if prefix else self._commands.exact
tier = "prefix" if prefix else "exact"
if existing := self._commands.owner(tier, command):
raise ValueError(
f"command '{command}' is already registered by '{existing}'"
)
register(command, handler, owner=self.owner)
def register_hook_factory(self, factory: AgentTurnHookFactory) -> None:
self._hook_factories.append(_owned_hook_factory(factory, self.owner))
class ExtensionRuntimeManager:
"""Activate a resolved snapshot and roll back failed registrations."""
def __init__(
self,
*,
tools: ToolRegistry,
commands: CommandRouter,
hook_factories: list[AgentTurnHookFactory] | None = None,
) -> None:
self._tools = tools
self._commands = commands
self._active: list[ExtensionCandidate] = []
self._hook_factories = hook_factories if hook_factories is not None else []
async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult:
diagnostics: list[ExtensionDiagnostic] = []
for candidate in snapshot.extensions:
try:
active = self._activate_candidate(candidate)
self._active.append(active)
except Exception as exc:
self._rollback_owner(candidate.manifest.id)
diagnostics.append(
ExtensionDiagnostic(
code="activation_failed",
extension_id=candidate.manifest.id,
message=str(exc),
)
)
return ActivationResult(
tuple(self._active),
tuple(self._hook_factories),
tuple(diagnostics),
)
async def close(self) -> None:
for active in reversed(self._active):
self._rollback_owner(active.manifest.id)
_unload_extension_modules(active)
self._active.clear()
def _activate_candidate(
self,
candidate: ExtensionCandidate,
) -> ExtensionCandidate:
self._activate_python(candidate)
return candidate
def _activate_python(self, candidate: ExtensionCandidate) -> None:
raw_entry = candidate.manifest.entry
module_name, separator, attribute = raw_entry.partition(":")
if not separator:
module_name = raw_entry
attribute = "register"
assert candidate.location is not None
importlib.invalidate_caches()
module_prefix = _module_prefix(candidate.manifest.id)
_unload_extension_modules(candidate)
package = ModuleType(module_prefix)
package.__package__ = module_prefix
package.__path__ = [str(candidate.location)]
package.__spec__ = ModuleSpec(module_prefix, loader=None, is_package=True)
sys.modules[module_prefix] = package
try:
module = importlib.import_module(f"{module_prefix}.{module_name}")
module_path = getattr(module, "__file__", None)
if not module_path or not Path(module_path).resolve().is_relative_to(
candidate.location.resolve()
):
raise ValueError(
f"Python extension entry resolves outside its package: {module_name}"
)
register = getattr(module, attribute)
api = PythonExtensionApi(
owner=candidate.manifest.id,
tools=self._tools,
commands=self._commands,
hook_factories=self._hook_factories,
)
result = register(api)
if result is not None:
raise TypeError("Python extension register function must return None")
except Exception:
_unload_extension_modules(candidate)
raise
def _rollback_owner(
self,
owner: str,
) -> None:
self._tools.unregister_owner(owner)
self._commands.unregister_owner(owner)
self._hook_factories[:] = [
factory
for factory in self._hook_factories
if getattr(factory, "__nanobot_extension_owner__", None) != owner
]
def _owned_hook_factory(
factory: AgentTurnHookFactory,
owner: str,
) -> AgentTurnHookFactory:
def owned(context: Any) -> AgentHook | None:
return factory(context)
setattr(owned, "__nanobot_extension_owner__", owner)
return owned
def _modules_under(root: Path) -> tuple[str, ...]:
package_root = root.resolve()
return tuple(
name
for name, module in tuple(sys.modules.items())
if (raw_path := getattr(module, "__file__", None))
and Path(raw_path).resolve().is_relative_to(package_root)
)
def _unload_modules_under(root: Path) -> None:
package_root = root.resolve()
for module_name in _modules_under(package_root):
sys.modules.pop(module_name, None)
for cache in package_root.rglob("__pycache__"):
shutil.rmtree(cache, ignore_errors=True)
def _module_prefix(extension_id: str) -> str:
return "_nanobot_extension_" + extension_id.encode().hex()
def _unload_extension_modules(candidate: ExtensionCandidate) -> None:
assert candidate.location is not None
prefix = _module_prefix(candidate.manifest.id)
for name in tuple(sys.modules):
if name == prefix or name.startswith(f"{prefix}."):
sys.modules.pop(name, None)
_unload_modules_under(candidate.location)
+167
View File
@@ -0,0 +1,167 @@
"""Transport-neutral extension management service."""
from __future__ import annotations
import asyncio
from dataclasses import asdict
from pathlib import Path
from typing import Any
from nanobot.extensions.host import ExtensionHost
from nanobot.extensions.manifest import ExtensionManifest
from nanobot.extensions.registry import ExtensionCandidate
from nanobot.extensions.store import ExtensionStore, InstalledExtension
class ExtensionService:
"""One management boundary shared by CLI and WebUI."""
def __init__(
self,
*,
host: ExtensionHost | None = None,
store: ExtensionStore | None = None,
) -> None:
self.host = host
self.store = store or ExtensionStore()
self._mutation_lock = asyncio.Lock()
async def status(self) -> dict[str, Any]:
snapshot = self.host.snapshot if self.host else None
catalog = snapshot.catalog if snapshot else None
if catalog is None:
discovery = self.store.discover()
candidates = discovery.candidates
diagnostics = discovery.diagnostics
active_ids: set[str] = set()
else:
candidates = catalog.candidates
active_ids = {
active.manifest.id
for active in snapshot.activation.extensions
}
diagnostics = catalog.diagnostics + snapshot.activation.diagnostics
records = self.store.records()
return {
"extensions": [
_candidate_payload(candidate, active_ids, records.get(candidate.manifest.id))
for candidate in sorted(
candidates,
key=lambda item: item.manifest.name.lower(),
)
],
"diagnostics": [asdict(item) for item in diagnostics],
}
async def install(
self,
source: str,
*,
kind: str = "git",
ref: str = "",
trusted: bool = False,
) -> dict[str, Any]:
async with self._mutation_lock:
if kind == "git":
result = await asyncio.to_thread(
self.store.install_git,
source,
ref=ref,
trusted=trusted,
)
elif kind == "local":
result = await asyncio.to_thread(
self.store.install_local,
Path(source),
trusted=trusted,
)
else:
raise ValueError(f"unknown extension source kind: {kind}")
await self._reload()
return {
"record": _record_payload(result.record),
"manifest": _manifest_payload(result.manifest),
}
async def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]:
return await self._update(extension_id, self.store.set_enabled, enabled)
async def set_trusted(self, extension_id: str, trusted: bool) -> dict[str, Any]:
return await self._update(extension_id, self.store.set_trusted, trusted)
async def set_permissions(
self,
extension_id: str,
permissions: set[str] | frozenset[str],
) -> dict[str, Any]:
return await self._update(
extension_id,
self.store.set_permissions,
permissions,
)
async def uninstall(self, extension_id: str) -> dict[str, Any]:
async with self._mutation_lock:
await asyncio.to_thread(self.store.uninstall, extension_id)
await self._reload()
return {"removed": extension_id}
async def _update(self, extension_id: str, action: Any, value: Any) -> dict[str, Any]:
async with self._mutation_lock:
record = await asyncio.to_thread(action, extension_id, value)
await self._reload()
return {"record": _record_payload(record)}
async def _reload(self) -> None:
if self.host is not None:
await self.host.reload()
def _candidate_payload(
candidate: ExtensionCandidate,
active_ids: set[str],
record: InstalledExtension | None,
) -> dict[str, Any]:
manifest = candidate.manifest
requested = [permission.name for permission in manifest.permissions]
return {
**_manifest_payload(manifest),
"location": str(candidate.location) if candidate.location else None,
"enabled": candidate.enabled,
"trusted": candidate.trusted,
"active": manifest.id in active_ids,
"requested_permissions": requested,
"granted_permissions": sorted(candidate.granted_permissions),
"source": record.source.value if record else "path",
"source_ref": record.source_ref if record else "",
"integrity": record.integrity if record else "",
"installed_at": record.installed_at if record else "",
}
def _manifest_payload(manifest: ExtensionManifest) -> dict[str, Any]:
return {
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"description": manifest.description,
"homepage": manifest.homepage,
"license": manifest.license,
"dependencies": [
{
"kind": dependency.kind.value,
"name": dependency.name,
"specifier": dependency.specifier,
"optional": dependency.optional,
}
for dependency in manifest.dependencies
],
"permissions": [
{"name": permission.name, "reason": permission.reason}
for permission in manifest.permissions
],
}
def _record_payload(record: InstalledExtension) -> dict[str, Any]:
return record.model_dump(mode="json")
+511
View File
@@ -0,0 +1,511 @@
"""Atomic installation store and trust state for external extensions."""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from uuid import uuid4
from filelock import FileLock
from pydantic import BaseModel, ConfigDict, field_validator
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
from nanobot.extensions.discovery import (
ExtensionDiscoveryResult,
discover_manifest_root,
)
from nanobot.extensions.manifest import ExtensionManifest, validate_extension_id
from nanobot.extensions.registry import ExtensionDiagnostic
_REGISTRY_FILENAME = ".registry.json"
_GIT_SCHEMES = frozenset({"git", "http", "https", "ssh"})
_SCP_GIT_URL = re.compile(
r"(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?:\S+"
)
_SHA256_INTEGRITY = re.compile(r"sha256:[0-9a-f]{64}")
class ExtensionSourceKind(str, Enum):
LOCAL = "local"
GIT = "git"
class InstalledExtension(BaseModel):
"""Persistent installation and policy record."""
model_config = ConfigDict(extra="forbid", frozen=True)
id: str
version: str
source: ExtensionSourceKind
source_ref: str
integrity: str
installed_at: str
enabled: bool = True
trusted: bool = False
granted_permissions: tuple[str, ...] = ()
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
return validate_extension_id(value)
@field_validator("version", "source_ref", "installed_at")
@classmethod
def validate_metadata(cls, value: str) -> str:
if not value:
raise ValueError("extension registry metadata must use non-empty strings")
return value
@field_validator("integrity")
@classmethod
def validate_integrity(cls, value: str) -> str:
if _SHA256_INTEGRITY.fullmatch(value) is None:
raise ValueError("extension registry integrity must be a sha256 digest")
return value
@field_validator("granted_permissions")
@classmethod
def reject_duplicate_permissions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value):
raise ValueError("extension granted permissions cannot contain duplicates")
return value
@dataclass(frozen=True, slots=True)
class InstallResult:
"""Installed package metadata."""
record: InstalledExtension
manifest: ExtensionManifest
class ExtensionStore:
"""Own the user extension directory and its atomic registry."""
def __init__(self, root: Path | None = None) -> None:
self.root = (root or Path.home() / ".nanobot" / "extensions").expanduser()
self.root.mkdir(parents=True, exist_ok=True)
self.registry_path = self.root / _REGISTRY_FILENAME
self._lock = FileLock(str(self.root / ".lock"))
def records(self, *, strict: bool = False) -> dict[str, InstalledExtension]:
if not self.registry_path.is_file():
return {}
try:
data = json.loads(self.registry_path.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("version") != 1:
raise ValueError("extension registry must be a version 1 object")
rows = data.get("extensions")
if not isinstance(rows, list):
raise ValueError("extension registry extensions must be an array")
records: dict[str, InstalledExtension] = {}
for item in rows:
record = InstalledExtension.model_validate(item)
if record.id in records:
raise ValueError(
f"extension registry contains duplicate id: {record.id}"
)
records[record.id] = record
return records
except (
OSError,
UnicodeError,
json.JSONDecodeError,
KeyError,
ValueError,
) as exc:
if strict:
raise ValueError(
f"invalid extension registry {self.registry_path}: {exc}"
) from exc
return {}
def discover(self) -> ExtensionDiscoveryResult:
"""Discover packages and apply persisted enable/trust state."""
result = discover_manifest_root(self.root)
diagnostics = list(result.diagnostics)
try:
records = self.records(strict=True)
except ValueError as exc:
records = {}
diagnostics.append(
ExtensionDiagnostic(
code="invalid_extension_registry",
extension_id="",
message=str(exc),
)
)
candidates = []
for candidate in result.candidates:
record = records.get(candidate.manifest.id)
trusted = record.trusted if record else False
integrity_valid = True
if candidate.location is not None and record is not None:
try:
_reject_unsafe_files(candidate.location)
actual_integrity = _tree_hash(candidate.location)
except (OSError, ValueError) as exc:
actual_integrity = ""
diagnostics.append(
ExtensionDiagnostic(
code="extension_integrity_error",
extension_id=candidate.manifest.id,
message=f"Could not verify installed package: {exc}",
)
)
if actual_integrity != record.integrity:
trusted = False
integrity_valid = False
diagnostics.append(
ExtensionDiagnostic(
code="extension_integrity_mismatch",
extension_id=candidate.manifest.id,
message=(
"Installed package contents changed after installation; "
"reinstall it before trusting it again"
),
)
)
candidates.append(
replace(
candidate,
enabled=record.enabled if record else True,
trusted=trusted,
integrity_valid=integrity_valid,
granted_permissions=frozenset(
record.granted_permissions if record else ()
),
)
)
return ExtensionDiscoveryResult(tuple(candidates), tuple(diagnostics))
def install_local(
self,
source: Path,
*,
trusted: bool = False,
) -> InstallResult:
return self._install_from_directory(
source.resolve(),
source_kind=ExtensionSourceKind.LOCAL,
source_ref=str(source.resolve()),
trusted=trusted,
)
def install_git(
self,
url: str,
*,
ref: str = "",
trusted: bool = False,
) -> InstallResult:
_validate_git_url(url)
with tempfile.TemporaryDirectory(prefix="nanobot-extension-git-") as raw:
checkout = Path(raw) / "checkout"
if ref:
_run(
[
"git",
"clone",
"--filter=blob:none",
"--no-checkout",
"--",
url,
str(checkout),
]
)
_run(
[
"git",
"-C",
str(checkout),
"fetch",
"--depth",
"1",
"--",
"origin",
ref,
]
)
_run(
[
"git",
"-C",
str(checkout),
"checkout",
"--detach",
"FETCH_HEAD",
]
)
else:
_run(["git", "clone", "--depth", "1", "--", url, str(checkout)])
return self._install_from_directory(
checkout,
source_kind=ExtensionSourceKind.GIT,
source_ref=f"{url}#{ref}" if ref else url,
trusted=trusted,
)
def set_enabled(self, extension_id: str, enabled: bool) -> InstalledExtension:
return self._update_record(extension_id, enabled=enabled)
def set_trusted(self, extension_id: str, trusted: bool) -> InstalledExtension:
return self._update_record(extension_id, trusted=trusted)
def set_permissions(
self,
extension_id: str,
permissions: set[str] | frozenset[str],
) -> InstalledExtension:
with self._lock:
records = self.records(strict=True)
if extension_id not in records:
raise KeyError(f"extension '{extension_id}' is not installed")
manifest = load_manifest(
self.root / extension_id / MANIFEST_FILENAME
)
requested = {
permission.name for permission in manifest.permissions
}
unknown = sorted(set(permissions) - requested)
if unknown:
raise ValueError(
"Cannot grant permissions not requested by the extension: "
+ ", ".join(unknown)
)
return self._update_record_locked(
records,
extension_id,
granted_permissions=tuple(sorted(permissions)),
)
def uninstall(self, extension_id: str) -> None:
with self._lock:
records = self.records(strict=True)
if extension_id not in records:
raise KeyError(f"extension '{extension_id}' is not installed")
target = self.root / extension_id
backup = self.root / f".uninstall-{uuid4().hex}"
if target.exists():
target.rename(backup)
try:
records.pop(extension_id)
self._write_records(records)
except Exception:
if backup.exists():
backup.rename(target)
raise
shutil.rmtree(backup, ignore_errors=True)
def _install_from_directory(
self,
source: Path,
*,
source_kind: ExtensionSourceKind,
source_ref: str,
trusted: bool,
) -> InstallResult:
with self._lock:
return self._install_from_directory_locked(
source,
source_kind=source_kind,
source_ref=source_ref,
trusted=trusted,
)
def _install_from_directory_locked(
self,
source: Path,
*,
source_kind: ExtensionSourceKind,
source_ref: str,
trusted: bool,
) -> InstallResult:
if not source.is_dir():
raise ValueError(f"extension source is not a directory: {source}")
if self.root.resolve().is_relative_to(source.resolve()):
raise ValueError("extension source cannot contain the extension store")
_reject_unsafe_files(source)
manifest = load_manifest(source / MANIFEST_FILENAME)
extension_id = manifest.id
self.root.mkdir(parents=True, exist_ok=True)
staging = self.root / f".install-{uuid4().hex}"
target = self.root / extension_id
backup = self.root / f".backup-{uuid4().hex}"
records = self.records(strict=True)
previous = records.get(extension_id)
backup_created = False
target_installed = False
try:
shutil.copytree(
source,
staging,
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
)
_reject_unsafe_files(staging)
integrity = _tree_hash(staging)
if target.exists():
target.rename(backup)
backup_created = True
staging.rename(target)
target_installed = True
requested_permissions = {
permission.name for permission in manifest.permissions
}
unchanged = bool(previous and previous.integrity == integrity)
record = InstalledExtension(
id=extension_id,
version=manifest.version,
source=source_kind,
source_ref=source_ref,
integrity=integrity,
installed_at=datetime.now(UTC).isoformat(),
enabled=previous.enabled if previous else True,
trusted=trusted or bool(unchanged and previous and previous.trusted),
granted_permissions=(
tuple(
permission
for permission in previous.granted_permissions
if permission in requested_permissions
)
if previous
else ()
),
)
records[extension_id] = record
self._write_records(records)
shutil.rmtree(backup, ignore_errors=True)
return InstallResult(record, manifest)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
if target_installed:
shutil.rmtree(target, ignore_errors=True)
if backup_created:
backup.rename(target)
raise
def _update_record(
self,
extension_id: str,
**changes: Any,
) -> InstalledExtension:
with self._lock:
records = self.records(strict=True)
return self._update_record_locked(records, extension_id, **changes)
def _update_record_locked(
self,
records: dict[str, InstalledExtension],
extension_id: str,
**changes: Any,
) -> InstalledExtension:
try:
record = records[extension_id].model_copy(update=changes)
except KeyError as exc:
raise KeyError(
f"extension '{extension_id}' is not installed"
) from exc
records[extension_id] = record
self._write_records(records)
return record
def _write_records(self, records: dict[str, InstalledExtension]) -> None:
self.root.mkdir(parents=True, exist_ok=True)
payload = {
"version": 1,
"extensions": [
record.model_dump(mode="json")
for record in sorted(records.values(), key=lambda item: item.id)
],
}
temp = self.registry_path.with_suffix(".tmp")
temp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(temp, self.registry_path)
def _run(command: list[str], *, cwd: Path | None = None) -> str:
try:
return subprocess.run(
command,
cwd=cwd,
check=True,
capture_output=True,
text=True,
).stdout
except FileNotFoundError as exc:
raise RuntimeError(f"required executable not found: {command[0]}") from exc
except subprocess.CalledProcessError as exc:
detail = (exc.stderr or exc.stdout or "").strip()
raise RuntimeError(f"{command[0]} failed: {detail}") from exc
def _validate_git_url(url: str) -> None:
if not isinstance(url, str) or not url.strip() or any(
character in url for character in ("\0", "\r", "\n")
):
raise ValueError("extension Git source must be a remote repository URL")
value = url.strip()
parsed = urlparse(value)
if parsed.scheme:
if parsed.scheme.lower() not in _GIT_SCHEMES or not parsed.hostname or not parsed.path:
raise ValueError(
"extension Git source must use git, http, https, or ssh"
)
if parsed.password or (
parsed.scheme.lower() in {"http", "https"} and parsed.username
):
raise ValueError(
"extension Git URLs cannot contain credentials; use a Git credential helper"
)
if parsed.query or parsed.fragment:
raise ValueError(
"extension Git URLs cannot contain query parameters or fragments; "
"pass the revision separately"
)
return
if _SCP_GIT_URL.fullmatch(value) is None or any(
character in value for character in ("?", "#")
):
raise ValueError("extension Git source must be a remote repository URL")
def _reject_unsafe_files(root: Path) -> None:
for path in root.rglob("*"):
if path.is_symlink():
raise ValueError(f"extension packages cannot contain symlinks: {path}")
if not path.is_file() and not path.is_dir():
raise ValueError(f"extension package contains a special file: {path}")
def _tree_hash(root: Path) -> str:
digest = hashlib.sha256()
for path in sorted(
item
for item in root.rglob("*")
if (item.is_file() or item.is_symlink())
and "__pycache__" not in item.parts
and item.suffix not in {".pyc", ".pyo"}
):
digest.update(path.relative_to(root).as_posix().encode())
digest.update(b"\0")
if path.is_symlink():
digest.update(b"link\0")
digest.update(os.fsencode(os.readlink(path)))
continue
digest.update(b"file\0")
with path.open("rb") as handle:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
return f"sha256:{digest.hexdigest()}"
+29
View File
@@ -0,0 +1,29 @@
"""Version constraint checks shared by extension activation gates."""
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from nanobot.extensions.manifest import ExtensionDependency
def dependency_version_failure(
dependency: ExtensionDependency,
version: str,
label: str,
) -> str:
"""Return a user-facing constraint failure, or an empty string on success."""
if not dependency.specifier:
return ""
try:
matches = Version(version) in SpecifierSet(dependency.specifier)
except (InvalidSpecifier, InvalidVersion):
return (
f"{label} {dependency.name} has an unsupported version constraint: "
f"{dependency.specifier}"
)
if matches:
return ""
return (
f"{label} {dependency.name} {version} does not satisfy "
f"{dependency.specifier}"
)
+20 -1
View File
@@ -11,6 +11,7 @@ from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.hooks import create_file_edit_activity_hook from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.extensions.host import ExtensionHost
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
from nanobot.sdk.runtime import ( from nanobot.sdk.runtime import (
@@ -77,6 +78,9 @@ class Nanobot:
self.sessions = SessionClient(loop) self.sessions = SessionClient(loop)
self.memory = MemoryClient(loop) self.memory = MemoryClient(loop)
self.runtime = RuntimeClient(loop) self.runtime = RuntimeClient(loop)
self._extensions = ExtensionHost(loop, lambda: config) if config else None
self._extensions_started = False
self._extensions_lock = asyncio.Lock()
@classmethod @classmethod
def from_config( def from_config(
@@ -153,6 +157,7 @@ class Nanobot:
model: Override the model for this run only. model: Override the model for this run only.
model_preset: Override the model preset for this run only. model_preset: Override the model preset for this run only.
""" """
await self._ensure_extensions()
capture = SDKCaptureHook() capture = SDKCaptureHook()
per_run_hooks = [capture, *(hooks or [])] per_run_hooks = [capture, *(hooks or [])]
runtime = self._loop.runtime_resolver.resolve_override( runtime = self._loop.runtime_resolver.resolve_override(
@@ -193,6 +198,7 @@ class Nanobot:
model_preset: str | None = None, model_preset: str | None = None,
) -> RunStream: ) -> RunStream:
"""Start a streamed run and return a handle for events and final result.""" """Start a streamed run and return a handle for events and final result."""
await self._ensure_extensions()
override_runtime = self._loop.runtime_resolver.resolve_override( override_runtime = self._loop.runtime_resolver.resolve_override(
model=model, model=model,
model_preset=model_preset, model_preset=model_preset,
@@ -316,7 +322,20 @@ class Nanobot:
async def aclose(self) -> None: async def aclose(self) -> None:
"""Release resources held by this instance (MCP connections, etc.).""" """Release resources held by this instance (MCP connections, etc.)."""
await self._loop.close_mcp() try:
if self._extensions is not None:
await self._extensions.close()
self._extensions_started = False
finally:
await self._loop.close_mcp()
async def _ensure_extensions(self) -> None:
if self._extensions is None or self._extensions_started:
return
async with self._extensions_lock:
if not self._extensions_started:
await self._extensions.reload()
self._extensions_started = True
async def __aenter__(self) -> Nanobot: async def __aenter__(self) -> Nanobot:
return self return self
+19 -66
View File
@@ -5,7 +5,6 @@ import errno
import json import json
import os import os
import re import re
import shutil
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from copy import deepcopy from copy import deepcopy
@@ -477,6 +476,14 @@ class SessionManager:
except _SESSION_DATA_ERRORS: except _SESSION_DATA_ERRORS:
return None return None
@classmethod
def _session_key_from_path(cls, path: Path) -> str | None:
"""Decode a session key only from a canonical collision-resistant filename."""
key = cls._decode_storage_key(path.stem)
if key is None or cls._storage_key(key) != path.stem:
return None
return key
def _get_session_path(self, key: str) -> Path: def _get_session_path(self, key: str) -> Path:
"""Get the collision-resistant workspace path for a session.""" """Get the collision-resistant workspace path for a session."""
return self.sessions_dir / f"{self._storage_key(key)}.jsonl" return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
@@ -489,61 +496,6 @@ class SessionManager:
"""Legacy global session path (~/.nanobot/sessions/).""" """Legacy global session path (~/.nanobot/sessions/)."""
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl" return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
@staticmethod
def _stored_key_for_path(path: Path) -> str | None:
"""Read the stored session key from a JSONL metadata row, if present."""
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
data = json.loads(line)
if not isinstance(data, dict):
raise ValueError("session records must be JSON objects")
if data.get("_type") == "metadata":
stored_key = data.get("key")
return stored_key if isinstance(stored_key, str) else None
return None
except _SESSION_DATA_ERRORS:
return None
return None
def _resolve_session_path(self, key: str, *, migrate: bool = False) -> Path | None:
"""Resolve a session path, falling back to legacy storage locations."""
path = self._get_session_path(key)
if path.exists():
return path
# TODO(v0.3.1): Remove both legacy fallbacks. v0.3.0 is the final
# compatibility window for reading and lazily migrating legacy session files.
fallback_paths = [
(self._get_legacy_lossy_path(key), "legacy lossy path"),
(self._get_legacy_session_path(key), "legacy path"),
]
for fallback_path, description in fallback_paths:
if not fallback_path.exists():
continue
stored_key = self._stored_key_for_path(fallback_path)
if stored_key and stored_key != key:
logger.info(
"Skipping session {} from {} because it belongs to {}",
key,
description,
stored_key,
)
continue
if not migrate:
return fallback_path
try:
shutil.move(str(fallback_path), str(path))
logger.info("Migrated session {} from {}", key, description)
except Exception:
logger.exception("Failed to migrate session {}", key)
return None
return path
return None
def get_or_create(self, key: str) -> Session: def get_or_create(self, key: str) -> Session:
""" """
Get an existing session or create a new one. Get an existing session or create a new one.
@@ -567,8 +519,8 @@ class SessionManager:
def _load(self, key: str) -> Session | None: def _load(self, key: str) -> Session | None:
"""Load a session from disk.""" """Load a session from disk."""
path = self._resolve_session_path(key, migrate=True) path = self._get_session_path(key)
if path is None: if not path.exists():
return None return None
try: try:
@@ -847,8 +799,8 @@ class SessionManager:
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
``None`` when the session file does not exist or fails to parse. ``None`` when the session file does not exist or fails to parse.
""" """
path = self._resolve_session_path(key) path = self._get_session_path(key)
if path is None: if not path.exists():
return None return None
try: try:
messages: list[dict[str, Any]] = [] messages: list[dict[str, Any]] = []
@@ -890,8 +842,8 @@ class SessionManager:
This is used by WebUI routes that need session-level metadata but not the This is used by WebUI routes that need session-level metadata but not the
full conversation transcript. full conversation transcript.
""" """
path = self._resolve_session_path(key) path = self._get_session_path(key)
if path is None: if not path.exists():
return None return None
try: try:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -935,8 +887,9 @@ class SessionManager:
sessions = [] sessions = []
for path in self.sessions_dir.glob("*.jsonl"): for path in self.sessions_dir.glob("*.jsonl"):
decoded = self._decode_storage_key(path.stem) storage_key = self._session_key_from_path(path)
fallback_key = decoded or path.stem.replace("_", ":", 1) if storage_key is None:
continue
try: try:
# Read the metadata line and a small preview for session lists. # Read the metadata line and a small preview for session lists.
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
@@ -946,7 +899,7 @@ class SessionManager:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("session records must be JSON objects") raise ValueError("session records must be JSON objects")
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or fallback_key key = data.get("key") or storage_key
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
title = _metadata_title(metadata) title = _metadata_title(metadata)
preview = "" preview = ""
@@ -991,7 +944,7 @@ class SessionManager:
except FileNotFoundError: except FileNotFoundError:
continue continue
except _SESSION_DATA_ERRORS: except _SESSION_DATA_ERRORS:
repaired = self._repair(fallback_key, path=path) repaired = self._repair(storage_key, path=path)
if repaired is not None: if repaired is not None:
sessions.append( sessions.append(
{ {
+155
View File
@@ -0,0 +1,155 @@
"""Authenticated HTTP adapter for the extension management service."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from urllib.parse import unquote
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.extensions.service import ExtensionService
from nanobot.webui.http_utils import is_local_browser_request
_VALUES_HEADER = "X-Nanobot-Extension-Values"
_VALUES_MAX_BYTES = 32 * 1024
_ACTION_PATHS = {
"/api/extensions/install": "install",
"/api/extensions/enable": "enable",
"/api/extensions/disable": "disable",
"/api/extensions/trust": "trust",
"/api/extensions/untrust": "untrust",
"/api/extensions/permissions": "permissions",
"/api/extensions/uninstall": "uninstall",
}
class WebUIExtensionsRouter:
"""Keep extension policy and installation outside WebSocket transport."""
def __init__(
self,
*,
service: ExtensionService | None,
check_api_token: Callable[[WsRequest], bool],
json_response: Callable[[dict[str, Any]], Response],
error_response: Callable[[int, str | None], Response],
allow_remote_package_install: bool = False,
logger: Any,
) -> None:
self._service = service
self._check_api_token = check_api_token
self._json_response = json_response
self._error_response = error_response
self._allow_remote_package_install = allow_remote_package_install
self._logger = logger
async def dispatch(
self,
connection: Any,
request: WsRequest,
path: str,
) -> Response | None:
if not path.startswith("/api/extensions"):
return None
if not self._check_api_token(request):
return self._error_response(401, "Unauthorized")
if self._service is None:
return self._error_response(503, "Extension service is not available")
try:
if path == "/api/extensions":
if _method(request) != "GET":
return self._error_response(405, "Method not allowed")
return self._json_response(await self._service.status())
action = _ACTION_PATHS.get(path)
if action is None:
return None
if _method(request) != "POST":
return self._error_response(405, "Method not allowed")
if not self._mutation_allowed(action, connection, request):
return self._error_response(
403,
"Extension changes require a local WebUI connection",
)
values = self._values(request)
if (
action == "install"
and str(values.get("kind") or "git") == "local"
and not is_local_browser_request(connection, request.headers)
):
return self._error_response(
403,
"Local extension paths require a local WebUI connection",
)
return self._json_response(await self._run_action(action, values))
except KeyError as exc:
return self._error_response(404, str(exc))
except ValueError as exc:
return self._error_response(400, str(exc))
except RuntimeError as exc:
return self._error_response(502, str(exc))
except Exception:
self._logger.exception("extension management request failed")
return self._error_response(500, "Extension operation failed")
async def _run_action(self, action: str, values: dict[str, Any]) -> dict[str, Any]:
assert self._service is not None
extension_id = str(values.get("id") or "").strip()
if action == "install":
source = str(values.get("source") or "").strip()
if not source:
raise ValueError("Missing extension source")
return await self._service.install(
source,
kind=str(values.get("kind") or "git"),
ref=str(values.get("ref") or ""),
trusted=False,
)
if not extension_id:
raise ValueError("Missing extension ID")
if action == "enable":
return await self._service.set_enabled(extension_id, True)
if action == "disable":
return await self._service.set_enabled(extension_id, False)
if action == "trust":
return await self._service.set_trusted(extension_id, True)
if action == "untrust":
return await self._service.set_trusted(extension_id, False)
if action == "permissions":
permissions = values.get("permissions", [])
if not isinstance(permissions, list) or not all(
isinstance(permission, str) for permission in permissions
):
raise ValueError("Extension permissions must be an array of strings")
return await self._service.set_permissions(extension_id, set(permissions))
return await self._service.uninstall(extension_id)
def _values(self, request: WsRequest) -> dict[str, Any]:
raw = request.headers.get(_VALUES_HEADER)
if not raw:
return {}
if len(raw.encode("utf-8")) > _VALUES_MAX_BYTES:
raise ValueError("Extension request is too large")
try:
value = json.loads(unquote(raw))
except json.JSONDecodeError as exc:
raise ValueError("Invalid extension request") from exc
if not isinstance(value, dict):
raise ValueError("Extension request must be a JSON object")
return value
def _mutation_allowed(
self,
action: str,
connection: Any,
request: WsRequest,
) -> bool:
return is_local_browser_request(connection, request.headers) or (
action == "install" and self._allow_remote_package_install
)
def _method(request: WsRequest) -> str:
return str(getattr(request, "method", "GET")).upper()
+4
View File
@@ -51,6 +51,8 @@ def build_gateway_services(
local_trigger_pending_ids: Callable[[str], set[str]] | None = None, local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
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,
extension_service: Any | None = None,
allow_remote_package_install: bool = False,
logger: Any = default_logger, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
tokens = GatewayTokenStore() tokens = GatewayTokenStore()
@@ -94,6 +96,8 @@ def build_gateway_services(
local_trigger_pending_ids=local_trigger_pending_ids, local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
extension_service=extension_service,
allow_remote_package_install=allow_remote_package_install,
log=logger, log=logger,
) )
return GatewayServices( return GatewayServices(
+10 -5
View File
@@ -54,7 +54,11 @@ def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, An
for row in existing_rows or [] for row in existing_rows or []
if isinstance(row.get("file"), str) if isinstance(row.get("file"), str)
} }
paths = sorted(session_manager.sessions_dir.glob("*.jsonl")) paths = sorted(
path
for path in session_manager.sessions_dir.glob("*.jsonl")
if SessionManager._session_key_from_path(path) is not None
)
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
changed = existing_rows is None changed = existing_rows is None
@@ -268,8 +272,9 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None: def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
storage_key = SessionManager._decode_storage_key(path.stem) storage_key = SessionManager._session_key_from_path(path)
fallback_key = storage_key or path.stem.replace("_", ":", 1) if storage_key is None:
return None
try: try:
with open(path, encoding="utf-8") as f: with open(path, encoding="utf-8") as f:
first_line = f.readline().strip() first_line = f.readline().strip()
@@ -320,7 +325,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat() fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
created_at_s = created_at_s or fallback_time created_at_s = created_at_s or fallback_time
updated_at_s = updated_at_s or fallback_time updated_at_s = updated_at_s or fallback_time
key = data.get("key") or fallback_key key = data.get("key") or storage_key
activity_signature = _webui_activity_signature(key) activity_signature = _webui_activity_signature(key)
activity_updated_at = _webui_activity_updated_at(activity_signature) activity_updated_at = _webui_activity_updated_at(activity_signature)
return { return {
@@ -340,7 +345,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
**activity_signature, **activity_signature,
} }
except Exception: except Exception:
repaired = session_manager._repair(fallback_key) repaired = session_manager._repair(storage_key)
if repaired is None: if repaired is None:
return None return None
return _indexed_row_for_session(repaired, path) return _indexed_row_for_session(repaired, path)
-17
View File
@@ -41,7 +41,6 @@ def default_webui_sidebar_state() -> dict[str, Any]:
"project_name_overrides": {}, "project_name_overrides": {},
"tags_by_key": {}, "tags_by_key": {},
"collapsed_groups": {}, "collapsed_groups": {},
"activity_seen_at_by_key": {},
"view": { "view": {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "show_previews": False,
@@ -88,19 +87,6 @@ def _clean_bool_map(value: Any) -> dict[str, bool]:
return out return out
def _clean_activity_seen_at_by_key(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
out: dict[str, str] = {}
for key, raw_timestamp in list(value.items())[:_MAX_MAP_ITEMS]:
cleaned_key = _clean_string(key)
cleaned_timestamp = _clean_string(raw_timestamp, max_len=64)
if cleaned_key is None or cleaned_timestamp is None:
continue
out[cleaned_key] = cleaned_timestamp
return out
def _clean_title_overrides(value: Any) -> dict[str, str]: def _clean_title_overrides(value: Any) -> dict[str, str]:
if not isinstance(value, dict): if not isinstance(value, dict):
return {} return {}
@@ -156,9 +142,6 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
) )
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key")) state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups")) state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
state["activity_seen_at_by_key"] = _clean_activity_seen_at_by_key(
raw.get("activity_seen_at_by_key")
)
state["view"] = _clean_view(raw.get("view")) state["view"] = _clean_view(raw.get("view"))
updated_at = raw.get("updated_at") updated_at = raw.get("updated_at")
state["updated_at"] = updated_at if isinstance(updated_at, str) else None state["updated_at"] = updated_at if isinstance(updated_at, str) else None
+14
View File
@@ -170,6 +170,8 @@ class GatewayHTTPHandler:
local_trigger_pending_ids: Callable[[str], set[str]] | None = None, local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
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,
extension_service: Any | None = None,
allow_remote_package_install: bool = False,
log: Any = logger, log: Any = logger,
) -> None: ) -> None:
self.config = config self.config = config
@@ -190,6 +192,7 @@ class GatewayHTTPHandler:
self._log = log self._log = log
self._runtime_surface = runtime_surface self._runtime_surface = runtime_surface
from nanobot.webui.extensions_routes import WebUIExtensionsRouter
from nanobot.webui.settings_api import runtime_capabilities as _rc from nanobot.webui.settings_api import runtime_capabilities as _rc
from nanobot.webui.settings_routes import WebUISettingsRouter from nanobot.webui.settings_routes import WebUISettingsRouter
@@ -206,6 +209,14 @@ class GatewayHTTPHandler:
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
) )
self.extensions_routes = WebUIExtensionsRouter(
service=extension_service,
check_api_token=self.check_api_token,
json_response=_http_json_response,
error_response=_http_error,
allow_remote_package_install=allow_remote_package_install,
logger=self._log,
)
def workspace_controls_available(self, connection: Any) -> bool: def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection) return self._runtime_surface == "native" or _is_localhost(connection)
@@ -247,6 +258,9 @@ class GatewayHTTPHandler:
# Settings routes (delegated) # Settings routes (delegated)
response = await self.settings_routes.dispatch(connection, request, got) response = await self.settings_routes.dispatch(connection, request, got)
if response is not None:
return response
response = await self.extensions_routes.dispatch(connection, request, got)
if response is not None: if response is not None:
return response return response
+9 -1
View File
@@ -353,6 +353,14 @@ class TestBuildSystemPrompt:
class TestBuildMessages: class TestBuildMessages:
def test_optional_arguments_are_keyword_only(self, tmp_path):
builder = _builder(tmp_path)
with pytest.raises(TypeError):
builder.build_system_prompt(["legacy-skill"])
with pytest.raises(TypeError):
builder.build_messages([], "hello", ["legacy-skill"])
def test_basic_empty_history(self, tmp_path): def test_basic_empty_history(self, tmp_path):
builder = _builder(tmp_path) builder = _builder(tmp_path)
messages = builder.build_messages([], "hello") messages = builder.build_messages([], "hello")
@@ -363,7 +371,7 @@ class TestBuildMessages:
def test_runtime_context_is_not_injected_by_default(self, tmp_path): def test_runtime_context_is_not_injected_by_default(self, tmp_path):
builder = _builder(tmp_path) builder = _builder(tmp_path)
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct") messages = builder.build_messages([], "hello", channel="cli")
user_msg = str(messages[-1]["content"]) user_msg = str(messages[-1]["content"])
assert user_msg == "hello" assert user_msg == "hello"
+1 -3
View File
@@ -70,7 +70,6 @@ def test_provider_context_appended_after_user_content(tmp_path) -> None:
history=[], history=[],
current_message="hello world", current_message="hello world",
channel="cli", channel="cli",
chat_id="direct",
runtime_context_blocks=[ runtime_context_blocks=[
RuntimeContextBlock(source="test", content="provider context"), RuntimeContextBlock(source="test", content="provider context"),
], ],
@@ -322,7 +321,7 @@ def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
messages = builder.build_messages( messages = builder.build_messages(
history=[], current_message="hi", history=[], current_message="hi",
channel="telegram", chat_id="123", channel="telegram",
) )
system = messages[0]["content"] system = messages[0]["content"]
assert "Format Hint" in system assert "Format Hint" in system
@@ -349,7 +348,6 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
history=[{"role": "assistant", "content": "previous result"}], history=[{"role": "assistant", "content": "previous result"}],
current_message="subagent result", current_message="subagent result",
channel="cli", channel="cli",
chat_id="direct",
current_role="assistant", current_role="assistant",
) )
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind, TurnState from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ChannelsConfig from nanobot.config.schema import ChannelsConfig
@@ -27,7 +27,7 @@ def _make_loop(tmp_path: Path, channels_config: ChannelsConfig | None = None) ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_state_restore_extracts_documents_by_default( async def test_restore_turn_extracts_documents_by_default(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -52,14 +52,13 @@ async def test_state_restore_extracts_documents_by_default(
ctx = TurnContext( ctx = TurnContext(
msg=msg, msg=msg,
session_key="cli:c", session_key="cli:c",
state=TurnState.RESTORE,
turn_id="turn-1", turn_id="turn-1",
runtime=loop.llm_runtime(), runtime=loop.llm_runtime(),
kind=TurnKind.USER, kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"), delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
) )
assert await loop._state_restore(ctx) == "ok" await loop._restore_turn(ctx)
assert calls == [("summarize", [str(doc_path)])] assert calls == [("summarize", [str(doc_path)])]
assert "Quarterly revenue" in ctx.msg.content assert "Quarterly revenue" in ctx.msg.content
@@ -67,7 +66,7 @@ async def test_state_restore_extracts_documents_by_default(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_state_restore_references_documents_when_extraction_disabled( async def test_restore_turn_references_documents_when_extraction_disabled(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
@@ -90,14 +89,13 @@ async def test_state_restore_references_documents_when_extraction_disabled(
ctx = TurnContext( ctx = TurnContext(
msg=msg, msg=msg,
session_key="cli:c", session_key="cli:c",
state=TurnState.RESTORE,
turn_id="turn-1", turn_id="turn-1",
runtime=loop.llm_runtime(), runtime=loop.llm_runtime(),
kind=TurnKind.USER, kind=TurnKind.USER,
delivery=loop.turn_delivery_factory.create(msg, "cli:c"), delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
) )
assert await loop._state_restore(ctx) == "ok" await loop._restore_turn(ctx)
assert "Quarterly revenue" not in ctx.msg.content assert "Quarterly revenue" not in ctx.msg.content
assert f"[Attachment: {doc_path}]" in ctx.msg.content assert f"[Attachment: {doc_path}]" in ctx.msg.content
+4 -4
View File
@@ -414,13 +414,13 @@ class TestEphemeralDirect:
captured = {} captured = {}
original_save = loop._state_save original_save = loop._persist_turn
async def patched_save(ctx): async def patched_save(ctx):
captured["ephemeral"] = ctx.ephemeral captured["ephemeral"] = ctx.ephemeral
return await original_save(ctx) return await original_save(ctx)
with patch.object(loop, "_state_save", side_effect=patched_save): with patch.object(loop, "_persist_turn", side_effect=patched_save):
await loop.process_direct( await loop.process_direct(
"test", session_key="dream:check", ephemeral=True, "test", session_key="dream:check", ephemeral=True,
) )
@@ -435,13 +435,13 @@ class TestEphemeralDirect:
captured = {} captured = {}
original_save = loop._state_save original_save = loop._persist_turn
async def patched_save(ctx): async def patched_save(ctx):
captured["ephemeral"] = ctx.ephemeral captured["ephemeral"] = ctx.ephemeral
return await original_save(ctx) return await original_save(ctx)
with patch.object(loop, "_state_save", side_effect=patched_save): with patch.object(loop, "_persist_turn", side_effect=patched_save):
await loop.process_direct("test", session_key="cli:normal") await loop.process_direct("test", session_key="cli:normal")
assert captured.get("ephemeral") is False assert captured.get("ephemeral") is False
+42 -39
View File
@@ -7,7 +7,7 @@ import pytest
from loguru import logger from loguru import logger
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop, TurnState 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.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
@@ -451,7 +451,6 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
[], [],
user_text, user_text,
channel="cli", channel="cli",
chat_id="direct",
) )
assert "_meta" not in messages[-1] assert "_meta" not in messages[-1]
@@ -476,7 +475,6 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
user_text, user_text,
media=[str(image)], media=[str(image)],
channel="cli", channel="cli",
chat_id="direct",
) )
loop._save_turn(session, messages, skip=1) loop._save_turn(session, messages, skip=1)
@@ -1101,7 +1099,7 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None: async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop.context.build_messages = MagicMock( # type: ignore[method-assign] loop.context.build_messages = MagicMock( # type: ignore[method-assign]
@@ -1135,12 +1133,11 @@ async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path:
assert result is not None assert result is not None
assert result.chat_id == "thread-777" assert result.chat_id == "thread-777"
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777" assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_message_uses_explicit_session_metadata_for_goal_context( async def test_process_message_uses_explicit_session_for_goal_context(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
@@ -1185,10 +1182,10 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
assert result is not None assert result is not None
assert result.content == "ok" assert result.content == "ok"
kwargs = loop.context.build_messages.call_args.kwargs kwargs = loop._run_agent_loop.call_args.kwargs
assert kwargs["chat_id"] == "chat-with-goal" assert kwargs["session"] is system_session
assert kwargs["session_metadata"] is system_session.metadata assert kwargs["session_key"] == "system"
assert GOAL_STATE_KEY not in kwargs["session_metadata"] assert GOAL_STATE_KEY not in kwargs["session"].metadata
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1570,27 +1567,26 @@ async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) ->
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path: Path) -> None: async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign] loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
return_value=False return_value=False
) )
visited: list[TurnState] = [] visited: list[str] = []
for state in ( for name in (
TurnState.RESTORE, "_restore_turn",
TurnState.COMPACT, "_compact_session",
TurnState.COMMAND, "_dispatch_command",
TurnState.BUILD, "_build_turn",
TurnState.RUN, "_run_turn",
TurnState.SAVE, "_persist_turn",
TurnState.RESPOND, "_prepare_outbound",
): ):
name = f"_state_{state.name.lower()}"
original = getattr(loop, name) original = getattr(loop, name)
async def record(ctx, *, _original=original, _state=state): async def record(ctx, *, _original=original, _name=name):
visited.append(_state) visited.append(_name)
return await _original(ctx) return await _original(ctx)
setattr(loop, name, record) setattr(loop, name, record)
@@ -1606,25 +1602,33 @@ async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path:
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign] loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
await loop._process_message( logs: list[str] = []
InboundMessage( sink_id = logger.add(logs.append, level="DEBUG", format="{message}")
channel="system", try:
sender_id="subagent", await loop._process_message(
chat_id="cli:test", InboundMessage(
content="subagent result", channel="system",
metadata={"subagent_task_id": "sub-1"}, sender_id="subagent",
chat_id="cli:test",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
) )
) finally:
logger.remove(sink_id)
assert visited == [ assert visited == [
TurnState.RESTORE, "_restore_turn",
TurnState.COMPACT, "_compact_session",
TurnState.COMMAND, "_dispatch_command",
TurnState.BUILD, "_build_turn",
TurnState.RUN, "_run_turn",
TurnState.SAVE, "_persist_turn",
TurnState.RESPOND, "_prepare_outbound",
] ]
logged = "".join(logs)
for stage in ("restore", "compact", "command", "build", "run", "save", "respond"):
assert f"Stage {stage} completed in" in logged
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1689,7 +1693,6 @@ def test_subagent_followup_uses_user_model_input_and_assistant_history(tmp_path:
current_message="subagent result", current_message="subagent result",
current_role="user", current_role="user",
channel="cli", channel="cli",
chat_id="merge",
) )
non_system = [m for m in projected if m.get("role") != "system"] non_system = [m for m in projected if m.get("role") != "system"]
+1 -1
View File
@@ -225,7 +225,7 @@ async def test_process_message_captures_original_text_before_restore(
seen.append((ctx.original_user_text, ctx.runtime)) seen.append((ctx.original_user_text, ctx.runtime))
raise RuntimeError("captured before restore") raise RuntimeError("captured before restore")
loop._state_restore = stop_after_capture # type: ignore[method-assign] loop._restore_turn = stop_after_capture # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="captured before restore"): with pytest.raises(RuntimeError, match="captured before restore"):
await loop._process_message( await loop._process_message(
+1 -1
View File
@@ -135,7 +135,7 @@ async def test_tool_fails_after_retry_exhausted():
assert "failed after retry" in output assert "failed after retry" in output
assert "ClosedResourceError" in output assert "ClosedResourceError" in output
assert is_tool_error_result(wrapper.name, output) assert is_tool_error_result(output)
assert session.call_tool.call_count == 2 assert session.call_tool.call_count == 2
+11 -37
View File
@@ -65,7 +65,7 @@ def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
assert lossy_path.read_text(encoding="utf-8") == stale_lossy assert lossy_path.read_text(encoding="utf-8") == stale_lossy
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None: def test_load_ignores_legacy_lossy_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch) sm = _manager(tmp_path, monkeypatch)
key = "telegram:legacy:lossy" key = "telegram:legacy:lossy"
lossy_path = sm._get_legacy_lossy_path(key) lossy_path = sm._get_legacy_lossy_path(key)
@@ -73,49 +73,23 @@ def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
session = sm._load(key) session = sm._load(key)
assert session is not None assert session is None
assert session.metadata == {"source": "test"} assert lossy_path.exists()
assert session.messages[0]["content"] == "loaded from lossy" assert not sm._get_session_path(key).exists()
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None: def test_load_ignores_legacy_global_path(tmp_path: Path, monkeypatch) -> None:
sm = _manager(tmp_path, monkeypatch) sm = _manager(tmp_path, monkeypatch)
key = "telegram:migrate:lossy" key = "telegram:legacy:global"
new_path = sm._get_session_path(key) new_path = sm._get_session_path(key)
lossy_path = sm._get_legacy_lossy_path(key) legacy_path = sm._get_legacy_session_path(key)
_write_session_file(lossy_path, key, "migrate me") _write_session_file(legacy_path, key, "loaded from global")
session = sm._load(key) session = sm._load(key)
assert session is not None assert session is None
assert session.messages[0]["content"] == "migrate me" assert legacy_path.exists()
assert new_path.exists() assert not new_path.exists()
assert not lossy_path.exists()
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
tmp_path: Path,
monkeypatch,
) -> None:
sm = _manager(tmp_path, monkeypatch)
first_key = "telegram:a_b"
second_key = "telegram:a:b"
lossy_path = sm._get_legacy_lossy_path(first_key)
assert lossy_path == sm._get_legacy_lossy_path(second_key)
_write_session_file(lossy_path, first_key, "belongs to first")
loaded_second = sm._load(second_key)
assert loaded_second is None
assert lossy_path.exists()
assert not sm._get_session_path(second_key).exists()
loaded_first = sm._load(first_key)
assert loaded_first is not None
assert loaded_first.messages[0]["content"] == "belongs to first"
assert sm._get_session_path(first_key).exists()
assert not lossy_path.exists()
def test_safe_key_is_lossy() -> None: def test_safe_key_is_lossy() -> None:
+1 -1
View File
@@ -140,5 +140,5 @@ async def test_loader_entry_point_error_wrapper_preserves_tool_api(tmp_path):
assert tool.to_schema() == {"name": "api_plugin", "custom": True} assert tool.to_schema() == {"name": "api_plugin", "custom": True}
result = await tool.execute(value="1") result = await tool.execute(value="1")
assert is_tool_error_result("api_plugin", result) is True assert is_tool_error_result(result) is True
assert str(result) == "Error: plugin failed" assert str(result) == "Error: plugin failed"
+1 -1
View File
@@ -78,7 +78,7 @@ async def test_run_inline_returns_structured_error(tmp_path):
) )
assert result == "subagent failed" assert result == "subagent failed"
assert is_tool_error_result("spawn", result) assert is_tool_error_result(result)
assert manager._running_tasks == {} assert manager._running_tasks == {}
assert manager._session_tasks == {} assert manager._session_tasks == {}
-29
View File
@@ -788,35 +788,6 @@ def test_discover_plugins_skips_names_outside_enabled_set():
assert loaded == [] assert loaded == []
def test_discover_plugins_warns_once_for_legacy_entry_points():
from nanobot.channels.registry import _warn_legacy_channel_entry_points, discover_plugins
legacy_entry_points = [SimpleNamespace(name="z-old"), SimpleNamespace(name="a-old")]
_warn_legacy_channel_entry_points.cache_clear()
try:
with (
patch(
"nanobot.channels.registry.entry_points",
return_value=legacy_entry_points,
) as metadata_entry_points,
patch("nanobot.channels.registry._channel_package_names", return_value=[]),
patch("nanobot.channels.registry.logger.warning") as warning,
):
discover_plugins()
discover_plugins()
finally:
_warn_legacy_channel_entry_points.cache_clear()
metadata_entry_points.assert_called_once_with(group="nanobot.channels")
warning.assert_called_once_with(
"Legacy channel entry points were detected but will not be loaded: {}. "
"The '{}' entry-point group is no longer supported; use a built-in channel or "
"migrate it into nanobot/channels/<channel>/.",
"a-old, z-old",
"nanobot.channels",
)
def test_channel_manifest_rejects_invalid_dependency_metadata(): def test_channel_manifest_rejects_invalid_dependency_metadata():
with pytest.raises(TypeError, match="tuple of requirements"): with pytest.raises(TypeError, match="tuple of requirements"):
ChannelPlugin( ChannelPlugin(
+34 -13
View File
@@ -1437,6 +1437,17 @@ def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
make_provider(config) make_provider(config)
class _FakeExtensionHost:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def reload(self) -> None:
pass
async def close(self) -> None:
pass
@pytest.fixture @pytest.fixture
def mock_agent_runtime(tmp_path): def mock_agent_runtime(tmp_path):
"""Mock agent command dependencies for focused CLI tests.""" """Mock agent command dependencies for focused CLI tests."""
@@ -1450,6 +1461,7 @@ def mock_agent_runtime(tmp_path):
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
patch("nanobot.bus.queue.MessageBus"), \ patch("nanobot.bus.queue.MessageBus"), \
patch("nanobot.cron.service.CronService"), \ patch("nanobot.cron.service.CronService"), \
patch("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost), \
patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config: patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
agent_loop = MagicMock() agent_loop = MagicMock()
agent_loop.channels_config = None agent_loop.channels_config = None
@@ -1538,6 +1550,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
return None return None
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@@ -1580,6 +1593,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
@@ -1630,6 +1644,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
result = runner.invoke( result = runner.invoke(
@@ -1686,6 +1701,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None "nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
) )
@@ -1728,17 +1744,6 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
assert passed_config.workspace_path == workspace_path assert passed_config.workspace_path == workspace_path
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"agents": {"defaults": {"memoryWindow": 42}}}))
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
assert result.exit_code == 0
assert "memoryWindow" in result.stdout
assert "no longer used" in result.stdout
def test_heartbeat_retains_recent_messages_by_default(): def test_heartbeat_retains_recent_messages_by_default():
config = Config() config = Config()
@@ -1872,6 +1877,7 @@ def _patch_cli_command_runtime(
) -> None: ) -> None:
provider_factory = make_provider or (lambda _config: _fake_provider()) provider_factory = make_provider or (lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
set_config_path or (lambda _path: None), set_config_path or (lambda _path: None),
@@ -2454,10 +2460,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
seen["workspace"] = kwargs["workspace"] seen["workspace"] = kwargs["workspace"]
async def _connect_mcp(self) -> None: async def _connect_mcp(self) -> None:
return None seen["mcp_connected"] = True
async def close_mcp(self) -> None: async def close_mcp(self) -> None:
return None seen["mcp_closed"] = True
def _fake_create_app( def _fake_create_app(
agent_loop, agent_loop,
@@ -3625,6 +3631,21 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
assert seen["api_key"] == "secret" assert seen["api_key"] == "secret"
def test_serve_preserves_mcp_lifecycle(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, Config(), seen)
result = runner.invoke(app, ["serve", "--config", str(config_file)])
api_app = seen["api_app"]
asyncio.run(api_app.on_startup[0](api_app))
asyncio.run(api_app.on_cleanup[0](api_app))
assert result.exit_code == 0
assert seen["mcp_connected"] is True
assert seen["mcp_closed"] is True
def test_trigger_cli_queues_message_in_workspace( def test_trigger_cli_queues_message_in_workspace(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from io import StringIO
from rich.console import Console
from typer.testing import CliRunner
from nanobot.cli.extensions import create_extensions_app
class _Service:
def __init__(self) -> None:
self.calls: list[tuple[str, object]] = []
async def status(self):
self.calls.append(("status", None))
return {
"extensions": [
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"description": "Example extension",
"enabled": True,
"trusted": False,
"active": False,
"dependencies": [],
"permissions": [{"name": "network", "reason": "Fetch data"}],
"granted_permissions": [],
}
],
"diagnostics": [],
}
async def install(self, source, *, kind, ref, trusted):
self.calls.append(("install", (source, kind, ref, trusted)))
return {
"record": {
"id": "sample",
"version": "1.0.0",
"trusted": trusted,
}
}
async def set_enabled(self, extension_id, enabled):
self.calls.append(("enabled", (extension_id, enabled)))
return {"record": {"id": extension_id}}
async def set_trusted(self, extension_id, trusted):
self.calls.append(("trusted", (extension_id, trusted)))
return {"record": {"id": extension_id}}
async def set_permissions(self, extension_id, permissions):
self.calls.append(("permissions", (extension_id, permissions)))
return {
"record": {
"id": extension_id,
"granted_permissions": sorted(permissions),
}
}
async def uninstall(self, extension_id):
self.calls.append(("uninstall", extension_id))
return {"removed": extension_id}
def _runner(service: _Service):
output = StringIO()
app = create_extensions_app(
console=Console(file=output, force_terminal=False),
service_factory=lambda: service,
)
return CliRunner(), app, output
def test_extension_cli_inspects() -> None:
service = _Service()
runner, app, output = _runner(service)
inspected = runner.invoke(app, ["inspect", "sample"])
assert inspected.exit_code == 0
assert "Sample" in output.getvalue()
assert service.calls == [("status", None)]
def test_extension_cli_install_and_policy_commands() -> None:
service = _Service()
runner, app, _output = _runner(service)
assert runner.invoke(app, ["install", "https://example.com/acme.git"]).exit_code == 0
assert runner.invoke(app, ["trust", "sample"]).exit_code == 0
assert runner.invoke(app, ["disable", "sample"]).exit_code == 0
assert runner.invoke(
app,
["permissions", "sample", "network", "filesystem.read"],
).exit_code == 0
assert runner.invoke(app, ["uninstall", "sample", "--yes"]).exit_code == 0
assert service.calls == [
("install", ("https://example.com/acme.git", "git", "", False)),
("trusted", ("sample", True)),
("enabled", ("sample", False)),
("permissions", ("sample", {"network", "filesystem.read"})),
("uninstall", "sample"),
]
+3 -9
View File
@@ -96,22 +96,17 @@ def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch)
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"]) @pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name) -> None: def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text( config_path.write_text(
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}), json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
encoding="utf-8", encoding="utf-8",
) )
with patch("nanobot.config.loader.logger.warning") as warning: config = load_config(config_path)
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234 assert config.agents.defaults.max_tokens == 1234
assert not hasattr(config.agents.defaults, "max_messages") assert not hasattr(config.agents.defaults, "max_messages")
warning.assert_called_once()
message = warning.call_args.args[0]
assert "legacy and ignored" in message
assert "next version" in message
def test_save_config_drops_legacy_max_messages(tmp_path) -> None: def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
@@ -121,8 +116,7 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
with patch("nanobot.config.loader.logger.warning"): config = load_config(config_path)
config = load_config(config_path)
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
+19
View File
@@ -52,3 +52,22 @@ def test_dream_config_uses_model_override_name_and_accepts_legacy_model() -> Non
assert cfg.model_override == "openrouter/sonnet" assert cfg.model_override == "openrouter/sonnet"
assert dumped["modelOverride"] == "openrouter/sonnet" assert dumped["modelOverride"] == "openrouter/sonnet"
assert "model" not in dumped assert "model" not in dumped
def test_dream_config_ignores_retired_noop_fields() -> None:
cfg = DreamConfig.model_validate(
{
"maxBatchSize": 99,
"maxIterations": 99,
"annotateLineAges": False,
}
)
dumped = cfg.model_dump(by_alias=True)
assert not hasattr(cfg, "max_batch_size")
assert not hasattr(cfg, "max_iterations")
assert not hasattr(cfg, "annotate_line_ages")
assert "maxBatchSize" not in dumped
assert "maxIterations" not in dumped
assert "annotateLineAges" not in dumped
+7
View File
@@ -0,0 +1,7 @@
from nanobot.config.schema import Config
def test_extensions_can_be_disabled_globally() -> None:
config = Config.model_validate({"extensions": {"enabled": False}})
assert config.extensions.enabled is False
+1
View File
@@ -0,0 +1 @@
"""Extension platform tests."""
+52
View File
@@ -0,0 +1,52 @@
import json
from nanobot.config.schema import Config
from nanobot.extensions.catalog import build_extension_catalog
from nanobot.extensions.store import ExtensionStore
def _write_extension(root, extension_id: str) -> None:
package = root / extension_id
package.mkdir(parents=True)
(package / "nanobot.extension.json").write_text(
json.dumps(
{
"id": extension_id,
"name": extension_id,
"version": "1.0.0",
"entry": "extension:register",
}
)
)
def test_catalog_requires_trust_before_activation(tmp_path) -> None:
_write_extension(tmp_path, "acme")
catalog = build_extension_catalog(Config(), user_root=tmp_path)
assert [item.manifest.id for item in catalog.candidates] == ["acme"]
assert catalog.snapshot.extensions == ()
def test_store_policy_can_trust_an_extension(tmp_path) -> None:
source = tmp_path / "source"
_write_extension(source, "acme")
store = ExtensionStore(tmp_path / "installed")
store.install_local(source / "acme")
store.set_trusted("acme", True)
catalog = build_extension_catalog(Config(), user_root=store.root)
assert [item.manifest.id for item in catalog.snapshot.extensions] == ["acme"]
def test_disabled_catalog_discovers_nothing(tmp_path) -> None:
_write_extension(tmp_path, "acme")
catalog = build_extension_catalog(
Config.model_validate({"extensions": {"enabled": False}}),
user_root=tmp_path,
)
assert catalog.candidates == ()
+42
View File
@@ -0,0 +1,42 @@
import json
import pytest
from nanobot.extensions import ExtensionManifest
from nanobot.extensions.codec import (
ManifestFormatError,
dump_manifest,
load_manifest,
manifest_from_mapping,
)
def test_manifest_json_round_trip(tmp_path) -> None:
path = tmp_path / "nanobot.extension.json"
original = ExtensionManifest(
id="acme.tools",
name="Acme Tools",
version="2.0.0",
entry="acme_extension:register",
)
dump_manifest(original, path)
assert load_manifest(path) == original
assert json.loads(path.read_text())["apiVersion"] == 1
def test_manifest_codec_rejects_unknown_fields() -> None:
with pytest.raises(ManifestFormatError, match="unknown fields: typo"):
manifest_from_mapping(
{"id": "bad", "name": "Bad", "version": "1.0.0", "typo": True}
)
with pytest.raises(ManifestFormatError, match="unknown fields: contributions"):
manifest_from_mapping(
{
"id": "bad",
"name": "Bad",
"version": "1.0.0",
"contributions": [{"kind": "mystery", "name": "unknown"}],
}
)
+40
View File
@@ -0,0 +1,40 @@
import json
from nanobot.extensions.discovery import discover_manifest_root
def _write_manifest(root, name: str, extension_id: str) -> None:
package = root / name
package.mkdir()
(package / "nanobot.extension.json").write_text(
json.dumps(
{
"id": extension_id,
"name": extension_id,
"version": "1.0.0",
"entry": "extension:register",
}
)
)
def test_discovery_reads_metadata_without_importing_runtime(tmp_path) -> None:
_write_manifest(tmp_path, "one", "one")
_write_manifest(tmp_path, "two", "two")
result = discover_manifest_root(tmp_path)
assert [candidate.manifest.id for candidate in result.candidates] == ["one", "two"]
assert result.diagnostics == ()
def test_discovery_reports_bad_manifest_without_hiding_good_packages(tmp_path) -> None:
_write_manifest(tmp_path, "good", "good")
bad = tmp_path / "bad"
bad.mkdir()
(bad / "nanobot.extension.json").write_text("{")
result = discover_manifest_root(tmp_path)
assert [candidate.manifest.id for candidate in result.candidates] == ["good"]
assert result.diagnostics[0].code == "invalid_manifest"
+49
View File
@@ -0,0 +1,49 @@
from pathlib import Path
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.command.router import CommandRouter
from nanobot.config.schema import Config
from nanobot.extensions.host import ExtensionHost
from nanobot.extensions.runtime import ExtensionRuntimeManager
class _Agent:
def __init__(self) -> None:
self.tools = ToolRegistry()
self.commands = CommandRouter()
self._hook_factories = []
async def test_host_reloads_and_closes_runtime(
monkeypatch,
tmp_path: Path,
) -> None:
agent = _Agent()
config = Config()
activated: list[object] = []
closed: list[object] = []
async def activate(self, snapshot):
activated.append(snapshot)
return type(
"Result",
(),
{"extensions": (), "hook_factories": (), "diagnostics": ()},
)()
async def close(self):
closed.append(self)
monkeypatch.setattr(ExtensionRuntimeManager, "activate", activate)
monkeypatch.setattr(ExtensionRuntimeManager, "close", close)
host = ExtensionHost(agent, lambda: config, user_root=tmp_path)
first = await host.reload()
second = await host.reload()
await host.close()
assert host.snapshot is None
assert first.catalog.snapshot.extensions == ()
assert second.catalog.snapshot.extensions == ()
assert len(activated) == 2
assert len(closed) == 2
+53
View File
@@ -0,0 +1,53 @@
import pytest
from nanobot.extensions import (
ExtensionDependency,
ExtensionManifest,
ExtensionPermission,
)
from nanobot.extensions.manifest import DependencyKind
def test_manifest_defaults_to_native_python_entry() -> None:
manifest = ExtensionManifest(
id="acme.research",
name="Acme Research",
version="1.2.0",
permissions=(
ExtensionPermission(
name="network",
reason="Fetch user-selected sources.",
),
),
)
assert manifest.entry == "extension:register"
@pytest.mark.parametrize("extension_id", ["Uppercase", "../escape", "two words", ""])
def test_manifest_rejects_invalid_ids(extension_id: str) -> None:
with pytest.raises(ValueError):
ExtensionManifest(id=extension_id, name="Invalid", version="1.0.0")
def test_manifest_rejects_duplicate_contract_rows() -> None:
dependency = ExtensionDependency(
kind=DependencyKind.PYTHON,
name="httpx",
)
permission = ExtensionPermission(name="network")
with pytest.raises(ValueError, match="duplicate dependencies"):
ExtensionManifest(
id="duplicate",
name="Duplicate",
version="1.0.0",
dependencies=(dependency, dependency),
)
with pytest.raises(ValueError, match="duplicate permissions"):
ExtensionManifest(
id="duplicate",
name="Duplicate",
version="1.0.0",
permissions=(permission, permission),
)
+53
View File
@@ -0,0 +1,53 @@
from nanobot.extensions import ExtensionDependency, ExtensionManifest
from nanobot.extensions.manifest import DependencyKind
from nanobot.extensions.preflight import evaluate_dependencies
from nanobot.extensions.registry import ExtensionCandidate
def _candidate(dependency: ExtensionDependency) -> ExtensionCandidate:
return ExtensionCandidate(
ExtensionManifest(
id="preflight.test",
name="Preflight test",
version="1.0.0",
dependencies=(dependency,),
),
trusted=True,
)
def test_missing_environment_dependency_disables_extension(monkeypatch) -> None:
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
candidates, diagnostics = evaluate_dependencies(
(
_candidate(
ExtensionDependency(
kind=DependencyKind.ENVIRONMENT,
name="NANOBOT_EXTENSION_TEST_KEY",
)
),
)
)
assert not candidates[0].enabled
assert diagnostics[0].code == "dependency_missing"
def test_optional_dependency_does_not_disable_extension(monkeypatch) -> None:
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
candidates, diagnostics = evaluate_dependencies(
(
_candidate(
ExtensionDependency(
kind=DependencyKind.ENVIRONMENT,
name="NANOBOT_EXTENSION_TEST_KEY",
optional=True,
)
),
)
)
assert candidates[0].enabled
assert diagnostics == ()
+59
View File
@@ -0,0 +1,59 @@
import pytest
from nanobot.extensions import ExtensionManifest, ExtensionPermission
from nanobot.extensions.registry import ExtensionCandidate, ExtensionRegistry
def _candidate(
extension_id: str,
*,
trusted: bool = True,
permissions: tuple[ExtensionPermission, ...] = (),
granted: frozenset[str] = frozenset(),
) -> ExtensionCandidate:
return ExtensionCandidate(
ExtensionManifest(
id=extension_id,
name=extension_id,
version="1.0.0",
permissions=permissions,
),
trusted=trusted,
granted_permissions=granted,
)
def test_only_trusted_extensions_activate() -> None:
registry = ExtensionRegistry()
registry.register(_candidate("trusted"))
registry.register(_candidate("untrusted", trusted=False))
assert [item.manifest.id for item in registry.snapshot().extensions] == ["trusted"]
def test_every_requested_permission_must_be_granted() -> None:
registry = ExtensionRegistry()
registry.register(
_candidate(
"permission.test",
permissions=(
ExtensionPermission(name="network"),
ExtensionPermission(name="filesystem.read"),
),
granted=frozenset({"network"}),
)
)
snapshot = registry.snapshot()
assert snapshot.extensions == ()
assert snapshot.diagnostics[0].code == "permission_required"
assert "filesystem.read" in snapshot.diagnostics[0].message
def test_duplicate_extension_ids_are_rejected() -> None:
registry = ExtensionRegistry()
registry.register(_candidate("duplicate"))
with pytest.raises(ValueError, match="already installed"):
registry.register(_candidate("duplicate"))
+180
View File
@@ -0,0 +1,180 @@
from pathlib import Path
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.command.router import CommandRouter
from nanobot.extensions import ExtensionManifest
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionSnapshot,
)
from nanobot.extensions.runtime import ExtensionRuntimeManager
def _candidate(root: Path, extension_id: str = "test.python") -> ExtensionCandidate:
return ExtensionCandidate(
ExtensionManifest(
id=extension_id,
name="Python extension",
version="1.0.0",
entry="extension:register",
),
location=root,
trusted=True,
)
def _snapshot(*candidates: ExtensionCandidate) -> ExtensionSnapshot:
return ExtensionSnapshot(candidates, ())
def _write_extension(root: Path, result: str = "ok") -> None:
(root / "extension.py").write_text(
f"""
from nanobot.agent.hook import AgentHook
from nanobot.agent.tools.base import Tool
class ExtensionTool(Tool):
@property
def name(self):
return "extension_echo"
@property
def description(self):
return "Echo from an extension"
@property
def parameters(self):
return {{"type": "object", "properties": {{}}}}
async def execute(self):
return "{result}"
async def command(_context):
return None
def hook_factory(_context):
return AgentHook()
def register(api):
api.register_tool(ExtensionTool())
api.register_command("extension", command)
api.register_hook_factory(hook_factory)
"""
)
async def test_python_extension_registers_and_removes_owned_capabilities(
tmp_path: Path,
) -> None:
_write_extension(tmp_path)
tools = ToolRegistry()
commands = CommandRouter()
hooks = []
manager = ExtensionRuntimeManager(
tools=tools,
commands=commands,
hook_factories=hooks,
)
result = await manager.activate(_snapshot(_candidate(tmp_path)))
assert result.diagnostics == ()
assert tools.owner("extension_echo") == "test.python"
assert commands.owner("exact", "/extension") == "test.python"
assert len(hooks) == 1
assert await tools.get("extension_echo").execute() == "ok"
await manager.close()
assert tools.owner("extension_echo") is None
assert commands.owner("exact", "/extension") is None
assert hooks == []
async def test_failed_registration_rolls_back_partial_state(tmp_path: Path) -> None:
(tmp_path / "extension.py").write_text(
"""
from nanobot.agent.tools.base import Tool
class DuplicateTool(Tool):
name = "duplicate"
description = "Duplicate"
parameters = {"type": "object", "properties": {}}
async def execute(self):
return "ok"
async def command(_context):
return None
def register(api):
api.register_tool(DuplicateTool())
api.register_command("duplicate", command)
"""
)
tools = ToolRegistry()
commands = CommandRouter()
async def core_handler(_context):
return None
commands.exact("/duplicate", core_handler)
manager = ExtensionRuntimeManager(
tools=tools,
commands=commands,
)
result = await manager.activate(_snapshot(_candidate(tmp_path)))
assert result.extensions == ()
assert result.diagnostics[0].code == "activation_failed"
assert tools.get("duplicate") is None
assert commands.owner("exact", "/duplicate") == "nanobot.core"
async def test_python_extension_reloads_updated_source(tmp_path: Path) -> None:
tools = ToolRegistry()
candidate = _candidate(tmp_path)
_write_extension(tmp_path, "first")
first = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
await first.activate(_snapshot(candidate))
assert await tools.get("extension_echo").execute() == "first"
await first.close()
_write_extension(tmp_path, "later")
second = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
await second.activate(_snapshot(candidate))
assert await tools.get("extension_echo").execute() == "later"
await second.close()
async def test_extensions_with_the_same_entry_module_are_isolated(
tmp_path: Path,
) -> None:
tools = ToolRegistry()
manager = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
first_root = tmp_path / "first"
second_root = tmp_path / "second"
first_root.mkdir()
second_root.mkdir()
_write_extension(first_root, "first")
_write_extension(second_root, "second")
second_source = (second_root / "extension.py").read_text()
(second_root / "extension.py").write_text(
second_source
.replace("extension_echo", "second_echo")
.replace('register_command("extension"', 'register_command("second"')
)
result = await manager.activate(
_snapshot(
_candidate(first_root, "test.first"),
_candidate(second_root, "test.second"),
)
)
assert result.diagnostics == ()
assert await tools.get("extension_echo").execute() == "first"
assert await tools.get("second_echo").execute() == "second"
await manager.close()
+88
View File
@@ -0,0 +1,88 @@
import json
from pathlib import Path
from types import SimpleNamespace
from nanobot.extensions import ExtensionManifest
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
ExtensionSnapshot,
)
from nanobot.extensions.service import ExtensionService
from nanobot.extensions.store import ExtensionStore
def _package(root: Path) -> Path:
root.mkdir()
(root / "extension.py").write_text("def register(api):\n pass\n")
(root / "nanobot.extension.json").write_text(
json.dumps(
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"entry": "extension:register",
}
)
)
return root
async def test_service_installs_untrusted_and_updates_policy(tmp_path: Path) -> None:
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
installed = await service.install(
str(_package(tmp_path / "source")),
kind="local",
)
trusted = await service.set_trusted("sample", True)
await service.set_enabled("sample", False)
assert installed["record"]["trusted"] is False
assert "runtime" not in installed["manifest"]
assert trusted["record"]["trusted"] is True
assert (await service.status())["extensions"][0]["enabled"] is False
async def test_service_reports_activation_failure(tmp_path: Path) -> None:
candidate = ExtensionCandidate(
ExtensionManifest(id="broken", name="Broken", version="1.0.0"),
location=tmp_path,
trusted=True,
)
host = SimpleNamespace(
snapshot=SimpleNamespace(
catalog=SimpleNamespace(
candidates=(candidate,),
diagnostics=(),
snapshot=ExtensionSnapshot((candidate,), ()),
),
activation=SimpleNamespace(
extensions=(),
diagnostics=(
ExtensionDiagnostic(
"activation_failed",
"broken",
"missing module",
),
),
),
)
)
service = ExtensionService(
host=host,
store=ExtensionStore(tmp_path / "installed"),
)
status = await service.status()
assert not status["extensions"][0]["active"]
assert status["diagnostics"][0]["code"] == "activation_failed"
async def test_service_uninstalls_package(tmp_path: Path) -> None:
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
await service.install(str(_package(tmp_path / "source")), kind="local")
assert await service.uninstall("sample") == {"removed": "sample"}
assert (await service.status())["extensions"] == []
+144
View File
@@ -0,0 +1,144 @@
import json
from pathlib import Path
import pytest
from nanobot.extensions.store import (
ExtensionSourceKind,
ExtensionStore,
InstalledExtension,
)
def _package(root: Path, *, version: str = "1.0.0") -> Path:
root.mkdir()
(root / "extension.py").write_text("def register(api):\n pass\n")
(root / "nanobot.extension.json").write_text(
json.dumps(
{
"id": "store.test",
"name": "Store test",
"version": version,
"entry": "extension:register",
"permissions": [
{"name": "network", "reason": "Fetch selected sources."}
],
}
)
)
return root
def test_local_install_is_atomic_and_untrusted_by_default(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
result = store.install_local(_package(tmp_path / "source"))
assert result.record.source is ExtensionSourceKind.LOCAL
assert result.record.integrity.startswith("sha256:")
assert result.manifest.id == "store.test"
assert not store.discover().candidates[0].trusted
def test_policy_updates_survive_discovery(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
store.set_trusted("store.test", True)
store.set_enabled("store.test", False)
store.set_permissions("store.test", {"network"})
candidate = store.discover().candidates[0]
assert candidate.trusted
assert not candidate.enabled
assert candidate.granted_permissions == frozenset({"network"})
def test_unknown_permission_cannot_be_granted(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
with pytest.raises(ValueError, match="not requested"):
store.set_permissions("store.test", {"filesystem.write"})
def test_modified_install_loses_effective_trust(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"), trusted=True)
(store.root / "store.test" / "extension.py").write_text("changed = True\n")
discovery = store.discover()
assert not discovery.candidates[0].trusted
assert not discovery.candidates[0].integrity_valid
assert discovery.diagnostics[0].code == "extension_integrity_mismatch"
def test_changed_reinstall_revokes_trust_but_identical_reinstall_preserves_it(
tmp_path: Path,
) -> None:
source = _package(tmp_path / "source")
store = ExtensionStore(tmp_path / "installed")
store.install_local(source, trusted=True)
identical = store.install_local(source)
assert identical.record.trusted
(source / "extension.py").write_text("changed = True\n")
changed = store.install_local(source)
assert not changed.record.trusted
def test_install_rejects_missing_manifest_and_symlinks(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(ValueError, match="cannot read extension manifest"):
store.install_local(empty)
source = _package(tmp_path / "source")
(source / "outside").symlink_to(tmp_path)
with pytest.raises(ValueError, match="symlink"):
store.install_local(source)
def test_install_rejects_source_containing_store(tmp_path: Path) -> None:
source = _package(tmp_path / "source")
store = ExtensionStore(source / "installed")
with pytest.raises(ValueError, match="cannot contain the extension store"):
store.install_local(source)
def test_registry_validation_and_uninstall(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="sha256 digest"):
InstalledExtension.model_validate(
{
"id": "sample",
"version": "1.0.0",
"source": "local",
"source_ref": "/tmp/sample",
"integrity": "invalid",
"installed_at": "2026-01-01T00:00:00Z",
}
)
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
store.uninstall("store.test")
assert store.records() == {}
assert not (store.root / "store.test").exists()
def test_corrupt_registry_is_diagnostic_and_not_overwritten(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
store.registry_path.write_text("{broken")
discovery = store.discover()
assert discovery.diagnostics[0].code == "invalid_extension_registry"
with pytest.raises(ValueError, match="invalid extension registry"):
store.set_trusted("store.test", True)
assert store.registry_path.read_text() == "{broken"
@@ -1,4 +1,5 @@
"""Regression tests for legacy-stem session handling.""" """Tests for retired legacy session storage paths."""
import json import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -6,15 +7,11 @@ from pathlib import Path
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch) -> None: def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: tmp_path / "legacy_sessions",
)
manager = SessionManager(tmp_path / "workspace") manager = SessionManager(tmp_path / "workspace")
# Simulate a legacy lossy-path filename (telegram_12345.jsonl) with a corrupt # A legacy lossy-path filename must not be treated as current session storage,
# first line that triggers the repair branch in list_sessions. # even when the file contains otherwise recoverable records.
legacy_stem = "telegram_12345" legacy_stem = "telegram_12345"
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl" corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
corrupt_path.parent.mkdir(parents=True, exist_ok=True) corrupt_path.parent.mkdir(parents=True, exist_ok=True)
@@ -24,7 +21,6 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
"created_at": datetime(2025, 1, 1).isoformat(), "created_at": datetime(2025, 1, 1).isoformat(),
"updated_at": datetime(2025, 1, 1).isoformat(), "updated_at": datetime(2025, 1, 1).isoformat(),
}) })
# Corrupt line followed by valid message
corrupt_path.write_text( corrupt_path.write_text(
metadata + "\n{INVALID JSON LINE\n" metadata + "\n{INVALID JSON LINE\n"
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n", + json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
@@ -33,14 +29,11 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
sessions = manager.list_sessions() sessions = manager.list_sessions()
# BUG: repair fails because _repair re-encodes the fallback_key via assert sessions == []
# _get_session_path, producing a base64 stem that doesn't match the assert corrupt_path.exists()
# actual legacy filename. The session is silently dropped.
assert len(sessions) == 1, f"Expected 1 session, got {len(sessions)}"
assert sessions[0]["key"] == "telegram:12345"
def test_read_session_methods_fall_back_to_legacy_lossy_stem( def test_read_session_methods_ignore_legacy_lossy_stem(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,
) -> None: ) -> None:
@@ -69,8 +62,5 @@ def test_read_session_methods_fall_back_to_legacy_lossy_stem(
metadata_result = manager.read_session_metadata(key) metadata_result = manager.read_session_metadata(key)
file_result = manager.read_session_file(key) file_result = manager.read_session_file(key)
assert metadata_result is not None assert metadata_result is None
assert metadata_result["metadata"] == metadata["metadata"] assert file_result is None
assert file_result is not None
assert file_result["metadata"] == metadata["metadata"]
assert file_result["messages"] == []
+1 -1
View File
@@ -380,7 +380,7 @@ def test_write_stdin_reports_missing_session(tmp_path):
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars="")) result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
assert result == "Error: exec session not found: 'missing\\nExit code: 0'" assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
assert is_tool_error_result("write_stdin", result) assert is_tool_error_result(result)
def test_list_exec_sessions_reports_running_commands(tmp_path): def test_list_exec_sessions_reports_running_commands(tmp_path):
+8 -8
View File
@@ -449,7 +449,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
result = await wrapper.execute() result = await wrapper.execute()
assert result == "Error: server-side MCP failure" assert result == "Error: server-side MCP failure"
assert is_tool_error_result(wrapper.name, result) assert is_tool_error_result(result)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -462,7 +462,7 @@ async def test_execute_contains_malformed_success_result() -> None:
result = await wrapper.execute() result = await wrapper.execute()
assert result == "(MCP tool returned malformed content: TypeError)" assert result == "(MCP tool returned malformed content: TypeError)"
assert is_tool_error_result(wrapper.name, result) assert is_tool_error_result(result)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -476,7 +476,7 @@ async def test_registry_adds_retry_hint_to_malformed_mcp_result() -> None:
result = await registry.execute(wrapper.name, {}) result = await registry.execute(wrapper.name, {})
assert is_tool_error_result(wrapper.name, result) assert is_tool_error_result(result)
assert "MCP tool returned malformed content" in result assert "MCP tool returned malformed content" in result
assert "Analyze the error above and try a different approach" in result assert "Analyze the error above and try a different approach" in result
@@ -494,7 +494,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
result = await wrapper.execute() result = await wrapper.execute()
assert result == "Error: generated report successfully" assert result == "Error: generated report successfully"
assert not is_tool_error_result(wrapper.name, result) assert not is_tool_error_result(result)
# Smallest valid 1x1 PNG, base64 without the data: prefix. # Smallest valid 1x1 PNG, base64 without the data: prefix.
@@ -562,7 +562,7 @@ async def test_execute_returns_timeout_message() -> None:
result = await wrapper.execute() result = await wrapper.execute()
assert result == "(MCP tool call timed out after 0.01s)" assert result == "(MCP tool call timed out after 0.01s)"
assert is_tool_error_result(wrapper.name, result) assert is_tool_error_result(result)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -575,7 +575,7 @@ async def test_execute_handles_server_cancelled_error() -> None:
result = await wrapper.execute() result = await wrapper.execute()
assert result == "(MCP tool call was cancelled)" assert result == "(MCP tool call was cancelled)"
assert is_tool_error_result(wrapper.name, result) assert is_tool_error_result(result)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -607,7 +607,7 @@ async def test_execute_handles_generic_exception() -> None:
result = await wrapper.execute() result = await wrapper.execute()
assert result == "(MCP tool call failed: RuntimeError)" assert result == "(MCP tool call failed: RuntimeError)"
assert is_tool_error_result(wrapper.name, result) assert is_tool_error_result(result)
def _make_tool_def(name: str) -> SimpleNamespace: def _make_tool_def(name: str) -> SimpleNamespace:
@@ -1631,7 +1631,7 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
assert wrapper._reconnect is not None assert wrapper._reconnect is not None
assert other_wrapper._reconnect is None assert other_wrapper._reconnect is None
removed = mcp_mod._unregister_server_tools(SimpleNamespace(), registry, server_name) removed = mcp_mod._unregister_server_tools(registry, server_name)
assert removed == 1 assert removed == 1
assert wrapper.name not in registry.tool_names assert wrapper.name not in registry.tool_names
+23
View File
@@ -108,6 +108,16 @@ def test_suggest_name_updates_after_register_and_unregister() -> None:
assert registry._suggest_name("read-file") == "readFile" assert registry._suggest_name("read-file") == "readFile"
def test_registry_tracks_and_removes_tool_owner() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("custom"), owner="acme.tools")
assert registry.owner("custom") == "acme.tools"
registry.unregister("custom")
assert registry.owner("custom") is None
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None: def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
registry = ToolRegistry() registry = ToolRegistry()
registry.register(_FakeTool("read_file")) registry.register(_FakeTool("read_file"))
@@ -312,6 +322,19 @@ def test_register_invalidates_cache() -> None:
assert len(second) == 2 assert len(second) == 2
def test_register_if_absent_preserves_existing_tool_and_owner() -> None:
registry = ToolRegistry()
existing = _FakeTool("read_file")
registry.register(existing, owner="extension.example")
assert not registry.register_if_absent(
_FakeTool("read_file"),
owner="nanobot.mcp.example",
)
assert registry.get("read_file") is existing
assert registry.owner("read_file") == "extension.example"
def test_unregister_invalidates_cache() -> None: def test_unregister_invalidates_cache() -> None:
registry = ToolRegistry() registry = ToolRegistry()
registry.register(_FakeTool("read_file")) registry.register(_FakeTool("read_file"))
+5 -5
View File
@@ -60,7 +60,7 @@ class SampleTool(Tool):
@tool_parameters( @tool_parameters(
tool_parameters_schema( tool_parameters_schema(
query=StringSchema(min_length=2), query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10), count=IntegerSchema(minimum=1, maximum=10),
required=["query", "count"], required=["query", "count"],
) )
) )
@@ -81,12 +81,12 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。""" """ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
root = tool_parameters_schema( root = tool_parameters_schema(
query=StringSchema(min_length=2), query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10), count=IntegerSchema(minimum=1, maximum=10),
required=["query", "count"], required=["query", "count"],
) )
obj = ObjectSchema( obj = ObjectSchema(
query=StringSchema(min_length=2), query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10), count=IntegerSchema(minimum=1, maximum=10),
required=["query", "count"], required=["query", "count"],
) )
params = {"query": "h", "count": 2} params = {"query": "h", "count": 2}
@@ -110,14 +110,14 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
expected = _Mini().validate_params(params) expected = _Mini().validate_params(params)
assert Schema.validate_json_schema_value(params, root, "") == expected assert Schema.validate_json_schema_value(params, root, "") == expected
assert obj.validate_value(params, "") == expected assert obj.validate_value(params, "") == expected
assert IntegerSchema(0, minimum=1).validate_value(0, "n") == ["n must be >= 1"] assert IntegerSchema(minimum=1).validate_value(0, "n") == ["n must be >= 1"]
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None: def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。""" """Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
built = tool_parameters_schema( built = tool_parameters_schema(
query=StringSchema(min_length=2), query=StringSchema(min_length=2),
count=IntegerSchema(2, minimum=1, maximum=10), count=IntegerSchema(minimum=1, maximum=10),
mode=StringSchema("", enum=["fast", "full"]), mode=StringSchema("", enum=["fast", "full"]),
meta=ObjectSchema( meta=ObjectSchema(
tag=StringSchema(""), tag=StringSchema(""),
+2 -2
View File
@@ -272,7 +272,7 @@ async def test_serper_search_http_error(monkeypatch):
tool = _tool(provider="serper", api_key="bad-serper-key") tool = _tool(provider="serper", api_key="bad-serper-key")
result = await tool.execute(query="serper") result = await tool.execute(query="serper")
assert "Error: Serper search failed (403)" in result assert "Error: Serper search failed (403)" in result
assert is_tool_error_result(tool.name, result) assert is_tool_error_result(result)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -284,7 +284,7 @@ async def test_serper_search_rate_limited(monkeypatch):
tool = _tool(provider="serper", api_key="serper-key") tool = _tool(provider="serper", api_key="serper-key")
result = await tool.execute(query="serper") result = await tool.execute(query="serper")
assert "Serper search rate limited" in result assert "Serper search rate limited" in result
assert is_tool_error_result(tool.name, result) assert is_tool_error_result(result)
@pytest.mark.asyncio @pytest.mark.asyncio
+1 -19
View File
@@ -30,11 +30,6 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
"project_name_overrides": {"/repo": " Core ", "bad": ""}, "project_name_overrides": {"/repo": " Core ", "bad": ""},
"tags_by_key": {"websocket:a": ["work", "work", ""]}, "tags_by_key": {"websocket:a": ["work", "work", ""]},
"collapsed_groups": {"Earlier": 1}, "collapsed_groups": {"Earlier": 1},
"activity_seen_at_by_key": {
"websocket:a": " 2026-07-27T08:30:00Z ",
"empty": "",
"invalid": 123,
},
"view": {"density": "tiny", "show_archived": True, "sort": "nope"}, "view": {"density": "tiny", "show_archived": True, "sort": "nope"},
} }
), ),
@@ -50,9 +45,6 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
assert state["project_name_overrides"] == {"/repo": "Core"} assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["tags_by_key"] == {"websocket:a": ["work"]} assert state["tags_by_key"] == {"websocket:a": ["work"]}
assert state["collapsed_groups"] == {"Earlier": True} assert state["collapsed_groups"] == {"Earlier": True}
assert state["activity_seen_at_by_key"] == {
"websocket:a": "2026-07-27T08:30:00Z"
}
assert state["view"] == { assert state["view"] == {
"density": "comfortable", "density": "comfortable",
"show_previews": False, "show_previews": False,
@@ -71,9 +63,6 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
"archived_keys": ["websocket:b"], "archived_keys": ["websocket:b"],
"title_overrides": {"websocket:a": "Release"}, "title_overrides": {"websocket:a": "Release"},
"project_name_overrides": {"/repo": "Core"}, "project_name_overrides": {"/repo": "Core"},
"activity_seen_at_by_key": {
"websocket:a": "2026-07-27T08:30:00Z"
},
"view": {"density": "compact", "show_previews": True}, "view": {"density": "compact", "show_previews": True},
} }
) )
@@ -82,14 +71,7 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
assert state["archived_keys"] == ["websocket:b"] assert state["archived_keys"] == ["websocket:b"]
assert state["title_overrides"] == {"websocket:a": "Release"} assert state["title_overrides"] == {"websocket:a": "Release"}
assert state["project_name_overrides"] == {"/repo": "Core"} assert state["project_name_overrides"] == {"/repo": "Core"}
assert state["activity_seen_at_by_key"] == {
"websocket:a": "2026-07-27T08:30:00Z"
}
assert state["view"]["density"] == "compact" assert state["view"]["density"] == "compact"
assert state["view"]["show_previews"] is True assert state["view"]["show_previews"] is True
assert webui_sidebar_state_path().is_file() assert webui_sidebar_state_path().is_file()
persisted = read_webui_sidebar_state() assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
assert persisted["pinned_keys"] == ["websocket:a"]
assert persisted["activity_seen_at_by_key"] == {
"websocket:a": "2026-07-27T08:30:00Z"
}
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from urllib.parse import quote
import pytest
from websockets.datastructures import Headers
from nanobot.webui.extensions_routes import WebUIExtensionsRouter
from nanobot.webui.http_utils import http_json_response
class _Service:
def __init__(self) -> None:
self.calls: list[tuple[str, object]] = []
async def status(self):
self.calls.append(("status", None))
return {"extensions": [], "diagnostics": []}
async def install(self, source, *, kind, ref, trusted):
self.calls.append(("install", (source, kind, ref, trusted)))
return {"record": {"id": "sample"}}
async def set_trusted(self, extension_id, trusted):
self.calls.append(("trust", (extension_id, trusted)))
return {"record": {"id": extension_id}}
async def set_permissions(self, extension_id, permissions):
self.calls.append(("permissions", (extension_id, permissions)))
return {"record": {"id": extension_id}}
def _router(
service: _Service,
*,
authorized: bool = True,
allow_remote: bool = False,
) -> WebUIExtensionsRouter:
return WebUIExtensionsRouter(
service=service,
check_api_token=lambda _request: authorized,
json_response=http_json_response,
error_response=lambda status, message: http_json_response(
{"error": message},
status=status,
),
allow_remote_package_install=allow_remote,
logger=SimpleNamespace(exception=lambda *_args: None),
)
def _request(
path: str,
*,
method: str = "GET",
values: dict[str, object] | None = None,
host: str = "127.0.0.1:8765",
):
headers = Headers([("Host", host)])
if values is not None:
headers["X-Nanobot-Extension-Values"] = quote(json.dumps(values))
return SimpleNamespace(path=path, method=method, headers=headers)
_LOCAL = SimpleNamespace(remote_address=("127.0.0.1", 12345))
_REMOTE = SimpleNamespace(remote_address=("192.0.2.1", 12345))
@pytest.mark.asyncio
async def test_extension_status_requires_auth_and_get() -> None:
service = _Service()
unauthorized = await _router(service, authorized=False).dispatch(
_LOCAL,
_request("/api/extensions"),
"/api/extensions",
)
wrong_method = await _router(service).dispatch(
_LOCAL,
_request("/api/extensions", method="POST"),
"/api/extensions",
)
response = await _router(service).dispatch(
_LOCAL,
_request("/api/extensions"),
"/api/extensions",
)
assert unauthorized is not None and unauthorized.status_code == 401
assert wrong_method is not None and wrong_method.status_code == 405
assert response is not None and response.status_code == 200
assert service.calls == [("status", None)]
@pytest.mark.asyncio
async def test_local_install_is_untrusted() -> None:
service = _Service()
response = await _router(service).dispatch(
_LOCAL,
_request(
"/api/extensions/install",
method="POST",
values={"source": "https://example.com/acme.git", "kind": "git"},
),
"/api/extensions/install",
)
assert response is not None and response.status_code == 200
assert service.calls == [
("install", ("https://example.com/acme.git", "git", "", False)),
]
@pytest.mark.asyncio
async def test_remote_policy_allows_git_but_never_local_paths() -> None:
service = _Service()
denied = await _router(service).dispatch(
_REMOTE,
_request(
"/api/extensions/install",
method="POST",
values={"source": "https://example.com/acme.git", "kind": "git"},
),
"/api/extensions/install",
)
allowed = await _router(service, allow_remote=True).dispatch(
_REMOTE,
_request(
"/api/extensions/install",
method="POST",
values={"source": "https://example.com/acme.git", "kind": "git"},
),
"/api/extensions/install",
)
local_denied = await _router(service, allow_remote=True).dispatch(
_REMOTE,
_request(
"/api/extensions/install",
method="POST",
values={"source": "/tmp/example", "kind": "local"},
),
"/api/extensions/install",
)
assert denied is not None and denied.status_code == 403
assert allowed is not None and allowed.status_code == 200
assert local_denied is not None and local_denied.status_code == 403
assert service.calls == [
("install", ("https://example.com/acme.git", "git", "", False)),
]
@pytest.mark.asyncio
async def test_remote_clients_cannot_change_trust() -> None:
service = _Service()
response = await _router(service, allow_remote=True).dispatch(
_REMOTE,
_request(
"/api/extensions/trust",
method="POST",
values={"id": "sample"},
),
"/api/extensions/trust",
)
assert response is not None and response.status_code == 403
assert service.calls == []
@pytest.mark.asyncio
async def test_permissions_require_an_array_of_strings() -> None:
response = await _router(_Service()).dispatch(
_LOCAL,
_request(
"/api/extensions/permissions",
method="POST",
values={"id": "sample", "permissions": "network"},
),
"/api/extensions/permissions",
)
assert response is not None and response.status_code == 400
+14
View File
@@ -98,6 +98,20 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
assert list_webui_sessions(manager) == [] assert list_webui_sessions(manager) == []
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
legacy_path.write_text(
'{"_type":"metadata","key":"websocket:legacy",'
'"created_at":"2025-01-01T00:00:00",'
'"updated_at":"2025-01-01T00:00:00","metadata":{}}\n',
encoding="utf-8",
)
assert list_webui_sessions(manager) == []
assert legacy_path.exists()
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None: def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:cron-preview") session = manager.get_or_create("websocket:cron-preview")
+61 -63
View File
@@ -90,7 +90,13 @@ const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
const PAIRING_POLL_INTERVAL_MS = 5_000; const PAIRING_POLL_INTERVAL_MS = 5_000;
const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000; const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000;
const PAIRING_DISMISS_SNOOZE_MS = 30_000; const PAIRING_DISMISS_SNOOZE_MS = 30_000;
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills"; type ShellView =
| "chat"
| "settings"
| "apps"
| "automations"
| "skills"
| "extensions";
type ShellRoute = { type ShellRoute = {
view: ShellView; view: ShellView;
activeKey: string | null; activeKey: string | null;
@@ -102,6 +108,10 @@ const SettingsView = lazy(async () => {
const module = await loadSettingsView(); const module = await loadSettingsView();
return { default: module.SettingsView }; return { default: module.SettingsView };
}); });
const ExtensionsView = lazy(async () => {
const module = await import("@/components/ExtensionsView");
return { default: module.ExtensionsView };
});
const SessionSearchDialog = lazy(async () => { const SessionSearchDialog = lazy(async () => {
const module = await import("@/components/SessionSearchDialog"); const module = await import("@/components/SessionSearchDialog");
return { default: module.SessionSearchDialog }; return { default: module.SessionSearchDialog };
@@ -225,6 +235,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") { if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" }; return { view: "skills", activeKey, settingsSection: "skills" };
} }
if (path === "/extensions") {
return { view: "extensions", activeKey, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) { if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length); const encoded = path.slice("/chat/".length);
try { try {
@@ -372,15 +385,6 @@ function writeSessionUpdateChatIds(chatIds: Set<string>): void {
} }
} }
function isActivityNewer(updatedAt: string | null, seenAt: string | undefined): boolean {
if (!updatedAt || !seenAt) return false;
const updatedTime = Date.parse(updatedAt);
const seenTime = Date.parse(seenAt);
return Number.isFinite(updatedTime)
&& Number.isFinite(seenTime)
&& updatedTime > seenTime;
}
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload { function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full"; const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
return { return {
@@ -1113,20 +1117,7 @@ function Shell({
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]); }, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => { const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const combined = new Set(updatedChatIds);
for (const session of sessions) {
if (
isActivityNewer(
session.updatedAt,
sidebarState.activity_seen_at_by_key[session.key],
)
) {
combined.add(session.chatId);
}
}
return Array.from(combined);
}, [sessions, sidebarState.activity_seen_at_by_key, updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activeSession?.chatId ?? null;
useEffect(() => { useEffect(() => {
activeChatIdRef.current = activeChatId; activeChatIdRef.current = activeChatId;
@@ -1137,26 +1128,7 @@ function Shell({
next.delete(activeChatId); next.delete(activeChatId);
return next; return next;
}); });
const activityAt = activeSession?.updatedAt; }, [activeChatId]);
const sessionKey = activeSession?.key;
if (!activityAt || !sessionKey) return;
void updateSidebarState((current) => {
const seenAt = current.activity_seen_at_by_key[sessionKey];
if (seenAt && !isActivityNewer(activityAt, seenAt)) return current;
return {
...current,
activity_seen_at_by_key: {
...current.activity_seen_at_by_key,
[sessionKey]: activityAt,
},
};
});
}, [
activeChatId,
activeSession?.key,
activeSession?.updatedAt,
updateSidebarState,
]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => { const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (activeChatId && workspaceOverrides[activeChatId]) { if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId]; return workspaceOverrides[activeChatId];
@@ -1694,6 +1666,12 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [activeKey, navigate]); }, [activeKey, navigate]);
const onOpenExtensions = useCallback(() => {
setSessionSearchOpen(false);
navigate({ view: "extensions", activeKey, settingsSection: "overview" });
setMobileSidebarOpen(false);
}, [activeKey, navigate]);
const onSettingsSectionChange = useCallback( const onSettingsSectionChange = useCallback(
(section: SettingsSectionKey) => { (section: SettingsSectionKey) => {
navigate({ navigate({
@@ -1921,6 +1899,12 @@ function Shell({
}); });
return; return;
} }
if (view === "extensions") {
document.title = t("app.documentTitle.chat", {
title: t("extensions.title", { defaultValue: "Extensions" }),
});
return;
}
document.title = activeSession document.title = activeSession
? t("app.documentTitle.chat", { title: headerTitle }) ? t("app.documentTitle.chat", { title: headerTitle })
: t("app.documentTitle.base"); : t("app.documentTitle.base");
@@ -1943,9 +1927,16 @@ function Shell({
onOpenApps, onOpenApps,
onOpenAutomations, onOpenAutomations,
onOpenSkills, onOpenSkills,
onOpenExtensions,
onSettingsIntent, onSettingsIntent,
onOpenSearch: onOpenSessionSearch, onOpenSearch: onOpenSessionSearch,
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null, activeUtility:
view === "apps"
|| view === "automations"
|| view === "skills"
|| view === "extensions"
? view
: null,
onToggleArchived, onToggleArchived,
pinnedKeys: sidebarState.pinned_keys, pinnedKeys: sidebarState.pinned_keys,
archivedKeys: sidebarState.archived_keys, archivedKeys: sidebarState.archived_keys,
@@ -2138,24 +2129,31 @@ function Shell({
{view !== "chat" && ( {view !== "chat" && (
<div className="absolute inset-0 flex flex-col"> <div className="absolute inset-0 flex flex-col">
<Suspense fallback={<SurfaceLoadingFallback />}> <Suspense fallback={<SurfaceLoadingFallback />}>
<SettingsView {view === "extensions" ? (
theme={theme} <ExtensionsView
initialSection={settingsInitialSection} onBackToChat={onBackToChat}
initialSettings={settingsSnapshot} hostChromeInset={showHostChrome}
showSidebar={view === "settings"} />
onToggleTheme={toggle} ) : (
onBackToChat={onBackToChat} <SettingsView
onModelNameChange={onModelNameChange} theme={theme}
onSettingsChange={setSettingsSnapshot} initialSection={settingsInitialSection}
skills={skills} initialSettings={settingsSnapshot}
onWorkspaceSettingsChange={refreshWorkspaces} showSidebar={view === "settings"}
onSectionChange={onSettingsSectionChange} onToggleTheme={toggle}
onLogout={onLogout} onBackToChat={onBackToChat}
onRestart={onRestart} onModelNameChange={onModelNameChange}
onNativeEngineRestart={onNativeEngineRestart} onSettingsChange={setSettingsSnapshot}
isRestarting={isRestarting} skills={skills}
hostChromeInset={showHostChrome} onWorkspaceSettingsChange={refreshWorkspaces}
/> onSectionChange={onSettingsSectionChange}
onLogout={onLogout}
onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting}
hostChromeInset={showHostChrome}
/>
)}
</Suspense> </Suspense>
</div> </div>
)} )}
+41
View File
@@ -0,0 +1,41 @@
import { ChevronLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ExtensionsCatalog } from "@/components/extensions/ExtensionsCatalog";
import { cn } from "@/lib/utils";
interface ExtensionsViewProps {
hostChromeInset?: boolean;
onBackToChat: () => void;
}
export function ExtensionsView({
hostChromeInset = false,
onBackToChat,
}: ExtensionsViewProps) {
const { t } = useTranslation();
return (
<main className="h-full min-w-0 overflow-y-auto bg-settings-canvas [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto w-full max-w-[920px] px-4 py-6 sm:px-8 sm:py-8 lg:py-12",
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
)}
>
<button
type="button"
onClick={onBackToChat}
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
>
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
{t("extensions.backToChat")}
</button>
<h1 className="mb-7 text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
{t("extensions.title")}
</h1>
<ExtensionsCatalog />
</div>
</main>
);
}
+10 -1
View File
@@ -4,6 +4,7 @@ import {
Brain, Brain,
CalendarClock, CalendarClock,
Menu, Menu,
PackageOpen,
Search, Search,
Settings, Settings,
SquarePen, SquarePen,
@@ -36,10 +37,11 @@ interface SidebarProps {
onOpenSettings: () => void; onOpenSettings: () => void;
onOpenApps: () => void; onOpenApps: () => void;
onOpenSkills: () => void; onOpenSkills: () => void;
onOpenExtensions: () => void;
onOpenAutomations: () => void; onOpenAutomations: () => void;
onSettingsIntent?: () => void; onSettingsIntent?: () => void;
onOpenSearch: () => void; onOpenSearch: () => void;
activeUtility?: "apps" | "skills" | "automations" | null; activeUtility?: "apps" | "skills" | "extensions" | "automations" | null;
onToggleArchived: () => void; onToggleArchived: () => void;
onCollapse: () => void; onCollapse: () => void;
onExpand?: () => void; onExpand?: () => void;
@@ -169,6 +171,13 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "skills"} active={props.activeUtility === "skills"}
icon={<Brain className="h-4 w-4" />} icon={<Brain className="h-4 w-4" />}
/> />
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.extensions")}
onClick={props.onOpenExtensions}
active={props.activeUtility === "extensions"}
icon={<PackageOpen className="h-4 w-4" />}
/>
<SidebarActionButton <SidebarActionButton
collapsed={collapsed} collapsed={collapsed}
label={t("sidebar.automations", { defaultValue: "Automations" })} label={t("sidebar.automations", { defaultValue: "Automations" })}
@@ -0,0 +1,262 @@
import { useState } from "react";
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
import type { ExtensionAction } from "@/lib/api";
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
DetailPill,
DetailSection,
ExtensionMark,
MetaItem,
NamedItems,
StatusBadge,
} from "./extension-ui";
interface ExtensionDetailSheetProps {
extension: ExtensionInfo | null;
diagnostics: ExtensionDiagnosticInfo[];
busy: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onAction: (
action: ExtensionAction,
values: Record<string, unknown>,
) => Promise<void>;
}
export function ExtensionDetailSheet({
extension,
diagnostics,
busy,
open,
onOpenChange,
onAction,
}: ExtensionDetailSheetProps) {
const { t } = useTranslation();
const [uninstallOpen, setUninstallOpen] = useState(false);
if (!extension) return null;
const requested = new Set(extension.requested_permissions);
const granted = new Set(extension.granted_permissions);
const allGranted = [...requested].every((permission) => granted.has(permission));
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="w-[min(36rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="flex items-start gap-3 pr-8">
<ExtensionMark large />
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold">
{extension.name}
</SheetTitle>
<SheetDescription className="mt-1 line-clamp-2 text-[13px]">
{extension.description || extension.id}
</SheetDescription>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<DetailPill>{extension.version}</DetailPill>
<StatusBadge extension={extension} />
</div>
</div>
</div>
<div className="mt-7 space-y-6">
<DetailSection title={t("extensions.details.identity")}>
<dl className="grid grid-cols-2 gap-2">
<MetaItem label="ID" value={extension.id} />
<MetaItem
label={t("extensions.details.source")}
value={extension.source}
/>
<MetaItem
label={t("extensions.details.license")}
value={extension.license || "—"}
/>
</dl>
{extension.homepage ? (
<a
href={extension.homepage}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-[12px] text-link hover:underline"
>
{t("extensions.details.homepage")}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
) : null}
</DetailSection>
<NamedItems
title={t("extensions.details.dependencies")}
rows={extension.dependencies.map((item) => ({
name: item.name,
meta: `${item.kind}${item.specifier ? ` ${item.specifier}` : ""}`,
}))}
/>
<DetailSection title={t("extensions.details.permissions")}>
{extension.permissions.length ? (
<div className="space-y-2">
{extension.permissions.map((permission) => (
<div
key={permission.name}
className="flex items-start justify-between gap-3 rounded-[14px] bg-muted/35 px-3 py-2.5"
>
<div className="min-w-0">
<div className="text-[13px] font-medium text-foreground">
{permission.name}
</div>
{permission.reason ? (
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{permission.reason}
</p>
) : null}
</div>
<span
className={cn(
"shrink-0 text-[11px]",
granted.has(permission.name)
? "text-emerald-600 dark:text-emerald-300"
: "text-muted-foreground",
)}
>
{granted.has(permission.name)
? t("extensions.permissionGranted")
: t("extensions.permissionPending")}
</span>
</div>
))}
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() =>
void onAction("permissions", {
id: extension.id,
permissions: allGranted ? [] : [...requested],
})
}
className="rounded-full"
>
{allGranted
? t("extensions.revokePermissions")
: t("extensions.grantPermissions")}
</Button>
</div>
) : (
<p className="text-[13px] text-muted-foreground">
{t("extensions.noPermissions")}
</p>
)}
</DetailSection>
{diagnostics.length ? (
<DetailSection title={t("extensions.details.diagnostics")}>
<div className="space-y-2">
{diagnostics.map((diagnostic, index) => (
<div
key={`${diagnostic.code}:${index}`}
className="rounded-[14px] bg-amber-500/10 px-3 py-2.5"
>
<div className="text-[12px] font-medium text-amber-700 dark:text-amber-300">
{diagnostic.code}
</div>
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{diagnostic.message}
</p>
</div>
))}
</div>
</DetailSection>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-border/45 bg-background/95 px-5 py-4">
<Button
size="sm"
variant={extension.trusted ? "outline" : "default"}
disabled={busy !== null || (!extension.trusted && !allGranted)}
onClick={() =>
void onAction(extension.trusted ? "untrust" : "trust", {
id: extension.id,
})
}
className="rounded-full"
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{extension.trusted
? t("extensions.revokeTrust")
: t("extensions.trust")}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy !== null || !extension.trusted}
onClick={() =>
void onAction(extension.enabled ? "disable" : "enable", {
id: extension.id,
})
}
className="rounded-full"
>
{extension.enabled
? t("extensions.disable")
: t("extensions.enable")}
</Button>
<Button
size="icon"
variant="ghost"
disabled={busy !== null}
aria-label={t("extensions.uninstall")}
title={t("extensions.uninstall")}
onClick={() => setUninstallOpen(true)}
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
</SheetContent>
</Sheet>
<AlertDialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
<AlertDialogContent className="max-w-[26rem] rounded-[18px]">
<AlertDialogHeader>
<AlertDialogTitle>{t("extensions.uninstallTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("extensions.uninstallDescription", { name: extension.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("extensions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => void onAction("uninstall", { id: extension.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("extensions.uninstall")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CircleAlert, Download, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
fetchExtensions,
runExtensionAction,
type ExtensionAction,
} from "@/lib/api";
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import { ExtensionDetailSheet } from "./ExtensionDetailSheet";
import {
EmptyState,
ExtensionMark,
filterExtensions,
LoadingState,
StatusBadge,
} from "./extension-ui";
type InstallKind = "git" | "local";
export function ExtensionsCatalog() {
const { t } = useTranslation();
const { token } = useClient();
const [query, setQuery] = useState("");
const [source, setSource] = useState("");
const [kind, setKind] = useState<InstallKind>("git");
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [diagnostics, setDiagnostics] = useState<ExtensionDiagnosticInfo[]>([]);
const [selected, setSelected] = useState<ExtensionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
const payload = await fetchExtensions(token);
setExtensions(payload.extensions);
setDiagnostics(payload.diagnostics);
setError(null);
setSelected((current) =>
current
? payload.extensions.find((item) => item.id === current.id) ?? null
: null,
);
} catch (reason) {
setError((reason as Error).message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
void refresh();
}, [refresh]);
const mutate = useCallback(
async (
action: ExtensionAction,
values: Record<string, unknown>,
key: string,
) => {
setBusy(key);
try {
await runExtensionAction(token, action, values);
await refresh();
setError(null);
} catch (reason) {
setError((reason as Error).message);
} finally {
setBusy(null);
}
},
[refresh, token],
);
const visible = useMemo(
() => filterExtensions(extensions, query),
[extensions, query],
);
return (
<div className="space-y-5">
<form
className="flex flex-col gap-2 sm:flex-row"
onSubmit={(event) => {
event.preventDefault();
const value = source.trim();
if (!value) return;
void mutate("install", { source: value, kind }, "install");
}}
>
<select
value={kind}
onChange={(event) => setKind(event.target.value as InstallKind)}
aria-label={t("extensions.installKind")}
className="h-11 rounded-[14px] border border-border/55 bg-settings-surface px-3 text-[13px] text-foreground outline-none"
>
<option value="git">{t("extensions.source.git")}</option>
<option value="local">{t("extensions.source.local")}</option>
</select>
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder={t(
kind === "git"
? "extensions.installGitPlaceholder"
: "extensions.installLocalPlaceholder",
)}
className="h-11 flex-1 rounded-[14px] border-border/55 bg-settings-surface text-[13px] shadow-none"
/>
<Button
type="submit"
disabled={!source.trim() || busy === "install"}
className="h-11 rounded-[14px] px-4"
>
<Download className="mr-2 h-4 w-4" aria-hidden />
{t("extensions.installAction")}
</Button>
</form>
<label className="relative block">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("extensions.searchInstalled")}
className="h-11 rounded-[14px] border-border/55 bg-settings-surface pl-10 text-[13px] shadow-none"
/>
</label>
{error ? (
<div className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[13px] text-destructive">
{error}
</div>
) : null}
<ExtensionList
extensions={visible}
diagnostics={diagnostics}
loading={loading}
onSelect={setSelected}
/>
<ExtensionDetailSheet
extension={selected}
diagnostics={diagnostics.filter(
(diagnostic) => diagnostic.extension_id === selected?.id,
)}
busy={busy}
open={selected !== null}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
onAction={(action, values) =>
mutate(action, values, `${action}:${selected?.id ?? ""}`)
}
/>
</div>
);
}
function ExtensionList({
extensions,
diagnostics,
loading,
onSelect,
}: {
extensions: ExtensionInfo[];
diagnostics: ExtensionDiagnosticInfo[];
loading: boolean;
onSelect: (extension: ExtensionInfo) => void;
}) {
const { t } = useTranslation();
if (loading) return <LoadingState />;
if (!extensions.length) {
return <EmptyState label={t("extensions.empty.installed")} />;
}
const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id));
return (
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
{extensions.map((extension, index) => (
<button
key={extension.id}
type="button"
onClick={() => onSelect(extension)}
className={cn(
"group flex w-full min-w-0 items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/35",
index > 0 && "border-t border-border/40",
)}
>
<ExtensionMark />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[14px] font-medium text-foreground">
{extension.name}
</h3>
</div>
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
{extension.description || extension.id}
</p>
</div>
{diagnosticIds.has(extension.id) ? (
<CircleAlert
className="h-4 w-4 shrink-0 text-amber-500"
aria-hidden
/>
) : null}
<StatusBadge extension={extension} />
</button>
))}
</section>
);
}
@@ -0,0 +1,130 @@
import type { ReactNode } from "react";
import { Loader2, PackageOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ExtensionInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export function ExtensionMark({ large = false }: { large?: boolean }) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-[13px] bg-muted/65 text-muted-foreground",
large ? "h-12 w-12" : "h-10 w-10",
)}
>
<PackageOpen
className={large ? "h-5 w-5" : "h-4 w-4"}
strokeWidth={1.8}
aria-hidden
/>
</div>
);
}
export function StatusBadge({ extension }: { extension: ExtensionInfo }) {
const { t } = useTranslation();
const [key, tone] = extension.active
? ["active", "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"]
: !extension.enabled
? ["disabled", "bg-muted text-muted-foreground"]
: !extension.trusted
? ["untrusted", "bg-amber-500/10 text-amber-700 dark:text-amber-300"]
: ["inactive", "bg-muted text-muted-foreground"];
return (
<span className={cn("shrink-0 rounded-full px-2 py-1 text-[11px] font-medium", tone)}>
{t(`extensions.status.${key}`)}
</span>
);
}
export function DetailSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section>
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
{children}
</section>
);
}
export function NamedItems({
title,
rows,
}: {
title: string;
rows: Array<{ name: string; meta: string }>;
}) {
const { t } = useTranslation();
return (
<DetailSection title={title}>
{rows.length ? (
<div className="divide-y divide-border/35 overflow-hidden rounded-[14px] bg-muted/30">
{rows.map((row, index) => (
<div key={`${row.meta}:${row.name}:${index}`} className="flex gap-3 px-3 py-2.5">
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{row.name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{row.meta}</span>
</div>
))}
</div>
) : (
<p className="text-[13px] text-muted-foreground">{t("extensions.none")}</p>
)}
</DetailSection>
);
}
export function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[14px] bg-muted/35 px-3 py-2.5">
<dt className="text-[11px] text-muted-foreground">{label}</dt>
<dd className="mt-0.5 truncate text-[13px] text-foreground">{value}</dd>
</div>
);
}
export function DetailPill({ children }: { children: ReactNode }) {
return (
<span className="rounded-full bg-muted px-2 py-1 text-[11px] text-muted-foreground">
{children}
</span>
);
}
export function LoadingState() {
const { t } = useTranslation();
return (
<div className="flex min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("extensions.loading")}
</div>
);
}
export function EmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-48 items-center justify-center text-[13px] text-muted-foreground">
{label}
</div>
);
}
export function filterExtensions(
items: ExtensionInfo[],
query: string,
): ExtensionInfo[] {
const term = query.trim().toLowerCase();
if (!term) return items;
return items.filter((item) =>
[item.name, item.id, item.description].some((value) =>
value.toLowerCase().includes(term),
),
);
}
+1 -21
View File
@@ -15,7 +15,6 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
project_name_overrides: {}, project_name_overrides: {},
tags_by_key: {}, tags_by_key: {},
collapsed_groups: {}, collapsed_groups: {},
activity_seen_at_by_key: {},
view: { view: {
density: "comfortable", density: "comfortable",
show_previews: false, show_previews: false,
@@ -95,7 +94,6 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
project_name_overrides: stringMap(value.project_name_overrides), project_name_overrides: stringMap(value.project_name_overrides),
tags_by_key: tagsMap(value.tags_by_key), tags_by_key: tagsMap(value.tags_by_key),
collapsed_groups: boolMap(value.collapsed_groups), collapsed_groups: boolMap(value.collapsed_groups),
activity_seen_at_by_key: stringMap(value.activity_seen_at_by_key),
view: { view: {
density, density,
show_previews: Boolean(view.show_previews), show_previews: Boolean(view.show_previews),
@@ -126,24 +124,9 @@ function pruneMissingSessions(
archived_keys: filterKeys(state.archived_keys), archived_keys: filterKeys(state.archived_keys),
title_overrides: filterMap(state.title_overrides), title_overrides: filterMap(state.title_overrides),
tags_by_key: filterMap(state.tags_by_key), tags_by_key: filterMap(state.tags_by_key),
activity_seen_at_by_key: filterMap(state.activity_seen_at_by_key),
}; };
} }
function seedMissingActivitySeenAt(
state: SidebarStatePayload,
sessions: ChatSummary[],
): SidebarStatePayload {
const seenAt = { ...state.activity_seen_at_by_key };
let changed = false;
for (const session of sessions) {
if (seenAt[session.key] || !session.updatedAt) continue;
seenAt[session.key] = session.updatedAt;
changed = true;
}
return changed ? { ...state, activity_seen_at_by_key: seenAt } : state;
}
function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean { function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
@@ -213,10 +196,7 @@ export function useSidebarState(
const pruned = useMemo(() => { const pruned = useMemo(() => {
if (!sessionsLoaded || loading) return state; if (!sessionsLoaded || loading) return state;
return seedMissingActivitySeenAt( return pruneMissingSessions(state, sessions);
pruneMissingSessions(state, sessions),
sessions,
);
}, [loading, sessions, sessionsLoaded, state]); }, [loading, sessions, sessionsLoaded, state]);
useEffect(() => { useEffect(() => {
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "Automations", "automations": "Automations",
"skills": { "skills": {
"title": "Skills" "title": "Skills"
} },
"extensions": "Extensions"
}, },
"settings": { "settings": {
"backToChat": "Back to chat", "backToChat": "Back to chat",
@@ -1261,5 +1262,51 @@
"usePath": "Use Path", "usePath": "Use Path",
"absolutePathRequired": "Enter an absolute folder path on this machine." "absolutePathRequired": "Enter an absolute folder path on this machine."
} }
},
"extensions": {
"backToChat": "Back to chat",
"title": "Extensions",
"searchInstalled": "Search extensions",
"empty": {
"installed": "No extensions installed."
},
"installKind": "Installation source",
"source": {
"git": "Git repository",
"local": "Local folder"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/absolute/path/to/extension",
"installAction": "Install",
"details": {
"identity": "Identity",
"source": "Source",
"license": "License",
"homepage": "Homepage",
"dependencies": "Dependencies",
"permissions": "Permissions",
"diagnostics": "Diagnostics"
},
"permissionGranted": "Granted",
"permissionPending": "Not granted",
"revokePermissions": "Revoke permissions",
"grantPermissions": "Grant permissions",
"noPermissions": "No host permissions requested.",
"revokeTrust": "Revoke trust",
"trust": "Trust",
"disable": "Disable",
"enable": "Enable",
"uninstall": "Uninstall",
"uninstallTitle": "Uninstall extension?",
"uninstallDescription": "This removes {{name}} and its installed files from nanobot.",
"cancel": "Cancel",
"status": {
"active": "Active",
"disabled": "Disabled",
"untrusted": "Untrusted",
"inactive": "Inactive"
},
"none": "None",
"loading": "Loading extensions…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "Automatizaciones", "automations": "Automatizaciones",
"skills": { "skills": {
"title": "Habilidades" "title": "Habilidades"
} },
"extensions": "Extensiones"
}, },
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
@@ -1248,5 +1249,51 @@
"usePath": "Usar ruta", "usePath": "Usar ruta",
"absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina." "absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina."
} }
},
"extensions": {
"backToChat": "Volver al chat",
"title": "Extensiones",
"searchInstalled": "Buscar extensiones",
"empty": {
"installed": "No hay extensiones instaladas."
},
"installKind": "Origen de instalación",
"source": {
"git": "Repositorio Git",
"local": "Carpeta local"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/ruta/absoluta/a/la/extensión",
"installAction": "Instalar",
"details": {
"identity": "Identidad",
"source": "Origen",
"license": "Licencia",
"homepage": "Página principal",
"dependencies": "Dependencias",
"permissions": "Permisos",
"diagnostics": "Diagnóstico"
},
"permissionGranted": "Concedido",
"permissionPending": "No concedido",
"revokePermissions": "Revocar permisos",
"grantPermissions": "Conceder permisos",
"noPermissions": "No solicita permisos del sistema.",
"revokeTrust": "Revocar confianza",
"trust": "Confiar",
"disable": "Desactivar",
"enable": "Activar",
"uninstall": "Desinstalar",
"uninstallTitle": "¿Desinstalar la extensión?",
"uninstallDescription": "Esto elimina {{name}} y sus archivos instalados de nanobot.",
"cancel": "Cancelar",
"status": {
"active": "Activa",
"disabled": "Desactivada",
"untrusted": "No confiable",
"inactive": "Inactiva"
},
"none": "Ninguno",
"loading": "Cargando extensiones…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "Automatisations", "automations": "Automatisations",
"skills": { "skills": {
"title": "Compétences" "title": "Compétences"
} },
"extensions": "Extensions"
}, },
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour au chat",
@@ -1247,5 +1248,51 @@
"usePath": "Utiliser le chemin", "usePath": "Utiliser le chemin",
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine." "absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
} }
},
"extensions": {
"backToChat": "Retour au chat",
"title": "Extensions",
"searchInstalled": "Rechercher des extensions",
"empty": {
"installed": "Aucune extension installée."
},
"installKind": "Source dinstallation",
"source": {
"git": "Dépôt Git",
"local": "Dossier local"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/chemin/absolu/vers/extension",
"installAction": "Installer",
"details": {
"identity": "Identité",
"source": "Source",
"license": "Licence",
"homepage": "Page daccueil",
"dependencies": "Dépendances",
"permissions": "Autorisations",
"diagnostics": "Diagnostics"
},
"permissionGranted": "Accordée",
"permissionPending": "Non accordée",
"revokePermissions": "Révoquer les autorisations",
"grantPermissions": "Accorder les autorisations",
"noPermissions": "Aucune autorisation hôte demandée.",
"revokeTrust": "Révoquer la confiance",
"trust": "Faire confiance",
"disable": "Désactiver",
"enable": "Activer",
"uninstall": "Désinstaller",
"uninstallTitle": "Désinstaller lextension ?",
"uninstallDescription": "Cela supprime {{name}} et ses fichiers installés de nanobot.",
"cancel": "Annuler",
"status": {
"active": "Active",
"disabled": "Désactivée",
"untrusted": "Non approuvée",
"inactive": "Inactive"
},
"none": "Aucun",
"loading": "Chargement des extensions…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "Otomasi", "automations": "Otomasi",
"skills": { "skills": {
"title": "Skill" "title": "Skill"
} },
"extensions": "Ekstensi"
}, },
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke chat",
@@ -1247,5 +1248,51 @@
"usePath": "Gunakan path", "usePath": "Gunakan path",
"absolutePathRequired": "Masukkan path folder absolut di mesin ini." "absolutePathRequired": "Masukkan path folder absolut di mesin ini."
} }
},
"extensions": {
"backToChat": "Kembali ke chat",
"title": "Ekstensi",
"searchInstalled": "Cari ekstensi",
"empty": {
"installed": "Belum ada ekstensi terpasang."
},
"installKind": "Sumber instalasi",
"source": {
"git": "Repositori Git",
"local": "Folder lokal"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/jalur/absolut/ke/ekstensi",
"installAction": "Pasang",
"details": {
"identity": "Identitas",
"source": "Sumber",
"license": "Lisensi",
"homepage": "Beranda",
"dependencies": "Dependensi",
"permissions": "Izin",
"diagnostics": "Diagnostik"
},
"permissionGranted": "Diberikan",
"permissionPending": "Belum diberikan",
"revokePermissions": "Cabut izin",
"grantPermissions": "Berikan izin",
"noPermissions": "Tidak meminta izin host.",
"revokeTrust": "Cabut kepercayaan",
"trust": "Percayai",
"disable": "Nonaktifkan",
"enable": "Aktifkan",
"uninstall": "Copot",
"uninstallTitle": "Copot ekstensi?",
"uninstallDescription": "Tindakan ini menghapus {{name}} dan berkas terpasangnya dari nanobot.",
"cancel": "Batal",
"status": {
"active": "Aktif",
"disabled": "Nonaktif",
"untrusted": "Belum dipercaya",
"inactive": "Tidak aktif"
},
"none": "Tidak ada",
"loading": "Memuat ekstensi…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "自動タスク", "automations": "自動タスク",
"skills": { "skills": {
"title": "スキル" "title": "スキル"
} },
"extensions": "拡張機能"
}, },
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
@@ -1247,5 +1248,51 @@
"usePath": "パスを使用", "usePath": "パスを使用",
"absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。" "absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。"
} }
},
"extensions": {
"backToChat": "チャットに戻る",
"title": "拡張機能",
"searchInstalled": "拡張機能を検索",
"empty": {
"installed": "拡張機能はインストールされていません。"
},
"installKind": "インストール元",
"source": {
"git": "Git リポジトリ",
"local": "ローカルフォルダー"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/拡張機能への絶対パス",
"installAction": "インストール",
"details": {
"identity": "識別情報",
"source": "ソース",
"license": "ライセンス",
"homepage": "ホームページ",
"dependencies": "依存関係",
"permissions": "権限",
"diagnostics": "診断"
},
"permissionGranted": "許可済み",
"permissionPending": "未許可",
"revokePermissions": "権限を取り消す",
"grantPermissions": "権限を許可",
"noPermissions": "ホスト権限は要求されていません。",
"revokeTrust": "信頼を取り消す",
"trust": "信頼する",
"disable": "無効化",
"enable": "有効化",
"uninstall": "アンインストール",
"uninstallTitle": "拡張機能をアンインストールしますか?",
"uninstallDescription": "{{name}} とインストール済みファイルを nanobot から削除します。",
"cancel": "キャンセル",
"status": {
"active": "有効",
"disabled": "無効",
"untrusted": "未信頼",
"inactive": "停止中"
},
"none": "なし",
"loading": "拡張機能を読み込み中…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "자동화", "automations": "자동화",
"skills": { "skills": {
"title": "스킬" "title": "스킬"
} },
"extensions": "확장 기능"
}, },
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
@@ -1247,5 +1248,51 @@
"usePath": "경로 사용", "usePath": "경로 사용",
"absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요." "absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요."
} }
},
"extensions": {
"backToChat": "채팅으로 돌아가기",
"title": "확장",
"searchInstalled": "확장 검색",
"empty": {
"installed": "설치된 확장이 없습니다."
},
"installKind": "설치 소스",
"source": {
"git": "Git 저장소",
"local": "로컬 폴더"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/확장/절대/경로",
"installAction": "설치",
"details": {
"identity": "식별 정보",
"source": "소스",
"license": "라이선스",
"homepage": "홈페이지",
"dependencies": "종속성",
"permissions": "권한",
"diagnostics": "진단"
},
"permissionGranted": "허용됨",
"permissionPending": "허용되지 않음",
"revokePermissions": "권한 취소",
"grantPermissions": "권한 허용",
"noPermissions": "호스트 권한을 요청하지 않습니다.",
"revokeTrust": "신뢰 취소",
"trust": "신뢰",
"disable": "비활성화",
"enable": "활성화",
"uninstall": "제거",
"uninstallTitle": "확장을 제거할까요?",
"uninstallDescription": "nanobot에서 {{name}} 및 설치된 파일을 제거합니다.",
"cancel": "취소",
"status": {
"active": "활성",
"disabled": "비활성",
"untrusted": "신뢰 안 함",
"inactive": "중지됨"
},
"none": "없음",
"loading": "확장 로드 중…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "Automações", "automations": "Automações",
"skills": { "skills": {
"title": "Skills" "title": "Skills"
} },
"extensions": "Extensões"
}, },
"settings": { "settings": {
"backToChat": "Voltar para a conversa", "backToChat": "Voltar para a conversa",
@@ -1261,5 +1262,51 @@
"usePath": "Usar caminho", "usePath": "Usar caminho",
"absolutePathRequired": "Informe um caminho absoluto de pasta nesta máquina." "absolutePathRequired": "Informe um caminho absoluto de pasta nesta máquina."
} }
},
"extensions": {
"backToChat": "Voltar ao chat",
"title": "Extensões",
"searchInstalled": "Buscar extensões",
"empty": {
"installed": "Nenhuma extensão instalada."
},
"installKind": "Origem da instalação",
"source": {
"git": "Repositório Git",
"local": "Pasta local"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/caminho/absoluto/para/extensão",
"installAction": "Instalar",
"details": {
"identity": "Identidade",
"source": "Origem",
"license": "Licença",
"homepage": "Página inicial",
"dependencies": "Dependências",
"permissions": "Permissões",
"diagnostics": "Diagnósticos"
},
"permissionGranted": "Concedida",
"permissionPending": "Não concedida",
"revokePermissions": "Revogar permissões",
"grantPermissions": "Conceder permissões",
"noPermissions": "Nenhuma permissão do host solicitada.",
"revokeTrust": "Revogar confiança",
"trust": "Confiar",
"disable": "Desativar",
"enable": "Ativar",
"uninstall": "Desinstalar",
"uninstallTitle": "Desinstalar extensão?",
"uninstallDescription": "Isso remove {{name}} e seus arquivos instalados do nanobot.",
"cancel": "Cancelar",
"status": {
"active": "Ativa",
"disabled": "Desativada",
"untrusted": "Não confiável",
"inactive": "Inativa"
},
"none": "Nenhum",
"loading": "Carregando extensões…"
} }
} }
+48 -1
View File
@@ -58,7 +58,8 @@
"automations": "Tự động hóa", "automations": "Tự động hóa",
"skills": { "skills": {
"title": "Kỹ năng" "title": "Kỹ năng"
} },
"extensions": "Tiện ích"
}, },
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại chat",
@@ -1247,5 +1248,51 @@
"usePath": "Dùng đường dẫn", "usePath": "Dùng đường dẫn",
"absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này." "absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này."
} }
},
"extensions": {
"backToChat": "Quay lại trò chuyện",
"title": "Tiện ích",
"searchInstalled": "Tìm tiện ích",
"empty": {
"installed": "Chưa cài tiện ích nào."
},
"installKind": "Nguồn cài đặt",
"source": {
"git": "Kho Git",
"local": "Thư mục cục bộ"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/đường/dẫn/tuyệt/đối/đến/tiện-ích",
"installAction": "Cài đặt",
"details": {
"identity": "Danh tính",
"source": "Nguồn",
"license": "Giấy phép",
"homepage": "Trang chủ",
"dependencies": "Phụ thuộc",
"permissions": "Quyền",
"diagnostics": "Chẩn đoán"
},
"permissionGranted": "Đã cấp",
"permissionPending": "Chưa cấp",
"revokePermissions": "Thu hồi quyền",
"grantPermissions": "Cấp quyền",
"noPermissions": "Không yêu cầu quyền máy chủ.",
"revokeTrust": "Thu hồi tin cậy",
"trust": "Tin cậy",
"disable": "Tắt",
"enable": "Bật",
"uninstall": "Gỡ cài đặt",
"uninstallTitle": "Gỡ tiện ích?",
"uninstallDescription": "Thao tác này xóa {{name}} và các tệp đã cài khỏi nanobot.",
"cancel": "Hủy",
"status": {
"active": "Đang hoạt động",
"disabled": "Đã tắt",
"untrusted": "Chưa tin cậy",
"inactive": "Không hoạt động"
},
"none": "Không có",
"loading": "Đang tải tiện ích…"
} }
} }

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