mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
refactor(extensions): simplify the native extension platform
This commit is contained in:
parent
d68857bb2d
commit
bed0db4922
@ -33,7 +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 a nanobot, Pi, or OpenClaw extension | [Extensions](./extensions.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) |
|
||||||
@ -81,7 +81,6 @@ These pages explain implementation and extension points. You do not need them to
|
|||||||
| 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) |
|
| Publish an extension package | [Extension Authoring](./extension-authoring.md) |
|
||||||
| Understand the extension control plane | [Extension System](./extension-system.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.
|
||||||
|
|||||||
@ -18,7 +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 nanobot, Pi, or OpenClaw packages |
|
| 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 |
|
||||||
@ -257,10 +257,7 @@ operations:
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot extensions list` | Show installed packages and activation policy |
|
| `nanobot extensions list` | Show installed packages and activation policy |
|
||||||
| `nanobot extensions inspect <id>` | Show contributions, dependencies, requested permissions, and diagnostics |
|
| `nanobot extensions inspect <id>` | Show identity, dependencies, requested permissions, and diagnostics |
|
||||||
| `nanobot extensions search [query]` | Search compatible packages on npm |
|
|
||||||
| `nanobot extensions search [query] --ecosystem <name>` | Filter to `nanobot`, `pi`, or `openclaw` |
|
|
||||||
| `nanobot extensions install <npm-spec>` | Install an npm package as untrusted |
|
|
||||||
| `nanobot extensions install <url> --kind git [--ref <ref>]` | Install from a Git branch, tag, or commit |
|
| `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 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 permissions <id> [permissions...]` | Replace the exact granted permission set; omit values to revoke all |
|
||||||
@ -274,16 +271,16 @@ operations:
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot extensions install @acme/pi-review
|
nanobot extensions install https://github.com/acme/nanobot-review.git
|
||||||
nanobot extensions inspect pi.acme.pi-review
|
nanobot extensions inspect acme.review
|
||||||
nanobot extensions permissions pi.acme.pi-review workspace.read
|
nanobot extensions permissions acme.review workspace.read
|
||||||
nanobot extensions trust pi.acme.pi-review
|
nanobot extensions trust acme.review
|
||||||
nanobot extensions enable pi.acme.pi-review
|
nanobot extensions enable acme.review
|
||||||
```
|
```
|
||||||
|
|
||||||
Installed packages live under `~/.nanobot/extensions/`. They do not execute
|
Installed packages live under `~/.nanobot/extensions/`. They do not execute
|
||||||
until trusted. See [Extensions](./extensions.md) for the safety model and
|
until trusted. See [Extensions](./extensions.md) for the safety model and
|
||||||
[Extension Authoring](./extension-authoring.md) for package compatibility.
|
[Extension Authoring](./extension-authoring.md) for the native package contract.
|
||||||
|
|
||||||
## Optional Features
|
## Optional Features
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +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) |
|
||||||
| Configure extension discovery and policy | [Extensions](#extensions) |
|
| 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) |
|
||||||
@ -66,7 +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) |
|
||||||
| Discover and govern extension packages | `extensions.*` | `nanobot extensions list`, then inspect the package | [Extensions](#extensions), [Extension guide](./extensions.md) |
|
| 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) |
|
||||||
@ -2239,27 +2239,12 @@ When enabled, all incoming messages — regardless of which channel they arrive
|
|||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Use the WebUI **Extensions** page or `nanobot extensions` commands for normal
|
Use the WebUI **Extensions** page or `nanobot extensions` commands for normal
|
||||||
installation and trust decisions. The top-level `extensions` object is for
|
installation and trust decisions. Extension support can be disabled globally:
|
||||||
advanced discovery and policy:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"paths": ["/opt/nanobot/extensions"],
|
|
||||||
"allow": [],
|
|
||||||
"deny": ["acme.blocked"],
|
|
||||||
"workspaceTrust": "ask",
|
|
||||||
"entries": {
|
|
||||||
"acme.review": {
|
|
||||||
"enabled": true,
|
|
||||||
"trusted": true,
|
|
||||||
"permissions": ["workspace.read"],
|
|
||||||
"config": {
|
|
||||||
"mode": "strict"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -2267,18 +2252,10 @@ advanced discovery and policy:
|
|||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `extensions.enabled` | `true` | Enable external extension discovery and activation |
|
| `extensions.enabled` | `true` | Enable external extension discovery and activation |
|
||||||
| `extensions.paths` | `[]` | Additional manifest roots; each root may be one package or contain package directories |
|
|
||||||
| `extensions.allow` | `[]` | Optional extension ID allowlist; empty allows all IDs not denied |
|
|
||||||
| `extensions.deny` | `[]` | Extension IDs that must remain inactive |
|
|
||||||
| `extensions.workspaceTrust` | `"ask"` | Workspace extension policy: `"ask"`, `"allow"`, or `"deny"` |
|
|
||||||
| `extensions.entries.<id>.enabled` | `true` | Per-extension activation switch |
|
|
||||||
| `extensions.entries.<id>.trusted` | `false` | Approve executing that extension's code |
|
|
||||||
| `extensions.entries.<id>.permissions` | `[]` | Exact host permissions granted to the extension |
|
|
||||||
| `extensions.entries.<id>.config` | `{}` | Package-owned configuration passed to its runtime |
|
|
||||||
|
|
||||||
User-installed packages and their interactive trust state live under
|
Installed packages and their trust, permission, and activation state live
|
||||||
`~/.nanobot/extensions/`. Config policy is merged at discovery time; nanobot
|
under `~/.nanobot/extensions/`. Managing an extension does not rewrite
|
||||||
does not silently rewrite `config.json` when you install a package.
|
`config.json`.
|
||||||
|
|
||||||
See [Extensions](./extensions.md) for the safe install flow and
|
See [Extensions](./extensions.md) for the safe install flow and
|
||||||
[Extension Authoring](./extension-authoring.md) for the package contract.
|
[Extension Authoring](./extension-authoring.md) for the package contract.
|
||||||
|
|||||||
@ -1,56 +1,33 @@
|
|||||||
# Extension Authoring
|
# Extension Authoring
|
||||||
|
|
||||||
nanobot extensions are packages with a strict `nanobot.extension.json`
|
A native nanobot extension is a directory containing:
|
||||||
manifest. The manifest can be inspected without importing optional SDKs or
|
|
||||||
executing package code. Runtime activation then projects each contribution into
|
|
||||||
the native nanobot registry that owns that capability.
|
|
||||||
|
|
||||||
This guide covers native Python extensions and the compatibility boundary for
|
|
||||||
Pi and OpenClaw packages. Users installing packages should read
|
|
||||||
[Extensions](./extensions.md).
|
|
||||||
|
|
||||||
## Package Layout
|
|
||||||
|
|
||||||
A minimal native package is:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
my-extension/
|
nanobot-review/
|
||||||
|-- nanobot.extension.json
|
├── nanobot.extension.json
|
||||||
`-- my_extension/
|
└── extension.py
|
||||||
`-- __init__.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`nanobot.extension.json`:
|
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
|
```json
|
||||||
{
|
{
|
||||||
"id": "acme.review",
|
"id": "acme.review",
|
||||||
"name": "Acme Review",
|
"name": "Acme Review",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"entry": "extension:register",
|
||||||
|
"description": "Adds repository review tools.",
|
||||||
"apiVersion": 1,
|
"apiVersion": 1,
|
||||||
"runtime": "python",
|
|
||||||
"entry": "my_extension:register",
|
|
||||||
"description": "Adds a review tool and command.",
|
|
||||||
"homepage": "https://example.com/acme-review",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"contributions": [
|
"homepage": "https://github.com/acme/nanobot-review",
|
||||||
{
|
|
||||||
"kind": "tool",
|
|
||||||
"name": "review_code",
|
|
||||||
"description": "Review a local change."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "command",
|
|
||||||
"name": "review",
|
|
||||||
"description": "Start a review from chat."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{
|
{
|
||||||
"kind": "python",
|
"kind": "executable",
|
||||||
"name": "acme-review-core",
|
"name": "git"
|
||||||
"specifier": ">=1,<2",
|
|
||||||
"optional": false
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@ -62,245 +39,109 @@ my-extension/
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Unknown fields are rejected. This is deliberate: a misspelled permission,
|
Required fields are `id`, `name`, and `version`. `entry` defaults to
|
||||||
dependency, or contribution must not silently change package behavior.
|
`"extension:register"` and `apiVersion` defaults to `1`.
|
||||||
|
|
||||||
## Manifest Reference
|
IDs use lowercase letters, digits, dots, underscores, and hyphens. Entry points
|
||||||
|
use `module:function` syntax and must resolve inside the package.
|
||||||
### Top-level fields
|
|
||||||
|
|
||||||
| Field | Required | Meaning |
|
|
||||||
|---|---:|---|
|
|
||||||
| `id` | yes | Stable lowercase package identity |
|
|
||||||
| `name` | yes | Human-readable name |
|
|
||||||
| `version` | yes | Installed package version |
|
|
||||||
| `apiVersion` | no | Manifest API, currently `1` |
|
|
||||||
| `runtime` | yes | `python`, `pi`, `openclaw`, or `declarative` |
|
|
||||||
| `entry` | runtime-dependent | One activation entry |
|
|
||||||
| `entries` | runtime-dependent | Multiple Pi/OpenClaw entries; takes precedence over `entry` |
|
|
||||||
| `contributions` | no | Capabilities owned by the package |
|
|
||||||
| `dependencies` | no | Activation prerequisites |
|
|
||||||
| `permissions` | no | Privileged host capabilities requested from the user |
|
|
||||||
| `description` | no | Catalog summary |
|
|
||||||
| `homepage` | no | Project or documentation URL |
|
|
||||||
| `license` | no | SPDX-style license label |
|
|
||||||
|
|
||||||
Entries must be relative to the package root and cannot contain `..`.
|
|
||||||
|
|
||||||
### Contributions
|
|
||||||
|
|
||||||
Supported `kind` values are:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tool
|
|
||||||
skill
|
|
||||||
channel
|
|
||||||
llm_provider
|
|
||||||
transcription_provider
|
|
||||||
image_generation_provider
|
|
||||||
web_search_provider
|
|
||||||
mcp_server
|
|
||||||
hook
|
|
||||||
command
|
|
||||||
webui
|
|
||||||
```
|
|
||||||
|
|
||||||
Each contribution has a stable `name`, optional runtime `target`, and optional
|
|
||||||
`description`. Two active extensions cannot own the same contribution name.
|
|
||||||
Disable one owner before activating the other; v1 deliberately does not allow
|
|
||||||
extensions to replace core or third-party registrations.
|
|
||||||
|
|
||||||
The manifest declares ownership. It does not create an implementation by
|
|
||||||
itself. A runtime must register the corresponding native capability.
|
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
| Kind | `name` identifies | Version behavior |
|
| Kind | Meaning |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| `python` | Installed Python distribution | PEP 440 specifier |
|
| `python` | Installed Python distribution; `specifier` accepts a version constraint |
|
||||||
| `npm` | Package under the extension's `node_modules` | npm installation constraint |
|
| `executable` | Command available on `PATH` |
|
||||||
| `executable` | Command on `PATH` | No version probe |
|
| `environment` | Non-empty environment variable |
|
||||||
| `environment` | Environment variable | Must be non-empty |
|
|
||||||
| `extension` | Another extension ID | Active extension version |
|
|
||||||
|
|
||||||
Set `optional: true` when the extension can activate without the dependency.
|
Set `"optional": true` when a missing dependency should not block activation.
|
||||||
Do not put API keys in the manifest.
|
|
||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
|
|
||||||
Permission names are lowercase namespaced identifiers such as
|
Permissions are lowercase namespaced identifiers chosen by the package, such
|
||||||
`workspace.read`, `workspace.write`, or `network.http`. Include a concrete
|
as `workspace.read` or `network`. Give each permission a concrete reason.
|
||||||
reason the user can evaluate. Activation requires every requested permission to
|
Activation waits until every requested permission is granted.
|
||||||
be granted.
|
|
||||||
|
|
||||||
Permissions are review and activation gates; they do not constrain direct
|
The host currently uses permissions as explicit user consent. They do not
|
||||||
Python or Node process access and are not an OS sandbox. Keep the request set
|
sandbox Python code, so do not describe a permission as stronger isolation
|
||||||
minimal and use native host operations when one exists.
|
than it provides.
|
||||||
|
|
||||||
## Native Python Runtime
|
## Registration API
|
||||||
|
|
||||||
The Python entry is `module[:attribute]`; the default attribute is `register`.
|
The entry point receives `PythonExtensionApi` and must return `None`:
|
||||||
The function is synchronous and must return `None`:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
|
|
||||||
class ReviewTool(Tool):
|
class ReviewTool(Tool):
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "review_code"
|
return "review_repository"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self) -> str:
|
def description(self) -> str:
|
||||||
return "Review a local code change."
|
return "Review the current repository."
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self) -> dict:
|
def parameters(self) -> dict[str, Any]:
|
||||||
return {
|
return {"type": "object", "properties": {}}
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": {"type": "string"},
|
|
||||||
},
|
|
||||||
"required": ["path"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(self, path: str) -> str:
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
return f"Review requested for {path}"
|
return "No findings."
|
||||||
|
|
||||||
|
|
||||||
def register(api) -> None:
|
def register(api) -> None:
|
||||||
api.register_tool(ReviewTool())
|
api.register_tool(ReviewTool())
|
||||||
```
|
```
|
||||||
|
|
||||||
The v1 Python API exposes:
|
The API has three stable methods:
|
||||||
|
|
||||||
- `register_tool(tool)`
|
```python
|
||||||
- `register_command(command, handler, prefix=False)`
|
api.register_tool(tool)
|
||||||
- `register_hook_factory(factory)`
|
api.register_command("review", handler)
|
||||||
|
api.register_hook_factory(factory)
|
||||||
These methods use existing nanobot registries. Do not import or patch
|
|
||||||
`AgentLoop`, reach into WebSocket internals, or create a second tool registry.
|
|
||||||
If a new contribution kind needs execution support, add an adapter at the
|
|
||||||
native subsystem boundary and keep the manifest API independent from that
|
|
||||||
implementation.
|
|
||||||
|
|
||||||
## Publish and Discover
|
|
||||||
|
|
||||||
For npm discovery, include the exact `nanobot-extension` keyword:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keywords": [
|
|
||||||
"nanobot-extension"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Compatibility packages use their upstream metadata and keyword:
|
Command handlers use nanobot's `CommandContext` and return an
|
||||||
|
`OutboundMessage` or `None`. Hook factories receive `AgentTurnHookContext` and
|
||||||
|
return an `AgentHook` or `None`.
|
||||||
|
|
||||||
- Pi: `pi-package` plus `pi.extensions`
|
Do not modify `AgentLoop` or global registries directly. The API tags every
|
||||||
- OpenClaw: `openclaw-plugin` plus `openclaw.extensions` or
|
registration with the extension ID so reload, failure rollback, and uninstall
|
||||||
`openclaw.runtimeExtensions`
|
can remove exactly what the package owns.
|
||||||
|
|
||||||
The adapter adds the `runtime.node` permission to both generated manifests.
|
## Collision and failure behavior
|
||||||
Users must explicitly grant it before any third-party JavaScript or TypeScript
|
|
||||||
entry runs.
|
|
||||||
|
|
||||||
Native Python packages can be installed from a local directory or Git source.
|
Tool and command names are unique across core and active extensions. If an
|
||||||
The market is an index; installation always passes through local validation,
|
extension registers a duplicate name, activation fails for that extension and
|
||||||
dependency checks, permission review, trust, and activation.
|
all of its partial registrations are rolled back.
|
||||||
|
|
||||||
## Compatibility Matrix
|
Missing dependencies are reported as diagnostics instead of crashing the
|
||||||
|
gateway.
|
||||||
|
|
||||||
The matrix describes the current adapter, not an intention to support every
|
## Develop locally
|
||||||
upstream API.
|
|
||||||
|
|
||||||
| Upstream capability | Pi | OpenClaw | nanobot behavior |
|
1. Create the manifest and entry module.
|
||||||
|---|---|---|---|
|
2. Install the directory with `--kind local`.
|
||||||
| Tool registration and calls | Executable | Executable | Projected as native `Tool`; implementation stays in Node sidecar |
|
3. Inspect and grant its permissions.
|
||||||
| Slash commands | Executable | Executable | Registered in native command router |
|
4. Trust it.
|
||||||
| Supported lifecycle events | Observation-only | Observation-only | Receives serialized run/tool events; cannot mutate native context |
|
5. Reinstall after editing so nanobot records a new integrity digest.
|
||||||
| Provider registration | Metadata only | Metadata only | Visible in catalog and diagnostics; not an executable provider |
|
|
||||||
| Transcription/image/web-search provider contracts | N/A | Metadata only | Visible but not projected into native provider registries |
|
|
||||||
| Skills declared in plugin metadata | N/A | Metadata only | Catalog ownership only; runtime skill installation is not synthesized |
|
|
||||||
| Channels | N/A | Metadata only | No OpenClaw channel host emulation |
|
|
||||||
| Session tree and custom entries | Unsupported | Unsupported | No compatible host surface |
|
|
||||||
| Terminal UI, widgets, renderers, shortcuts | Unsupported | Unsupported | WebUI and CLI have different rendering contracts |
|
|
||||||
| Model selection and thinking control | Unsupported | Unsupported | Remains owned by nanobot model presets and request policy |
|
|
||||||
| Arbitrary upstream host services | Unsupported | Unsupported | Reported as diagnostics rather than silently emulated |
|
|
||||||
|
|
||||||
### Pi package shape
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@acme/pi-review",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"keywords": ["pi-package"],
|
|
||||||
"pi": {
|
|
||||||
"extensions": ["./index.ts"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The sidecar supports `registerTool`, `registerCommand`, and selected `on(...)`
|
|
||||||
lifecycle handlers. TypeScript uses Node's native type stripping when
|
|
||||||
available, with `jiti` as a fallback installed with the package runtime.
|
|
||||||
Required `peerDependencies` are installed into that runtime as well; peers
|
|
||||||
marked optional in `peerDependenciesMeta` remain optional.
|
|
||||||
The generated manifest requests `runtime.node`.
|
|
||||||
|
|
||||||
### OpenClaw package shape
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@acme/openclaw-review",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"keywords": ["openclaw-plugin"],
|
|
||||||
"openclaw": {
|
|
||||||
"runtimeExtensions": ["./dist/index.js"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If present, `openclaw.plugin.json` supplies catalog identity, contribution
|
|
||||||
contracts, command aliases, and compatibility diagnostics. The OpenClaw
|
|
||||||
`register` function must complete synchronously during load.
|
|
||||||
The generated manifest requests `runtime.node`.
|
|
||||||
|
|
||||||
## Test an Extension
|
|
||||||
|
|
||||||
Use an isolated config and workspace while developing:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot extensions install ./my-extension --kind local
|
nanobot extensions install "$PWD" --kind local
|
||||||
nanobot extensions inspect acme.review
|
nanobot extensions inspect acme.review
|
||||||
nanobot extensions permissions acme.review workspace.read
|
nanobot extensions permissions acme.review workspace.read
|
||||||
nanobot extensions trust acme.review
|
nanobot extensions trust acme.review
|
||||||
nanobot extensions enable acme.review
|
|
||||||
nanobot agent -m "Use review_code on README.md"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Also test:
|
Keep tests in the extension repository. At minimum, test registration,
|
||||||
|
duplicate-name failure, and behavior when each required dependency is missing.
|
||||||
|
|
||||||
- install while untrusted does not execute code;
|
## Distribution
|
||||||
- package changes after installation revoke effective trust;
|
|
||||||
- missing hard dependencies leave the package inactive;
|
|
||||||
- extension dependencies activate before their dependents and reject cycles;
|
|
||||||
- denied or missing permissions prevent activation;
|
|
||||||
- duplicate contribution names become diagnostics;
|
|
||||||
- disable, untrust, reload, and uninstall remove runtime registrations;
|
|
||||||
- one broken package does not block unrelated packages.
|
|
||||||
|
|
||||||
For nanobot itself, extension tests live under `tests/extensions/`. Keep
|
Publish the directory in a Git repository. Users can pin a release tag or
|
||||||
compatibility fixtures small and assert diagnostics for unsupported APIs.
|
commit with `--ref`. The repository root must contain
|
||||||
|
`nanobot.extension.json`; install scripts and generated compatibility manifests
|
||||||
## Design Rules
|
are not part of the native contract.
|
||||||
|
|
||||||
1. Extend the control plane, not the core loop.
|
|
||||||
2. Keep one native owner for each capability.
|
|
||||||
3. Make discovery metadata-only.
|
|
||||||
4. Separate install, permission grant, trust, and enable.
|
|
||||||
5. Report partial compatibility honestly.
|
|
||||||
6. Keep market metadata independent from runtime execution.
|
|
||||||
7. Roll back all registrations owned by a failed or unloaded extension.
|
|
||||||
|
|||||||
@ -1,167 +0,0 @@
|
|||||||
# Extension system
|
|
||||||
|
|
||||||
This page documents the maintainer-facing architecture. For installation and
|
|
||||||
operations, read [Extensions](./extensions.md). For the package contract, read
|
|
||||||
[Extension Authoring](./extension-authoring.md).
|
|
||||||
|
|
||||||
nanobot treats an extension as an installable, governable unit and a
|
|
||||||
contribution as one capability supplied by that unit. This distinction keeps
|
|
||||||
the agent core small without forcing tools, channels, providers, skills, MCP
|
|
||||||
servers, hooks, commands, and WebUI code into one artificial runtime interface.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The extension platform is a control plane over existing native registries:
|
|
||||||
|
|
||||||
```text
|
|
||||||
package / workspace directory / compatibility package
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ExtensionManifest
|
|
||||||
|
|
|
||||||
v
|
|
||||||
ExtensionRegistry
|
|
||||||
selection, policy, ownership
|
|
||||||
|
|
|
||||||
+----------------+----------------+
|
|
||||||
| | |
|
|
||||||
v v v
|
|
||||||
native adapters Pi adapter OpenClaw adapter
|
|
||||||
| | |
|
|
||||||
+----------------+----------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
executable native adapters + inspectable metadata
|
|
||||||
```
|
|
||||||
|
|
||||||
`ExtensionManifest` is dependency-free metadata. Discovery can inspect it
|
|
||||||
without importing optional SDKs or executing plugin code. `ExtensionRegistry`
|
|
||||||
selects the active installation, applies allow/deny policy, and resolves
|
|
||||||
contribution ownership. Runtime adapters activate only the contributions the
|
|
||||||
host supports.
|
|
||||||
|
|
||||||
Packages expose this metadata as `nanobot.extension.json`. The same canonical
|
|
||||||
JSON shape is used on disk, over the Node sidecar protocol, and in market
|
|
||||||
indexes. Unknown fields are rejected so a misspelled permission or contribution
|
|
||||||
cannot silently change behavior.
|
|
||||||
|
|
||||||
The agent loop does not discover or execute plugins. Assembly code resolves
|
|
||||||
extensions before constructing the runtime and passes native tools, hooks, and
|
|
||||||
other contributions through the interfaces those subsystems already expose.
|
|
||||||
|
|
||||||
## Identity and precedence
|
|
||||||
|
|
||||||
An extension ID is stable across installations. The same ID may exist in three
|
|
||||||
scopes:
|
|
||||||
|
|
||||||
1. `builtin`
|
|
||||||
2. `user`
|
|
||||||
3. `workspace`
|
|
||||||
|
|
||||||
The nearest policy-eligible scope wins for the same extension ID. A disabled,
|
|
||||||
untrusted, denied, or invalid higher-scope copy does not shadow an eligible
|
|
||||||
lower copy. Different extensions may not take over the same contribution name.
|
|
||||||
Conflicts become diagnostics instead of crashing unrelated extensions.
|
|
||||||
Extension API v1 deliberately has no override mechanism because runtime
|
|
||||||
replacement must be both transactional and reversible.
|
|
||||||
|
|
||||||
## Compatibility runtimes
|
|
||||||
|
|
||||||
Pi and OpenClaw extensions are JavaScript or TypeScript programs, so Python
|
|
||||||
cannot import them as native nanobot modules. Compatibility runs them in a
|
|
||||||
Node.js sidecar and projects supported registrations into nanobot's native
|
|
||||||
registries over a versioned protocol.
|
|
||||||
|
|
||||||
Compatibility is capability-based rather than all-or-nothing:
|
|
||||||
|
|
||||||
- A package may load while one unsupported contribution is disabled.
|
|
||||||
- Inspection reports every supported, translated, degraded, and unsupported
|
|
||||||
contribution.
|
|
||||||
- UI- or host-specific behavior is never reported as working when nanobot
|
|
||||||
cannot provide the required host interface.
|
|
||||||
- Plugin failures are isolated from the agent process and produce actionable
|
|
||||||
diagnostics.
|
|
||||||
|
|
||||||
The compatibility sidecar is a failure-isolation boundary, not a security
|
|
||||||
sandbox. The exact executable and metadata-only surfaces are listed in the
|
|
||||||
[compatibility matrix](./extension-authoring.md#compatibility-matrix).
|
|
||||||
Generated Pi and OpenClaw manifests always request `runtime.node`, so process
|
|
||||||
execution is visible and requires explicit consent even though that permission
|
|
||||||
is not an OS-level confinement mechanism.
|
|
||||||
|
|
||||||
## Security model
|
|
||||||
|
|
||||||
Extensions are trusted code, not prompts or static skills. Installation and
|
|
||||||
activation are separate actions. The host records source, version, requested
|
|
||||||
permissions, dependency state, and trust scope before executing code.
|
|
||||||
|
|
||||||
Project-local extensions require workspace trust. Contribution conflicts never
|
|
||||||
grant an implicit override. Secrets remain in nanobot provider or host config
|
|
||||||
unless the operator explicitly passes values through extension config. Native
|
|
||||||
Python code and compatible Node processes are trusted code and may still
|
|
||||||
inspect their process environment or filesystem directly; permission
|
|
||||||
declarations are review and activation gates, not technical confinement.
|
|
||||||
Existing workspace, network, SSRF, and shell restrictions apply only when an
|
|
||||||
extension uses host-provided operations.
|
|
||||||
|
|
||||||
Untrusted packages remain visible in the catalog with an inactive state. They
|
|
||||||
do not own active contributions and their runtime is not imported. Built-in
|
|
||||||
capabilities are trusted by construction; installed and workspace packages
|
|
||||||
need an explicit trusted entry or an allowed workspace trust policy.
|
|
||||||
|
|
||||||
The root `extensions` config controls explicit search paths, allow/deny policy,
|
|
||||||
per-extension enablement, package-owned config, and workspace trust. Discovery
|
|
||||||
does not import extension code. Installation does not imply workspace trust,
|
|
||||||
and activation does not rewrite `config.json` behind the user's back.
|
|
||||||
|
|
||||||
The activation gates are deliberately independent:
|
|
||||||
|
|
||||||
```text
|
|
||||||
installed -> integrity verified -> active dependencies ready
|
|
||||||
-> permissions granted -> trusted + enabled
|
|
||||||
```
|
|
||||||
|
|
||||||
Only candidates that pass every gate own active contributions. Reload first
|
|
||||||
rolls back registrations by extension owner and then activates the new
|
|
||||||
snapshot. A failed activation is converted into a diagnostic.
|
|
||||||
|
|
||||||
## Market boundary
|
|
||||||
|
|
||||||
The market is an index, not a runtime. Extension API v1 searches npm for
|
|
||||||
nanobot, Pi, and OpenClaw package keywords. Git and local directories are
|
|
||||||
install sources but are not searchable catalogs. Installing a listing still
|
|
||||||
goes through the local installer, integrity record, policy, dependency checks,
|
|
||||||
and trust flow. The boundary permits more catalog adapters later without
|
|
||||||
coupling package discovery to execution.
|
|
||||||
|
|
||||||
## Ownership boundaries
|
|
||||||
|
|
||||||
The extension package owns identity, policy, and contribution declarations.
|
|
||||||
Native subsystems continue to own execution:
|
|
||||||
|
|
||||||
| Concern | Owner |
|
|
||||||
|---|---|
|
|
||||||
| Package identity, source, trust, permissions | `nanobot.extensions` |
|
|
||||||
| Tool execution contract | `nanobot.agent.tools` |
|
|
||||||
| Commands | `nanobot.command` |
|
|
||||||
| Agent lifecycle hooks | `nanobot.agent.hook` |
|
|
||||||
| Providers | `nanobot.providers` |
|
|
||||||
| Channels | `nanobot.channels` |
|
|
||||||
| Skills | `nanobot.skills` |
|
|
||||||
| Browser management surface | `webui` |
|
|
||||||
|
|
||||||
Do not add extension discovery or compatibility branching to `AgentLoop`.
|
|
||||||
Runtime assembly creates an `ExtensionHost`, projects supported registrations
|
|
||||||
through native APIs, and closes the host with the surrounding runtime.
|
|
||||||
|
|
||||||
## Protocol boundary
|
|
||||||
|
|
||||||
Pi and OpenClaw entries run behind a versioned NDJSON request/response protocol.
|
|
||||||
The Python process sends load, call, lifecycle event, and close requests. The
|
|
||||||
Node process returns registrations, results, outputs, and diagnostics. Protocol
|
|
||||||
messages contain JSON-compatible values only.
|
|
||||||
|
|
||||||
The adapter must reject malformed messages, time out stalled requests, and
|
|
||||||
close the sidecar when activation fails. Unsupported upstream methods are
|
|
||||||
either explicit no-ops with diagnostics or rejected; they must never be
|
|
||||||
reported as executable contributions.
|
|
||||||
@ -1,205 +1,89 @@
|
|||||||
# Extensions
|
# Extensions
|
||||||
|
|
||||||
Extensions add capabilities to nanobot without modifying the agent loop. A
|
Extensions add native tools, slash commands, or lifecycle hooks without
|
||||||
package can declare tools, commands, hooks, skills, channels, providers, MCP
|
changing nanobot core. An extension is a Python package with one manifest and
|
||||||
servers, or WebUI surfaces in one governable manifest. Extension API v1
|
one registration entry point.
|
||||||
executes native Python tools, commands, and hooks plus the compatible Pi and
|
|
||||||
OpenClaw surfaces listed below; other contribution kinds are catalog metadata
|
|
||||||
until their owning nanobot subsystem provides an activation adapter.
|
|
||||||
|
|
||||||
Use this page to install and manage extensions. To publish one, read
|
Use an extension when a capability needs executable integration with nanobot.
|
||||||
[Extension Authoring](./extension-authoring.md). For the internal design, read
|
Use a [skill](./skills.md) when instructions alone are enough, an App when the
|
||||||
[Extension System](./extension-system.md).
|
agent should call an external CLI, and MCP when a service already exposes an
|
||||||
|
MCP server.
|
||||||
|
|
||||||
## Install Safely
|
## Install
|
||||||
|
|
||||||
The WebUI **Extensions** page and the `nanobot extensions` commands use the same
|
Install from a Git repository:
|
||||||
local extension store. The safe lifecycle is:
|
|
||||||
|
|
||||||
1. Find or install a package.
|
|
||||||
2. Inspect its source, runtime, dependencies, requested permissions, and
|
|
||||||
diagnostics.
|
|
||||||
3. Grant only the permissions you understand.
|
|
||||||
4. Mark the package trusted.
|
|
||||||
5. Enable it.
|
|
||||||
|
|
||||||
Installation does not grant trust. An installed package remains visible but
|
|
||||||
inactive until it is enabled, trusted, has all requested permissions, and
|
|
||||||
passes integrity and dependency checks.
|
|
||||||
|
|
||||||
### WebUI
|
|
||||||
|
|
||||||
Open **Extensions** in the sidebar:
|
|
||||||
|
|
||||||
- **Installed** manages packages in `~/.nanobot/extensions/`.
|
|
||||||
- **Discover** searches npm packages marked for nanobot, Pi, or OpenClaw.
|
|
||||||
- **Built in** shows native nanobot capabilities projected into the same
|
|
||||||
catalog. Built-in entries are informational and are not uninstallable.
|
|
||||||
|
|
||||||
Select an entry to see its contributions, dependencies, permission reasons,
|
|
||||||
source, compatibility notices, and activation errors. Trust and permission
|
|
||||||
controls are intentionally separate.
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Search all supported npm ecosystems
|
nanobot extensions install https://github.com/acme/nanobot-review.git
|
||||||
nanobot extensions search "memory"
|
|
||||||
|
|
||||||
# Search one compatibility ecosystem
|
|
||||||
nanobot extensions search "review" --ecosystem pi
|
|
||||||
|
|
||||||
# Install without trusting the package
|
|
||||||
nanobot extensions install @scope/package
|
|
||||||
|
|
||||||
# Inspect before activation
|
|
||||||
nanobot extensions inspect pi.scope.package
|
|
||||||
|
|
||||||
# Grant exactly the requested host permissions
|
|
||||||
nanobot extensions permissions pi.scope.package network.http workspace.read
|
|
||||||
|
|
||||||
# Trust and enable
|
|
||||||
nanobot extensions trust pi.scope.package
|
|
||||||
nanobot extensions enable pi.scope.package
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Install from Git or a local directory when the package is not published:
|
Install a local package while developing it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot extensions install https://github.com/acme/nanobot-tool.git --kind git
|
nanobot extensions install /absolute/path/to/nanobot-review --kind local
|
||||||
nanobot extensions install ./my-extension --kind local
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Local-directory installation is available only to local WebUI requests. Remote
|
Git installs may select a branch, tag, or commit:
|
||||||
browser clients cannot ask the gateway to read an arbitrary server path.
|
|
||||||
|
|
||||||
## Sources and Scopes
|
```bash
|
||||||
|
nanobot extensions install https://github.com/acme/nanobot-review.git \
|
||||||
Extensions can come from three scopes:
|
--ref v1.2.0
|
||||||
|
|
||||||
| Scope | Typical source | Behavior |
|
|
||||||
|---|---|---|
|
|
||||||
| Built in | nanobot package | Trusted by construction; shown for ownership and diagnostics |
|
|
||||||
| User | `~/.nanobot/extensions/` | Installed and governed through the WebUI or CLI |
|
|
||||||
| Workspace | `<workspace>/.nanobot/extensions/` | Project-local code; controlled by workspace trust policy |
|
|
||||||
|
|
||||||
When the same extension ID exists in multiple scopes, the nearest eligible copy
|
|
||||||
wins: workspace over user, user over built in. An untrusted, disabled, denied,
|
|
||||||
or invalid copy does not hide a usable lower-scope copy. A contribution cannot
|
|
||||||
silently replace another extension's contribution. Disable one owner before
|
|
||||||
activating the other; extension API v1 does not let packages replace core or
|
|
||||||
third-party registrations.
|
|
||||||
|
|
||||||
## Pi and OpenClaw Packages
|
|
||||||
|
|
||||||
nanobot reads native Pi and OpenClaw package metadata and runs supported
|
|
||||||
JavaScript or TypeScript entries in a Node.js sidecar. Compatibility is not
|
|
||||||
all-or-nothing:
|
|
||||||
|
|
||||||
- Adapted packages always request `runtime.node`, making process-level code
|
|
||||||
execution explicit before trust and activation.
|
|
||||||
- Tools, slash commands, and supported lifecycle observation hooks can run.
|
|
||||||
- Provider-like registrations and several host-specific capabilities may be
|
|
||||||
cataloged but not executable.
|
|
||||||
- Terminal UI, session-tree, renderer, shortcut, and model-control APIs do not
|
|
||||||
have equivalent nanobot host surfaces.
|
|
||||||
- Every degraded or unsupported registration appears in diagnostics.
|
|
||||||
|
|
||||||
Check the [compatibility matrix](./extension-authoring.md#compatibility-matrix)
|
|
||||||
before depending on a package. A package appearing in search results means its
|
|
||||||
metadata is recognizable, not that every upstream API is implemented.
|
|
||||||
|
|
||||||
## Trust and Permissions
|
|
||||||
|
|
||||||
An extension is executable code. Review it with the same care as a Python or npm
|
|
||||||
dependency.
|
|
||||||
|
|
||||||
- **Enabled** says the extension may activate.
|
|
||||||
- **Trusted** says you approve executing its code.
|
|
||||||
- **Granted permissions** record the exact capabilities you reviewed and
|
|
||||||
approved.
|
|
||||||
- **Dependencies** must themselves be active before activation.
|
|
||||||
|
|
||||||
Permissions are consent and activation gates, not runtime capability
|
|
||||||
enforcement or an operating-system sandbox. Direct extension code may access
|
|
||||||
anything available to its process. A trusted native Python extension executes
|
|
||||||
inside nanobot. A Pi or OpenClaw extension executes in a separate Node.js
|
|
||||||
process, which improves failure isolation but is not a strong security
|
|
||||||
boundary. Their generated manifests therefore request `runtime.node`; granting
|
|
||||||
it acknowledges this execution model but does not confine the process. Use
|
|
||||||
containers or another OS sandbox for untrusted third-party code.
|
|
||||||
|
|
||||||
nanobot records a package content hash at installation. If files change later,
|
|
||||||
the package cannot activate, even if configuration marks it trusted; reinstall
|
|
||||||
it so the new contents can be reviewed.
|
|
||||||
|
|
||||||
npm installation uses lifecycle scripts disabled. This prevents package
|
|
||||||
`preinstall` and `postinstall` scripts from running during installation, but the
|
|
||||||
extension entry itself will run after you explicitly trust and enable it.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Most users should manage installed packages in the WebUI or CLI. Advanced
|
|
||||||
deployments can also define extension policy in `~/.nanobot/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"extensions": {
|
|
||||||
"enabled": true,
|
|
||||||
"paths": ["/opt/nanobot/extensions"],
|
|
||||||
"allow": [],
|
|
||||||
"deny": ["acme.blocked"],
|
|
||||||
"workspaceTrust": "ask",
|
|
||||||
"entries": {
|
|
||||||
"acme.review": {
|
|
||||||
"enabled": true,
|
|
||||||
"trusted": true,
|
|
||||||
"permissions": ["workspace.read"],
|
|
||||||
"config": {
|
|
||||||
"mode": "strict"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Config entries do not rewrite the installation registry. See
|
The WebUI **Extensions** page exposes the same Git and local installation
|
||||||
[Configuration](./configuration.md#extensions) for exact fields. Actions from
|
flows. Local paths are accepted only from a browser running on the nanobot
|
||||||
the Extensions WebUI or CLI reload the extension host. Direct edits to advanced
|
host.
|
||||||
`extensions` config fields are applied on the next process start.
|
|
||||||
|
|
||||||
## Diagnose an Inactive Extension
|
## Review before activation
|
||||||
|
|
||||||
Start with:
|
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
|
```bash
|
||||||
nanobot extensions list
|
nanobot extensions list
|
||||||
nanobot extensions inspect <extension-id>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Common causes:
|
Disable, untrust, or remove a package at any time:
|
||||||
|
|
||||||
| State or diagnostic | What to do |
|
|
||||||
|---|---|
|
|
||||||
| Untrusted | Review the package, then use `trust` |
|
|
||||||
| Requested permission pending | Grant the exact requested permission set |
|
|
||||||
| Disabled | Use `enable` or remove it from `extensions.deny` |
|
|
||||||
| Integrity mismatch | Reinstall and review the changed package |
|
|
||||||
| Missing dependency | Install and activate the named package, executable, environment variable, or extension |
|
|
||||||
| Contribution conflict | Disable one owner before activating the other |
|
|
||||||
| Compatibility notice | Read which upstream API was translated, degraded, or unsupported |
|
|
||||||
| Activation failed | Check the package entry, runtime dependency, and gateway logs |
|
|
||||||
|
|
||||||
Policy changes reload the active extension host. A broken extension becomes a
|
|
||||||
diagnostic and does not prevent unrelated extensions from being discovered.
|
|
||||||
|
|
||||||
## Remove an Extension
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot extensions disable <extension-id>
|
nanobot extensions disable acme.review
|
||||||
nanobot extensions uninstall <extension-id>
|
nanobot extensions untrust acme.review
|
||||||
|
nanobot extensions uninstall acme.review
|
||||||
```
|
```
|
||||||
|
|
||||||
Uninstall removes the user-scope package and its local policy record. It does
|
Changes made in the WebUI reload its gateway extension host immediately.
|
||||||
not remove a built-in capability or a separately configured workspace copy.
|
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.
|
||||||
|
|||||||
@ -305,7 +305,8 @@ WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it as
|
|||||||
remote access and leave package installs disabled unless that is intentional.
|
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`. Extension packages use their declared Git or npm source.
|
`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.
|
||||||
|
|||||||
@ -86,21 +86,9 @@ class ToolLoader:
|
|||||||
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
|
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
|
||||||
registered: list[str] = []
|
registered: list[str] = []
|
||||||
builtin_names: set[str] = set()
|
builtin_names: set[str] = set()
|
||||||
sources = [
|
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
|
||||||
(
|
|
||||||
(("nanobot.core", tool_cls) for tool_cls in self.discover()),
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
(
|
|
||||||
(f"legacy.tool.{entry_point_name}", tool_cls)
|
|
||||||
for entry_point_name, tool_cls in self._discover_plugins().items()
|
|
||||||
),
|
|
||||||
True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for source, is_plugin_source in sources:
|
for source, is_plugin_source in sources:
|
||||||
for owner, tool_cls in source:
|
for tool_cls in source:
|
||||||
cls_label = tool_cls.__name__
|
cls_label = tool_cls.__name__
|
||||||
try:
|
try:
|
||||||
if scope not in getattr(tool_cls, "_scopes", {"core"}):
|
if scope not in getattr(tool_cls, "_scopes", {"core"}):
|
||||||
@ -121,7 +109,7 @@ class ToolLoader:
|
|||||||
"Tool name collision: %s from %s overwrites existing",
|
"Tool name collision: %s from %s overwrites existing",
|
||||||
tool.name, cls_label,
|
tool.name, cls_label,
|
||||||
)
|
)
|
||||||
registry.register(tool, owner=owner)
|
registry.register(tool)
|
||||||
registered.append(tool.name)
|
registered.append(tool.name)
|
||||||
if not is_plugin_source:
|
if not is_plugin_source:
|
||||||
builtin_names.add(tool.name)
|
builtin_names.add(tool.name)
|
||||||
|
|||||||
@ -1349,7 +1349,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 import ExtensionHost
|
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
|
||||||
|
|
||||||
@ -1652,7 +1652,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 import ExtensionHost, ExtensionService
|
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,
|
||||||
@ -2164,7 +2165,6 @@ def _run_gateway(
|
|||||||
console.print,
|
console.print,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await agent._connect_mcp()
|
|
||||||
await extension_host.reload()
|
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.
|
||||||
@ -2285,7 +2285,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 import ExtensionHost
|
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)
|
||||||
@ -2356,7 +2356,6 @@ def agent(
|
|||||||
# Single message mode — direct call, no bus needed
|
# Single message mode — direct call, no bus needed
|
||||||
async def run_once():
|
async def run_once():
|
||||||
try:
|
try:
|
||||||
await agent_loop._connect_mcp()
|
|
||||||
await extension_host.reload()
|
await extension_host.reload()
|
||||||
renderer = StreamRenderer(
|
renderer = StreamRenderer(
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
@ -2418,7 +2417,6 @@ 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 agent_loop._connect_mcp()
|
|
||||||
await extension_host.reload()
|
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()
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from nanobot.extensions import ExtensionService
|
from nanobot.extensions.service import ExtensionService
|
||||||
|
|
||||||
ServiceFactory = Callable[[], ExtensionService]
|
ServiceFactory = Callable[[], ExtensionService]
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ def create_extensions_app(
|
|||||||
service_factory: ServiceFactory = ExtensionService,
|
service_factory: ServiceFactory = ExtensionService,
|
||||||
) -> typer.Typer:
|
) -> typer.Typer:
|
||||||
"""Build the extension command group around the transport-neutral service."""
|
"""Build the extension command group around the transport-neutral service."""
|
||||||
app = typer.Typer(help="Discover, install, inspect, and govern extensions.")
|
app = typer.Typer(help="Install, inspect, and govern native extensions.")
|
||||||
|
|
||||||
def service() -> ExtensionService:
|
def service() -> ExtensionService:
|
||||||
return service_factory()
|
return service_factory()
|
||||||
@ -39,7 +39,6 @@ def create_extensions_app(
|
|||||||
payload = run(service().status())
|
payload = run(service().status())
|
||||||
table = Table(show_header=True, header_style="bold")
|
table = Table(show_header=True, header_style="bold")
|
||||||
table.add_column("Extension")
|
table.add_column("Extension")
|
||||||
table.add_column("Runtime")
|
|
||||||
table.add_column("State")
|
table.add_column("State")
|
||||||
table.add_column("Trust")
|
table.add_column("Trust")
|
||||||
table.add_column("Version")
|
table.add_column("Version")
|
||||||
@ -47,7 +46,6 @@ def create_extensions_app(
|
|||||||
state = "active" if item["active"] else ("enabled" if item["enabled"] else "disabled")
|
state = "active" if item["active"] else ("enabled" if item["enabled"] else "disabled")
|
||||||
table.add_row(
|
table.add_row(
|
||||||
item["name"],
|
item["name"],
|
||||||
item["runtime"],
|
|
||||||
state,
|
state,
|
||||||
"trusted" if item["trusted"] else "untrusted",
|
"trusted" if item["trusted"] else "untrusted",
|
||||||
item["version"],
|
item["version"],
|
||||||
@ -71,12 +69,10 @@ def create_extensions_app(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
console.print(f"[bold]{item['name']}[/bold] [dim]{item['version']}[/dim]")
|
console.print(f"[bold]{item['name']}[/bold] [dim]{item['version']}[/dim]")
|
||||||
console.print(item["description"] or "[dim]No description.[/dim]")
|
console.print(item["description"] or "[dim]No description.[/dim]")
|
||||||
console.print(f"Runtime: {item['runtime']} Scope: {item['scope']}")
|
|
||||||
console.print(
|
console.print(
|
||||||
f"State: {'active' if item['active'] else 'inactive'} "
|
f"State: {'active' if item['active'] else 'inactive'} "
|
||||||
f"Trust: {'trusted' if item['trusted'] else 'untrusted'}"
|
f"Trust: {'trusted' if item['trusted'] else 'untrusted'}"
|
||||||
)
|
)
|
||||||
_print_named_rows(console, "Contributions", item["contributions"], "kind", "name")
|
|
||||||
_print_named_rows(console, "Dependencies", item["dependencies"], "kind", "name")
|
_print_named_rows(console, "Dependencies", item["dependencies"], "kind", "name")
|
||||||
_print_permissions(console, item["permissions"], set(item["granted_permissions"]))
|
_print_permissions(console, item["permissions"], set(item["granted_permissions"]))
|
||||||
diagnostics = [
|
diagnostics = [
|
||||||
@ -91,39 +87,10 @@ def create_extensions_app(
|
|||||||
f" [yellow]{diagnostic['code']}[/yellow] {diagnostic['message']}"
|
f" [yellow]{diagnostic['code']}[/yellow] {diagnostic['message']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.command("search")
|
|
||||||
def search_extensions(
|
|
||||||
query: str = typer.Argument("", help="Package name or keyword"),
|
|
||||||
ecosystem: str = typer.Option(
|
|
||||||
"all",
|
|
||||||
"--ecosystem",
|
|
||||||
"-e",
|
|
||||||
help="all, nanobot, pi, or openclaw",
|
|
||||||
),
|
|
||||||
limit: int = typer.Option(20, "--limit", min=1, max=100),
|
|
||||||
) -> None:
|
|
||||||
"""Search compatible extension packages on npm."""
|
|
||||||
payload = run(service().search(query, ecosystem=ecosystem, limit=limit))
|
|
||||||
table = Table(show_header=True, header_style="bold")
|
|
||||||
table.add_column("Package")
|
|
||||||
table.add_column("Ecosystem")
|
|
||||||
table.add_column("Version")
|
|
||||||
table.add_column("Description")
|
|
||||||
for package in payload["packages"]:
|
|
||||||
table.add_row(
|
|
||||||
package["name"],
|
|
||||||
package["ecosystem"],
|
|
||||||
package["version"],
|
|
||||||
package["description"],
|
|
||||||
)
|
|
||||||
console.print(table)
|
|
||||||
if not payload["packages"]:
|
|
||||||
console.print("[dim]No compatible packages found.[/dim]")
|
|
||||||
|
|
||||||
@app.command("install")
|
@app.command("install")
|
||||||
def install_extension(
|
def install_extension(
|
||||||
source: str = typer.Argument(..., help="npm spec, Git URL, or local path"),
|
source: str = typer.Argument(..., help="Git URL or local package path"),
|
||||||
kind: str = typer.Option("npm", "--kind", help="npm, git, or local"),
|
kind: str = typer.Option("git", "--kind", help="git or local"),
|
||||||
ref: str = typer.Option("", "--ref", help="Git branch, tag, or commit"),
|
ref: str = typer.Option("", "--ref", help="Git branch, tag, or commit"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Install an extension without granting trust or permissions."""
|
"""Install an extension without granting trust or permissions."""
|
||||||
|
|||||||
@ -97,13 +97,6 @@ class CommandRouter:
|
|||||||
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
|
self._owners[("prefix", pfx)] = owner
|
||||||
|
|
||||||
def registrations(self) -> tuple[tuple[str, str, str], ...]:
|
|
||||||
"""Return ``(tier, command, owner)`` rows for extension inspection."""
|
|
||||||
return tuple(
|
|
||||||
(tier, command, owner)
|
|
||||||
for (tier, command), owner in sorted(self._owners.items())
|
|
||||||
)
|
|
||||||
|
|
||||||
def owner(self, tier: str, command: str) -> str | None:
|
def owner(self, tier: str, command: str) -> str | None:
|
||||||
"""Return the extension that owns one command registration."""
|
"""Return the extension that owns one command registration."""
|
||||||
return self._owners.get((tier, command))
|
return self._owners.get((tier, command))
|
||||||
|
|||||||
@ -411,33 +411,10 @@ class ToolsConfig(Base):
|
|||||||
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 ExtensionEntryConfig(Base):
|
|
||||||
"""Activation and package-owned config for one installed extension."""
|
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
trusted: bool = False
|
|
||||||
permissions: list[str] = Field(default_factory=list)
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionsConfig(Base):
|
class ExtensionsConfig(Base):
|
||||||
"""Discovery and trust policy for first-class extensions."""
|
"""Global switch for external extension activation."""
|
||||||
|
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
paths: list[str] = Field(default_factory=list)
|
|
||||||
allow: list[str] = Field(default_factory=list)
|
|
||||||
deny: list[str] = Field(default_factory=list)
|
|
||||||
entries: dict[str, ExtensionEntryConfig] = Field(default_factory=dict)
|
|
||||||
workspace_trust: Literal["ask", "allow", "deny"] = "ask"
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _validate_policy(self) -> "ExtensionsConfig":
|
|
||||||
overlap = set(self.allow) & set(self.deny)
|
|
||||||
if overlap:
|
|
||||||
raise ValueError(
|
|
||||||
f"extension IDs cannot appear in both allow and deny: {sorted(overlap)}"
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
|
|||||||
@ -1,101 +1,19 @@
|
|||||||
"""First-class extension metadata and discovery primitives."""
|
"""Stable author-facing API for native nanobot extensions."""
|
||||||
|
|
||||||
from nanobot.extensions.catalog import ExtensionCatalog, build_extension_catalog
|
|
||||||
from nanobot.extensions.codec import (
|
|
||||||
MANIFEST_FILENAME,
|
|
||||||
ManifestFormatError,
|
|
||||||
dump_manifest,
|
|
||||||
load_manifest,
|
|
||||||
manifest_from_mapping,
|
|
||||||
manifest_to_mapping,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.host import ExtensionHost, ExtensionHostSnapshot
|
|
||||||
from nanobot.extensions.manifest import (
|
from nanobot.extensions.manifest import (
|
||||||
EXTENSION_API_VERSION,
|
EXTENSION_API_VERSION,
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
DependencyKind,
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionDependency,
|
ExtensionDependency,
|
||||||
ExtensionManifest,
|
ExtensionManifest,
|
||||||
ExtensionPermission,
|
ExtensionPermission,
|
||||||
ExtensionRuntime,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.market import ExtensionMarketplace, MarketplacePackage
|
|
||||||
from nanobot.extensions.native import discover_native_extensions
|
|
||||||
from nanobot.extensions.node_host import NodeSidecar
|
|
||||||
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
|
||||||
from nanobot.extensions.protocol import (
|
|
||||||
NODE_PROTOCOL_VERSION,
|
|
||||||
NodeLoadResult,
|
|
||||||
NodeProtocolError,
|
|
||||||
NodeRegistration,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.registry import (
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionDiagnostic,
|
|
||||||
ExtensionPolicy,
|
|
||||||
ExtensionRegistry,
|
|
||||||
ExtensionScope,
|
|
||||||
ExtensionSnapshot,
|
|
||||||
ResolvedContribution,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.runtime import (
|
|
||||||
ActivatedExtension,
|
|
||||||
ActivationResult,
|
|
||||||
ExtensionRuntimeManager,
|
|
||||||
PythonExtensionApi,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.service import ExtensionService
|
|
||||||
from nanobot.extensions.store import (
|
|
||||||
ExtensionSourceKind,
|
|
||||||
ExtensionStore,
|
|
||||||
InstalledExtension,
|
|
||||||
InstallResult,
|
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.runtime import PythonExtensionApi
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EXTENSION_API_VERSION",
|
"EXTENSION_API_VERSION",
|
||||||
"ContributionKind",
|
|
||||||
"DependencyKind",
|
"DependencyKind",
|
||||||
"ExtensionCandidate",
|
|
||||||
"ExtensionCatalog",
|
|
||||||
"ExtensionHost",
|
|
||||||
"ExtensionHostSnapshot",
|
|
||||||
"ExtensionMarketplace",
|
|
||||||
"ExtensionContribution",
|
|
||||||
"ExtensionDependency",
|
"ExtensionDependency",
|
||||||
"ExtensionDiagnostic",
|
|
||||||
"ExtensionManifest",
|
"ExtensionManifest",
|
||||||
"ExtensionPermission",
|
"ExtensionPermission",
|
||||||
"ExtensionPolicy",
|
|
||||||
"ExtensionRegistry",
|
|
||||||
"ExtensionRuntime",
|
|
||||||
"ExtensionRuntimeManager",
|
|
||||||
"ExtensionScope",
|
|
||||||
"ExtensionSnapshot",
|
|
||||||
"ExtensionSourceKind",
|
|
||||||
"ExtensionStore",
|
|
||||||
"ExtensionService",
|
|
||||||
"MANIFEST_FILENAME",
|
|
||||||
"ManifestFormatError",
|
|
||||||
"NODE_PROTOCOL_VERSION",
|
|
||||||
"NodeLoadResult",
|
|
||||||
"NodeProtocolError",
|
|
||||||
"NodeRegistration",
|
|
||||||
"NodeSidecar",
|
|
||||||
"ActivatedExtension",
|
|
||||||
"AdaptedPackage",
|
|
||||||
"ActivationResult",
|
|
||||||
"PythonExtensionApi",
|
"PythonExtensionApi",
|
||||||
"InstalledExtension",
|
|
||||||
"InstallResult",
|
|
||||||
"MarketplacePackage",
|
|
||||||
"ResolvedContribution",
|
|
||||||
"build_extension_catalog",
|
|
||||||
"adapt_package",
|
|
||||||
"dump_manifest",
|
|
||||||
"discover_native_extensions",
|
|
||||||
"load_manifest",
|
|
||||||
"manifest_from_mapping",
|
|
||||||
"manifest_to_mapping",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,31 +1,21 @@
|
|||||||
"""Assemble native and installed extensions into one inspectable catalog."""
|
"""Discover installed extensions and resolve one activation snapshot."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Iterable
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from nanobot.extensions.discovery import (
|
|
||||||
ExtensionDiscoveryResult,
|
|
||||||
discover_manifest_root,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.native import discover_native_extensions
|
|
||||||
from nanobot.extensions.preflight import evaluate_dependencies
|
from nanobot.extensions.preflight import evaluate_dependencies
|
||||||
from nanobot.extensions.registry import (
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
ExtensionPolicy,
|
|
||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionScope,
|
|
||||||
ExtensionSnapshot,
|
ExtensionSnapshot,
|
||||||
)
|
)
|
||||||
from nanobot.extensions.store import ExtensionStore
|
from nanobot.extensions.store import ExtensionStore
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.skills import SkillsLoader
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.command.router import CommandRouter
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
@ -41,43 +31,17 @@ class ExtensionCatalog:
|
|||||||
def build_extension_catalog(
|
def build_extension_catalog(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
skills: SkillsLoader | None = None,
|
|
||||||
tools: ToolRegistry | None = None,
|
|
||||||
commands: CommandRouter | None = None,
|
|
||||||
user_root: Path | None = None,
|
user_root: Path | None = None,
|
||||||
) -> ExtensionCatalog:
|
) -> ExtensionCatalog:
|
||||||
"""Build the authoritative extension view without executing plugin code."""
|
"""Build the authoritative extension view without executing package code."""
|
||||||
native = discover_native_extensions(
|
if not config.extensions.enabled:
|
||||||
config,
|
return ExtensionCatalog((), ExtensionSnapshot((), ()), ())
|
||||||
skills=skills,
|
|
||||||
tools=tools,
|
|
||||||
commands=commands,
|
|
||||||
)
|
|
||||||
discoveries = [native]
|
|
||||||
if config.extensions.enabled:
|
|
||||||
external_root = user_root or Path.home() / ".nanobot" / "extensions"
|
|
||||||
discoveries.append(ExtensionStore(external_root).discover())
|
|
||||||
discoveries.extend(
|
|
||||||
_external_discoveries(config, user_root=external_root)
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates = tuple(
|
discovery = ExtensionStore(user_root).discover()
|
||||||
_apply_entry_config(config, candidate)
|
candidates, dependency_diagnostics = evaluate_dependencies(
|
||||||
for result in discoveries
|
discovery.candidates
|
||||||
for candidate in result.candidates
|
|
||||||
)
|
|
||||||
candidates, dependency_diagnostics = evaluate_dependencies(candidates)
|
|
||||||
discovery_diagnostics = tuple(
|
|
||||||
diagnostic
|
|
||||||
for result in discoveries
|
|
||||||
for diagnostic in result.diagnostics
|
|
||||||
)
|
|
||||||
registry = ExtensionRegistry(
|
|
||||||
ExtensionPolicy(
|
|
||||||
allow=frozenset(config.extensions.allow),
|
|
||||||
deny=frozenset(config.extensions.deny),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
registry = ExtensionRegistry()
|
||||||
registry_diagnostics: list[ExtensionDiagnostic] = []
|
registry_diagnostics: list[ExtensionDiagnostic] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
try:
|
try:
|
||||||
@ -92,47 +56,9 @@ def build_extension_catalog(
|
|||||||
)
|
)
|
||||||
snapshot = registry.snapshot()
|
snapshot = registry.snapshot()
|
||||||
diagnostics = (
|
diagnostics = (
|
||||||
discovery_diagnostics
|
discovery.diagnostics
|
||||||
+ dependency_diagnostics
|
+ dependency_diagnostics
|
||||||
+ tuple(registry_diagnostics)
|
+ tuple(registry_diagnostics)
|
||||||
+ snapshot.diagnostics
|
+ snapshot.diagnostics
|
||||||
)
|
)
|
||||||
return ExtensionCatalog(candidates, snapshot, diagnostics)
|
return ExtensionCatalog(candidates, snapshot, diagnostics)
|
||||||
|
|
||||||
|
|
||||||
def _external_discoveries(
|
|
||||||
config: Config,
|
|
||||||
*,
|
|
||||||
user_root: Path,
|
|
||||||
) -> Iterable[ExtensionDiscoveryResult]:
|
|
||||||
for raw_path in config.extensions.paths:
|
|
||||||
yield discover_manifest_root(
|
|
||||||
Path(raw_path).expanduser(),
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
)
|
|
||||||
|
|
||||||
workspace_root = config.workspace_path / ".nanobot" / "extensions"
|
|
||||||
workspace_trust = config.extensions.workspace_trust
|
|
||||||
if workspace_trust != "deny":
|
|
||||||
yield discover_manifest_root(
|
|
||||||
workspace_root,
|
|
||||||
scope=ExtensionScope.WORKSPACE,
|
|
||||||
trusted=workspace_trust == "allow",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_entry_config(
|
|
||||||
config: Config,
|
|
||||||
candidate: ExtensionCandidate,
|
|
||||||
) -> ExtensionCandidate:
|
|
||||||
entry = config.extensions.entries.get(candidate.manifest.id)
|
|
||||||
if entry is None or candidate.scope is ExtensionScope.BUILTIN:
|
|
||||||
return candidate
|
|
||||||
return replace(
|
|
||||||
candidate,
|
|
||||||
enabled=entry.enabled,
|
|
||||||
trusted=candidate.trusted or entry.trusted,
|
|
||||||
granted_permissions=(
|
|
||||||
candidate.granted_permissions | frozenset(entry.permissions)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,46 +1,17 @@
|
|||||||
"""Strict JSON codec for portable extension manifests."""
|
"""JSON persistence for extension manifests."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.extensions.manifest import (
|
from pydantic import ValidationError
|
||||||
EXTENSION_API_VERSION,
|
|
||||||
ContributionKind,
|
from nanobot.extensions.manifest import ExtensionManifest
|
||||||
DependencyKind,
|
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionPermission,
|
|
||||||
ExtensionRuntime,
|
|
||||||
)
|
|
||||||
|
|
||||||
MANIFEST_FILENAME = "nanobot.extension.json"
|
MANIFEST_FILENAME = "nanobot.extension.json"
|
||||||
|
|
||||||
_MANIFEST_KEYS = frozenset(
|
|
||||||
{
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"version",
|
|
||||||
"runtime",
|
|
||||||
"entry",
|
|
||||||
"entries",
|
|
||||||
"contributions",
|
|
||||||
"description",
|
|
||||||
"dependencies",
|
|
||||||
"permissions",
|
|
||||||
"apiVersion",
|
|
||||||
"homepage",
|
|
||||||
"license",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
_CONTRIBUTION_KEYS = frozenset({"kind", "name", "target", "description"})
|
|
||||||
_DEPENDENCY_KEYS = frozenset({"kind", "name", "specifier", "optional"})
|
|
||||||
_PERMISSION_KEYS = frozenset({"name", "reason"})
|
|
||||||
|
|
||||||
|
|
||||||
class ManifestFormatError(ValueError):
|
class ManifestFormatError(ValueError):
|
||||||
"""Raised when a manifest cannot be decoded unambiguously."""
|
"""Raised when a manifest cannot be decoded unambiguously."""
|
||||||
@ -57,147 +28,29 @@ def load_manifest(path: Path) -> ExtensionManifest:
|
|||||||
|
|
||||||
def dump_manifest(manifest: ExtensionManifest, path: Path) -> None:
|
def dump_manifest(manifest: ExtensionManifest, path: Path) -> None:
|
||||||
"""Write one canonical JSON manifest."""
|
"""Write one canonical JSON manifest."""
|
||||||
payload = json.dumps(
|
path.write_text(
|
||||||
manifest_to_mapping(manifest),
|
json.dumps(manifest_to_mapping(manifest), ensure_ascii=False, indent=2) + "\n",
|
||||||
ensure_ascii=False,
|
encoding="utf-8",
|
||||||
indent=2,
|
|
||||||
)
|
)
|
||||||
path.write_text(payload + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def manifest_from_mapping(data: object) -> ExtensionManifest:
|
def manifest_from_mapping(data: object) -> ExtensionManifest:
|
||||||
"""Decode a mapping while rejecting misspelled or ambiguous fields."""
|
"""Decode a manifest and reject unknown or invalid fields."""
|
||||||
mapping = _mapping(data, "extension manifest")
|
|
||||||
_reject_unknown(mapping, _MANIFEST_KEYS, "extension manifest")
|
|
||||||
try:
|
try:
|
||||||
contributions = tuple(
|
return ExtensionManifest.model_validate(data)
|
||||||
_contribution_from_mapping(item)
|
except ValidationError as exc:
|
||||||
for item in _sequence(mapping.get("contributions", ()), "contributions")
|
unknown = sorted(
|
||||||
|
".".join(str(part) for part in error["loc"])
|
||||||
|
for error in exc.errors()
|
||||||
|
if error["type"] == "extra_forbidden"
|
||||||
)
|
)
|
||||||
dependencies = tuple(
|
if unknown:
|
||||||
_dependency_from_mapping(item)
|
raise ManifestFormatError(
|
||||||
for item in _sequence(mapping.get("dependencies", ()), "dependencies")
|
f"extension manifest has unknown fields: {', '.join(unknown)}"
|
||||||
)
|
) from exc
|
||||||
permissions = tuple(
|
|
||||||
_permission_from_mapping(item)
|
|
||||||
for item in _sequence(mapping.get("permissions", ()), "permissions")
|
|
||||||
)
|
|
||||||
return ExtensionManifest(
|
|
||||||
id=mapping["id"],
|
|
||||||
name=mapping["name"],
|
|
||||||
version=mapping["version"],
|
|
||||||
runtime=ExtensionRuntime(mapping["runtime"]),
|
|
||||||
entry=mapping.get("entry", ""),
|
|
||||||
entries=tuple(_sequence(mapping.get("entries", ()), "entries")),
|
|
||||||
contributions=contributions,
|
|
||||||
description=mapping.get("description", ""),
|
|
||||||
dependencies=dependencies,
|
|
||||||
permissions=permissions,
|
|
||||||
api_version=mapping.get("apiVersion", EXTENSION_API_VERSION),
|
|
||||||
homepage=mapping.get("homepage", ""),
|
|
||||||
license=mapping.get("license", ""),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise ManifestFormatError(f"invalid extension manifest: {exc}") from exc
|
raise ManifestFormatError(f"invalid extension manifest: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
|
def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
|
||||||
"""Return the stable wire representation used by sidecars and catalogs."""
|
"""Return the canonical JSON representation."""
|
||||||
return {
|
return manifest.model_dump(mode="json", by_alias=True)
|
||||||
"id": manifest.id,
|
|
||||||
"name": manifest.name,
|
|
||||||
"version": manifest.version,
|
|
||||||
"apiVersion": manifest.api_version,
|
|
||||||
"runtime": manifest.runtime.value,
|
|
||||||
"entry": manifest.entry,
|
|
||||||
"entries": list(manifest.entries),
|
|
||||||
"description": manifest.description,
|
|
||||||
"homepage": manifest.homepage,
|
|
||||||
"license": manifest.license,
|
|
||||||
"contributions": [
|
|
||||||
{
|
|
||||||
"kind": item.kind.value,
|
|
||||||
"name": item.name,
|
|
||||||
"target": item.target,
|
|
||||||
"description": item.description,
|
|
||||||
}
|
|
||||||
for item in manifest.contributions
|
|
||||||
],
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"kind": item.kind.value,
|
|
||||||
"name": item.name,
|
|
||||||
"specifier": item.specifier,
|
|
||||||
"optional": item.optional,
|
|
||||||
}
|
|
||||||
for item in manifest.dependencies
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
{"name": item.name, "reason": item.reason}
|
|
||||||
for item in manifest.permissions
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _contribution_from_mapping(data: object) -> ExtensionContribution:
|
|
||||||
mapping = _mapping(data, "extension contribution")
|
|
||||||
_reject_unknown(mapping, _CONTRIBUTION_KEYS, "extension contribution")
|
|
||||||
try:
|
|
||||||
return ExtensionContribution(
|
|
||||||
kind=ContributionKind(mapping["kind"]),
|
|
||||||
name=mapping["name"],
|
|
||||||
target=mapping.get("target", ""),
|
|
||||||
description=mapping.get("description", ""),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise ManifestFormatError(f"invalid extension contribution: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _dependency_from_mapping(data: object) -> ExtensionDependency:
|
|
||||||
mapping = _mapping(data, "extension dependency")
|
|
||||||
_reject_unknown(mapping, _DEPENDENCY_KEYS, "extension dependency")
|
|
||||||
try:
|
|
||||||
return ExtensionDependency(
|
|
||||||
kind=DependencyKind(mapping["kind"]),
|
|
||||||
name=mapping["name"],
|
|
||||||
specifier=mapping.get("specifier", ""),
|
|
||||||
optional=mapping.get("optional", False),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise ManifestFormatError(f"invalid extension dependency: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _permission_from_mapping(data: object) -> ExtensionPermission:
|
|
||||||
mapping = _mapping(data, "extension permission")
|
|
||||||
_reject_unknown(mapping, _PERMISSION_KEYS, "extension permission")
|
|
||||||
try:
|
|
||||||
return ExtensionPermission(
|
|
||||||
name=mapping["name"],
|
|
||||||
reason=mapping.get("reason", ""),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise ManifestFormatError(f"invalid extension permission: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _mapping(data: object, label: str) -> Mapping[str, Any]:
|
|
||||||
if not isinstance(data, Mapping) or not all(
|
|
||||||
isinstance(key, str) for key in data
|
|
||||||
):
|
|
||||||
raise ManifestFormatError(f"{label} must be a JSON object")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _sequence(data: object, label: str) -> list[Any] | tuple[Any, ...]:
|
|
||||||
if not isinstance(data, (list, tuple)):
|
|
||||||
raise ManifestFormatError(f"{label} must be a JSON array")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_unknown(
|
|
||||||
mapping: Mapping[str, Any],
|
|
||||||
allowed: frozenset[str],
|
|
||||||
label: str,
|
|
||||||
) -> None:
|
|
||||||
unknown = sorted(set(mapping) - allowed)
|
|
||||||
if unknown:
|
|
||||||
raise ManifestFormatError(f"{label} has unknown fields: {', '.join(unknown)}")
|
|
||||||
|
|||||||
@ -1,221 +0,0 @@
|
|||||||
"""Adapters that project Pi/OpenClaw registrations into native nanobot APIs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, is_dataclass
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from nanobot.agent.hook import (
|
|
||||||
AgentHook,
|
|
||||||
AgentHookContext,
|
|
||||||
AgentRunHookContext,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
from nanobot.command.router import CommandContext, CommandRouter
|
|
||||||
from nanobot.extensions.node_host import NodeSidecar
|
|
||||||
from nanobot.extensions.protocol import NodeLoadResult, NodeRegistration
|
|
||||||
|
|
||||||
_EVENT_MAP = {
|
|
||||||
"pi": {
|
|
||||||
"before_run": "agent_start",
|
|
||||||
"after_run": "agent_end",
|
|
||||||
"before_execute_tool": "tool_call",
|
|
||||||
"after_execute_tool": "tool_result",
|
|
||||||
},
|
|
||||||
"openclaw": {
|
|
||||||
"before_run": "before_agent_run",
|
|
||||||
"after_run": "agent_end",
|
|
||||||
"before_execute_tool": "before_tool_call",
|
|
||||||
"after_execute_tool": "after_tool_call",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteTool(Tool):
|
|
||||||
"""Native Tool facade whose implementation remains inside a sidecar."""
|
|
||||||
|
|
||||||
def __init__(self, host: NodeSidecar, registration: NodeRegistration) -> None:
|
|
||||||
self._host = host
|
|
||||||
self._registration = registration
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._registration.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return self._registration.description
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
schema = self._registration.schema or {}
|
|
||||||
return {"type": "object", "properties": {}, **schema}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def read_only(self) -> bool:
|
|
||||||
return bool((self._registration.metadata or {}).get("readOnly"))
|
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> Any:
|
|
||||||
result = await self._host.request(
|
|
||||||
"extension.call",
|
|
||||||
{
|
|
||||||
"kind": "tool",
|
|
||||||
"name": self.name,
|
|
||||||
"callId": uuid4().hex,
|
|
||||||
"input": kwargs,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return result.get("text", "")
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteHook(AgentHook):
|
|
||||||
"""Observation-only lifecycle bridge for compatible extension events."""
|
|
||||||
|
|
||||||
def __init__(self, host: NodeSidecar, runtime: str, events: set[str]) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._host = host
|
|
||||||
self._events = events
|
|
||||||
self._mapping = _EVENT_MAP[runtime]
|
|
||||||
|
|
||||||
async def _emit(self, lifecycle: str, context: object, **extra: Any) -> None:
|
|
||||||
event = self._mapping[lifecycle]
|
|
||||||
if event not in self._events:
|
|
||||||
return
|
|
||||||
payload = _jsonable(context)
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
payload.update(extra)
|
|
||||||
await self._host.request(
|
|
||||||
"extension.event",
|
|
||||||
{"name": event, "event": payload},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def before_run(self, context: AgentRunHookContext) -> None:
|
|
||||||
await self._emit("before_run", context)
|
|
||||||
|
|
||||||
async def after_run(self, context: AgentRunHookContext) -> None:
|
|
||||||
await self._emit("after_run", context)
|
|
||||||
|
|
||||||
async def before_execute_tool(
|
|
||||||
self,
|
|
||||||
context: AgentHookContext,
|
|
||||||
tool_call: Any,
|
|
||||||
tool: Any,
|
|
||||||
params: Any,
|
|
||||||
) -> None:
|
|
||||||
await self._emit(
|
|
||||||
"before_execute_tool",
|
|
||||||
context,
|
|
||||||
toolCall=_jsonable(tool_call),
|
|
||||||
tool=getattr(tool, "name", ""),
|
|
||||||
input=_jsonable(params),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def after_execute_tool(
|
|
||||||
self,
|
|
||||||
context: AgentHookContext,
|
|
||||||
tool_call: Any,
|
|
||||||
tool: Any,
|
|
||||||
params: Any,
|
|
||||||
result: Any,
|
|
||||||
) -> None:
|
|
||||||
await self._emit(
|
|
||||||
"after_execute_tool",
|
|
||||||
context,
|
|
||||||
toolCall=_jsonable(tool_call),
|
|
||||||
tool=getattr(tool, "name", ""),
|
|
||||||
input=_jsonable(params),
|
|
||||||
result=_jsonable(result),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CompatibleExtension:
|
|
||||||
"""Loaded Pi/OpenClaw extension and its native projections."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
host: NodeSidecar,
|
|
||||||
runtime: str,
|
|
||||||
owner: str,
|
|
||||||
result: NodeLoadResult,
|
|
||||||
) -> None:
|
|
||||||
self.host = host
|
|
||||||
self.runtime = runtime
|
|
||||||
self.owner = owner
|
|
||||||
self.result = result
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tools(self) -> tuple[RemoteTool, ...]:
|
|
||||||
return tuple(
|
|
||||||
RemoteTool(self.host, item)
|
|
||||||
for item in self.result.registrations
|
|
||||||
if item.kind == "tool"
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hook(self) -> RemoteHook | None:
|
|
||||||
events = {
|
|
||||||
item.name
|
|
||||||
for item in self.result.registrations
|
|
||||||
if item.kind == "hook"
|
|
||||||
}
|
|
||||||
return RemoteHook(self.host, self.runtime, events) if events else None
|
|
||||||
|
|
||||||
def register_commands(self, router: CommandRouter) -> None:
|
|
||||||
for item in self.result.registrations:
|
|
||||||
if item.kind != "command":
|
|
||||||
continue
|
|
||||||
command = f"/{item.name}"
|
|
||||||
for tier, value in (("exact", command), ("prefix", f"{command} ")):
|
|
||||||
existing = router.owner(tier, value)
|
|
||||||
if existing and existing != self.owner:
|
|
||||||
raise ValueError(
|
|
||||||
f"command '{command}' is already registered by '{existing}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handler(ctx: CommandContext, name: str = item.name) -> OutboundMessage | None:
|
|
||||||
result = await self.host.request(
|
|
||||||
"extension.call",
|
|
||||||
{
|
|
||||||
"kind": "command",
|
|
||||||
"name": name,
|
|
||||||
"input": {
|
|
||||||
"args": ctx.args,
|
|
||||||
"raw": ctx.raw,
|
|
||||||
"channel": ctx.msg.channel,
|
|
||||||
"chatId": ctx.msg.chat_id,
|
|
||||||
"senderId": ctx.msg.sender_id,
|
|
||||||
"sessionKey": ctx.key,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
text = str(result.get("text") or "")
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
return OutboundMessage(
|
|
||||||
channel=ctx.msg.channel,
|
|
||||||
chat_id=ctx.msg.chat_id,
|
|
||||||
content=text,
|
|
||||||
)
|
|
||||||
|
|
||||||
router.exact(command, handler, owner=self.owner)
|
|
||||||
router.prefix(f"{command} ", handler, owner=self.owner)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
await self.host.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _jsonable(value: Any) -> Any:
|
|
||||||
if is_dataclass(value):
|
|
||||||
return _jsonable(asdict(value))
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return [_jsonable(item) for item in value]
|
|
||||||
if isinstance(value, BaseException):
|
|
||||||
return {"type": type(value).__name__, "message": str(value)}
|
|
||||||
if value is None or isinstance(value, (str, int, float, bool)):
|
|
||||||
return value
|
|
||||||
return str(value)
|
|
||||||
@ -9,7 +9,6 @@ from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
|||||||
from nanobot.extensions.registry import (
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
ExtensionScope,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -21,10 +20,6 @@ class ExtensionDiscoveryResult:
|
|||||||
|
|
||||||
def discover_manifest_root(
|
def discover_manifest_root(
|
||||||
root: Path,
|
root: Path,
|
||||||
*,
|
|
||||||
scope: ExtensionScope,
|
|
||||||
trusted: bool = False,
|
|
||||||
enabled_ids: frozenset[str] = frozenset(),
|
|
||||||
) -> ExtensionDiscoveryResult:
|
) -> ExtensionDiscoveryResult:
|
||||||
"""Discover direct children containing ``nanobot.extension.json``."""
|
"""Discover direct children containing ``nanobot.extension.json``."""
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
@ -62,10 +57,7 @@ def discover_manifest_root(
|
|||||||
candidates.append(
|
candidates.append(
|
||||||
ExtensionCandidate(
|
ExtensionCandidate(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
scope=scope,
|
|
||||||
location=path.parent.resolve(),
|
location=path.parent.resolve(),
|
||||||
enabled=not enabled_ids or manifest.id in enabled_ids,
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@ -59,19 +59,11 @@ class ExtensionHost:
|
|||||||
config = self._config_loader()
|
config = self._config_loader()
|
||||||
catalog = build_extension_catalog(
|
catalog = build_extension_catalog(
|
||||||
config,
|
config,
|
||||||
skills=getattr(
|
|
||||||
getattr(self._agent, "context", None),
|
|
||||||
"skills",
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
tools=self._agent.tools,
|
|
||||||
commands=self._agent.commands,
|
|
||||||
user_root=self._user_root,
|
user_root=self._user_root,
|
||||||
)
|
)
|
||||||
manager = ExtensionRuntimeManager(
|
manager = ExtensionRuntimeManager(
|
||||||
tools=self._agent.tools,
|
tools=self._agent.tools,
|
||||||
commands=self._agent.commands,
|
commands=self._agent.commands,
|
||||||
config=config,
|
|
||||||
hook_factories=self._agent._hook_factories,
|
hook_factories=self._agent._hook_factories,
|
||||||
)
|
)
|
||||||
activation = await manager.activate(catalog.snapshot)
|
activation = await manager.activate(catalog.snapshot)
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
"""Dependency-free metadata shared by every nanobot extension format."""
|
"""Strict schema for native nanobot extension packages."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Literal, Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
EXTENSION_API_VERSION = 1
|
EXTENSION_API_VERSION = 1
|
||||||
|
|
||||||
@ -13,43 +15,19 @@ _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]+)*")
|
_PERMISSION = re.compile(r"[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*")
|
||||||
|
|
||||||
|
|
||||||
class ExtensionRuntime(str, Enum):
|
|
||||||
"""Runtime used to activate an extension package."""
|
|
||||||
|
|
||||||
PYTHON = "python"
|
|
||||||
PI = "pi"
|
|
||||||
OPENCLAW = "openclaw"
|
|
||||||
DECLARATIVE = "declarative"
|
|
||||||
|
|
||||||
|
|
||||||
class ContributionKind(str, Enum):
|
|
||||||
"""Native capability slots an extension may contribute to."""
|
|
||||||
|
|
||||||
TOOL = "tool"
|
|
||||||
SKILL = "skill"
|
|
||||||
CHANNEL = "channel"
|
|
||||||
LLM_PROVIDER = "llm_provider"
|
|
||||||
TRANSCRIPTION_PROVIDER = "transcription_provider"
|
|
||||||
IMAGE_GENERATION_PROVIDER = "image_generation_provider"
|
|
||||||
WEB_SEARCH_PROVIDER = "web_search_provider"
|
|
||||||
MCP_SERVER = "mcp_server"
|
|
||||||
HOOK = "hook"
|
|
||||||
COMMAND = "command"
|
|
||||||
WEBUI = "webui"
|
|
||||||
|
|
||||||
|
|
||||||
class DependencyKind(str, Enum):
|
class DependencyKind(str, Enum):
|
||||||
"""Kinds of prerequisites resolved before activation."""
|
"""Kinds of prerequisites resolved before activation."""
|
||||||
|
|
||||||
PYTHON = "python"
|
PYTHON = "python"
|
||||||
NPM = "npm"
|
|
||||||
EXECUTABLE = "executable"
|
EXECUTABLE = "executable"
|
||||||
ENVIRONMENT = "environment"
|
ENVIRONMENT = "environment"
|
||||||
EXTENSION = "extension"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
class _ManifestModel(BaseModel):
|
||||||
class ExtensionDependency:
|
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionDependency(_ManifestModel):
|
||||||
"""One activation prerequisite declared by an extension."""
|
"""One activation prerequisite declared by an extension."""
|
||||||
|
|
||||||
kind: DependencyKind
|
kind: DependencyKind
|
||||||
@ -57,162 +35,92 @@ class ExtensionDependency:
|
|||||||
specifier: str = ""
|
specifier: str = ""
|
||||||
optional: bool = False
|
optional: bool = False
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
@field_validator("name")
|
||||||
if not isinstance(self.kind, DependencyKind):
|
@classmethod
|
||||||
raise TypeError("extension dependency kind must be a DependencyKind")
|
def validate_name(cls, value: str) -> str:
|
||||||
_require_text(self.name, "extension dependency name")
|
return _require_text(value, "extension dependency name")
|
||||||
if self.kind is DependencyKind.EXTENSION:
|
|
||||||
_require_identifier(self.name, "extension dependency name")
|
|
||||||
if not isinstance(self.specifier, str):
|
|
||||||
raise TypeError("extension dependency specifier must be a string")
|
|
||||||
if not isinstance(self.optional, bool):
|
|
||||||
raise TypeError("extension dependency optional must be a boolean")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
class ExtensionPermission(_ManifestModel):
|
||||||
class ExtensionPermission:
|
|
||||||
"""A privileged host capability requested by an extension."""
|
"""A privileged host capability requested by an extension."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
@field_validator("name")
|
||||||
if not isinstance(self.name, str) or _PERMISSION.fullmatch(self.name) is None:
|
@classmethod
|
||||||
|
def validate_name(cls, value: str) -> str:
|
||||||
|
if _PERMISSION.fullmatch(value) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"extension permission must be a lowercase namespaced identifier"
|
"extension permission must be a lowercase namespaced identifier"
|
||||||
)
|
)
|
||||||
if not isinstance(self.reason, str):
|
return value
|
||||||
raise TypeError("extension permission reason must be a string")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
class ExtensionManifest(_ManifestModel):
|
||||||
class ExtensionContribution:
|
"""Identity, prerequisites, and consent declarations for one extension."""
|
||||||
"""A contribution projected into one existing nanobot registry."""
|
|
||||||
|
|
||||||
kind: ContributionKind
|
|
||||||
name: str
|
|
||||||
target: str = ""
|
|
||||||
description: str = ""
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not isinstance(self.kind, ContributionKind):
|
|
||||||
raise TypeError("extension contribution kind must be a ContributionKind")
|
|
||||||
_require_identifier(self.name, "extension contribution name")
|
|
||||||
if not isinstance(self.target, str):
|
|
||||||
raise TypeError("extension contribution target must be a string")
|
|
||||||
if not isinstance(self.description, str):
|
|
||||||
raise TypeError("extension contribution description must be a string")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionManifest:
|
|
||||||
"""Portable identity and capability declaration for one extension."""
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
version: str
|
version: str
|
||||||
runtime: ExtensionRuntime
|
entry: str = "extension:register"
|
||||||
entry: str = ""
|
|
||||||
entries: tuple[str, ...] = ()
|
|
||||||
contributions: tuple[ExtensionContribution, ...] = ()
|
|
||||||
description: str = ""
|
description: str = ""
|
||||||
dependencies: tuple[ExtensionDependency, ...] = ()
|
dependencies: tuple[ExtensionDependency, ...] = ()
|
||||||
permissions: tuple[ExtensionPermission, ...] = ()
|
permissions: tuple[ExtensionPermission, ...] = ()
|
||||||
api_version: int = EXTENSION_API_VERSION
|
api_version: Literal[EXTENSION_API_VERSION] = Field(
|
||||||
|
default=EXTENSION_API_VERSION,
|
||||||
|
alias="apiVersion",
|
||||||
|
)
|
||||||
homepage: str = ""
|
homepage: str = ""
|
||||||
license: str = ""
|
license: str = ""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
@field_validator("id")
|
||||||
_require_identifier(self.id, "extension id")
|
@classmethod
|
||||||
_require_text(self.name, "extension name")
|
def validate_id(cls, value: str) -> str:
|
||||||
_require_text(self.version, "extension version")
|
return _require_identifier(value, "extension id")
|
||||||
if not isinstance(self.runtime, ExtensionRuntime):
|
|
||||||
raise TypeError("extension runtime must be an ExtensionRuntime")
|
|
||||||
if not isinstance(self.entry, str):
|
|
||||||
raise TypeError("extension entry must be a string")
|
|
||||||
if not isinstance(self.entries, tuple) or not all(
|
|
||||||
isinstance(entry, str) and entry for entry in self.entries
|
|
||||||
):
|
|
||||||
raise TypeError("extension entries must be a tuple of non-empty strings")
|
|
||||||
activation_entries = self.activation_entries
|
|
||||||
if len(set(activation_entries)) != len(activation_entries):
|
|
||||||
raise ValueError("extension entries contains duplicates")
|
|
||||||
for entry in activation_entries:
|
|
||||||
if Path(entry).is_absolute():
|
|
||||||
raise ValueError("extension entry must be relative to the package root")
|
|
||||||
if ".." in Path(entry).parts:
|
|
||||||
raise ValueError("extension entry cannot escape the package root")
|
|
||||||
if self.api_version != EXTENSION_API_VERSION:
|
|
||||||
raise ValueError(
|
|
||||||
f"unsupported extension API version {self.api_version}; "
|
|
||||||
f"expected {EXTENSION_API_VERSION}"
|
|
||||||
)
|
|
||||||
_require_tuple_of(
|
|
||||||
self.contributions,
|
|
||||||
ExtensionContribution,
|
|
||||||
"extension contributions",
|
|
||||||
)
|
|
||||||
_require_tuple_of(
|
|
||||||
self.dependencies,
|
|
||||||
ExtensionDependency,
|
|
||||||
"extension dependencies",
|
|
||||||
)
|
|
||||||
_require_tuple_of(
|
|
||||||
self.permissions,
|
|
||||||
ExtensionPermission,
|
|
||||||
"extension permissions",
|
|
||||||
)
|
|
||||||
for value, label in (
|
|
||||||
(self.description, "extension description"),
|
|
||||||
(self.homepage, "extension homepage"),
|
|
||||||
(self.license, "extension license"),
|
|
||||||
):
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise TypeError(f"{label} must be a string")
|
|
||||||
|
|
||||||
contribution_keys = [
|
@field_validator("name", "version")
|
||||||
(contribution.kind, contribution.name)
|
@classmethod
|
||||||
for contribution in self.contributions
|
def validate_required_text(cls, value: str, info) -> str:
|
||||||
]
|
return _require_text(value, f"extension {info.field_name}")
|
||||||
if len(set(contribution_keys)) != len(contribution_keys):
|
|
||||||
raise ValueError("extension manifest contains duplicate contributions")
|
@field_validator("entry")
|
||||||
dependency_keys = [
|
@classmethod
|
||||||
(dependency.kind, dependency.name) for dependency in self.dependencies
|
def validate_entry(cls, value: str) -> str:
|
||||||
]
|
value = _require_text(value, "extension entry")
|
||||||
if len(set(dependency_keys)) != len(dependency_keys):
|
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")
|
raise ValueError("extension manifest contains duplicate dependencies")
|
||||||
permission_names = [permission.name for permission in self.permissions]
|
permissions = [item.name for item in self.permissions]
|
||||||
if len(set(permission_names)) != len(permission_names):
|
if len(set(permissions)) != len(permissions):
|
||||||
raise ValueError("extension manifest contains duplicate permissions")
|
raise ValueError("extension manifest contains duplicate permissions")
|
||||||
|
return self
|
||||||
@property
|
|
||||||
def activation_entries(self) -> tuple[str, ...]:
|
|
||||||
"""Return every runtime entry while preserving the v1 single-entry form."""
|
|
||||||
return self.entries or ((self.entry,) if self.entry else ())
|
|
||||||
|
|
||||||
|
|
||||||
def _require_text(value: object, label: str) -> str:
|
def _require_text(value: str, label: str) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not value.strip():
|
||||||
raise ValueError(f"{label} must be a non-empty string")
|
raise ValueError(f"{label} must be a non-empty string")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _require_identifier(value: object, label: str) -> str:
|
def _require_identifier(value: str, label: str) -> str:
|
||||||
text = _require_text(value, label)
|
value = _require_text(value, label)
|
||||||
if _IDENTIFIER.fullmatch(text) is None:
|
if _IDENTIFIER.fullmatch(value) is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{label} must use lowercase letters, digits, dots, underscores, or hyphens"
|
f"{label} must use lowercase letters, digits, dots, underscores, or hyphens"
|
||||||
)
|
)
|
||||||
return text
|
return value
|
||||||
|
|
||||||
|
|
||||||
def validate_extension_id(value: object) -> str:
|
def validate_extension_id(value: object) -> str:
|
||||||
"""Validate and return one portable extension identifier."""
|
"""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")
|
return _require_identifier(value, "extension id")
|
||||||
|
|
||||||
|
|
||||||
def _require_tuple_of(value: object, item_type: type, label: str) -> None:
|
|
||||||
if not isinstance(value, tuple) or not all(
|
|
||||||
isinstance(item, item_type) for item in value
|
|
||||||
):
|
|
||||||
raise TypeError(f"{label} must be a tuple of {item_type.__name__}")
|
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
"""Package discovery for nanobot, Pi, and OpenClaw extension ecosystems."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_SEARCHES = {
|
|
||||||
"nanobot": "keywords:nanobot-extension",
|
|
||||||
"pi": "keywords:pi-package",
|
|
||||||
"openclaw": "keywords:openclaw-plugin",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MarketplacePackage:
|
|
||||||
"""One untrusted package candidate returned by a public package index."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
version: str
|
|
||||||
description: str
|
|
||||||
ecosystem: str
|
|
||||||
publisher: str = ""
|
|
||||||
license: str = ""
|
|
||||||
homepage: str = ""
|
|
||||||
repository: str = ""
|
|
||||||
published_at: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionMarketplace:
|
|
||||||
"""Search npm without making package installation an implicit trust action."""
|
|
||||||
|
|
||||||
def search(
|
|
||||||
self,
|
|
||||||
query: str = "",
|
|
||||||
*,
|
|
||||||
ecosystem: str = "all",
|
|
||||||
limit: int = 30,
|
|
||||||
) -> tuple[MarketplacePackage, ...]:
|
|
||||||
if ecosystem != "all" and ecosystem not in _SEARCHES:
|
|
||||||
raise ValueError(f"unknown extension ecosystem: {ecosystem}")
|
|
||||||
if not 1 <= limit <= 100:
|
|
||||||
raise ValueError("market search limit must be between 1 and 100")
|
|
||||||
ecosystems = _SEARCHES if ecosystem == "all" else {ecosystem: _SEARCHES[ecosystem]}
|
|
||||||
found: dict[str, MarketplacePackage] = {}
|
|
||||||
for name, keyword in ecosystems.items():
|
|
||||||
terms = " ".join(part for part in (keyword, query.strip()) if part)
|
|
||||||
for row in _npm_search(terms, limit=limit):
|
|
||||||
required_keyword = keyword.partition(":")[2]
|
|
||||||
keywords = row.get("keywords")
|
|
||||||
if not isinstance(keywords, list) or required_keyword not in keywords:
|
|
||||||
continue
|
|
||||||
package = _marketplace_package(row, ecosystem=name)
|
|
||||||
if not package.name or not package.version:
|
|
||||||
continue
|
|
||||||
found.setdefault(package.name, package)
|
|
||||||
return tuple(
|
|
||||||
sorted(found.values(), key=lambda item: (item.ecosystem, item.name))[:limit]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _npm_search(query: str, *, limit: int) -> list[dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["npm", "search", "--json", f"--searchlimit={limit}", "--", query],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise RuntimeError("npm is required to search the extension marketplace") from exc
|
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise RuntimeError("extension marketplace search timed out") from exc
|
|
||||||
except subprocess.CalledProcessError as exc:
|
|
||||||
message = (exc.stderr or exc.stdout).strip()
|
|
||||||
raise RuntimeError(message or "extension marketplace search failed") from exc
|
|
||||||
try:
|
|
||||||
value = json.loads(result.stdout)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise RuntimeError("npm returned an invalid marketplace response") from exc
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise RuntimeError("npm returned an invalid marketplace response")
|
|
||||||
return [row for row in value if isinstance(row, dict)]
|
|
||||||
|
|
||||||
|
|
||||||
def _marketplace_package(row: dict[str, Any], *, ecosystem: str) -> MarketplacePackage:
|
|
||||||
publisher = row.get("publisher")
|
|
||||||
publisher_name = publisher.get("username", "") if isinstance(publisher, dict) else ""
|
|
||||||
links = row.get("links")
|
|
||||||
links = links if isinstance(links, dict) else {}
|
|
||||||
return MarketplacePackage(
|
|
||||||
name=str(row.get("name") or ""),
|
|
||||||
version=str(row.get("version") or ""),
|
|
||||||
description=str(row.get("description") or ""),
|
|
||||||
ecosystem=ecosystem,
|
|
||||||
publisher=str(publisher_name),
|
|
||||||
license=str(row.get("license") or ""),
|
|
||||||
homepage=str(links.get("homepage") or ""),
|
|
||||||
repository=str(links.get("repository") or ""),
|
|
||||||
published_at=str(row.get("date") or ""),
|
|
||||||
)
|
|
||||||
@ -1,340 +0,0 @@
|
|||||||
"""Project existing nanobot registries into the extension control plane."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Iterable
|
|
||||||
|
|
||||||
from packaging.requirements import Requirement
|
|
||||||
|
|
||||||
from nanobot import __version__
|
|
||||||
from nanobot.extensions.discovery import ExtensionDiscoveryResult
|
|
||||||
from nanobot.extensions.manifest import (
|
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionRuntime,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.registry import ExtensionCandidate, ExtensionScope
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.agent.skills import SkillsLoader
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.command.router import CommandRouter
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
_NON_ID_CHARACTER = re.compile(r"[^a-z0-9._-]+")
|
|
||||||
|
|
||||||
|
|
||||||
def discover_native_extensions(
|
|
||||||
config: Config,
|
|
||||||
*,
|
|
||||||
skills: SkillsLoader | None = None,
|
|
||||||
tools: ToolRegistry | None = None,
|
|
||||||
commands: CommandRouter | None = None,
|
|
||||||
) -> ExtensionDiscoveryResult:
|
|
||||||
"""Return built-in, workspace, and configured capabilities as extensions."""
|
|
||||||
candidates = [
|
|
||||||
*_channel_candidates(),
|
|
||||||
*_provider_candidates(),
|
|
||||||
*_transcription_candidates(),
|
|
||||||
*_image_generation_candidates(),
|
|
||||||
*_mcp_candidates(config),
|
|
||||||
]
|
|
||||||
if skills is not None:
|
|
||||||
candidates.extend(_skill_candidates(skills))
|
|
||||||
if tools is not None:
|
|
||||||
candidates.extend(_tool_candidates(tools))
|
|
||||||
if commands is not None:
|
|
||||||
candidates.extend(_command_candidates(commands))
|
|
||||||
return ExtensionDiscoveryResult(candidates=_merge_candidates(candidates))
|
|
||||||
|
|
||||||
|
|
||||||
def _channel_candidates() -> list[ExtensionCandidate]:
|
|
||||||
from nanobot.channels.registry import discover_plugins
|
|
||||||
|
|
||||||
candidates = []
|
|
||||||
for name, plugin in sorted(discover_plugins().items()):
|
|
||||||
contributions = [
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.CHANNEL,
|
|
||||||
name=name,
|
|
||||||
target=plugin.runtime,
|
|
||||||
description=plugin.display_name,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if plugin.webui:
|
|
||||||
contributions.append(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.WEBUI,
|
|
||||||
name=f"channel-{name}",
|
|
||||||
target=plugin.webui,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
candidates.append(
|
|
||||||
_candidate(
|
|
||||||
f"nanobot.channel.{name}",
|
|
||||||
plugin.display_name,
|
|
||||||
contributions,
|
|
||||||
dependencies=_python_dependencies(plugin.dependencies),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def _provider_candidates() -> list[ExtensionCandidate]:
|
|
||||||
from nanobot.providers.registry import PROVIDERS
|
|
||||||
|
|
||||||
candidates = []
|
|
||||||
for spec in PROVIDERS:
|
|
||||||
if spec.settings_alias_for or spec.is_transcription_only:
|
|
||||||
continue
|
|
||||||
candidates.append(
|
|
||||||
_candidate(
|
|
||||||
f"nanobot.provider.{spec.name}",
|
|
||||||
spec.label,
|
|
||||||
(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.LLM_PROVIDER,
|
|
||||||
name=spec.name,
|
|
||||||
target=spec.backend,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def _transcription_candidates() -> list[ExtensionCandidate]:
|
|
||||||
from nanobot.audio.transcription_registry import TRANSCRIPTION_PROVIDERS
|
|
||||||
|
|
||||||
return [
|
|
||||||
_candidate(
|
|
||||||
f"nanobot.transcription.{spec.name}",
|
|
||||||
f"{spec.name} transcription",
|
|
||||||
(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.TRANSCRIPTION_PROVIDER,
|
|
||||||
name=spec.name,
|
|
||||||
target=spec.adapter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for spec in TRANSCRIPTION_PROVIDERS
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _image_generation_candidates() -> list[ExtensionCandidate]:
|
|
||||||
from nanobot.providers.image_generation import image_gen_provider_names
|
|
||||||
|
|
||||||
return [
|
|
||||||
_candidate(
|
|
||||||
f"nanobot.image-generation.{name}",
|
|
||||||
f"{name} image generation",
|
|
||||||
(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.IMAGE_GENERATION_PROVIDER,
|
|
||||||
name=name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for name in image_gen_provider_names()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _mcp_candidates(config: Config) -> list[ExtensionCandidate]:
|
|
||||||
return [
|
|
||||||
_candidate(
|
|
||||||
f"nanobot.mcp.{_identifier(name)}",
|
|
||||||
name,
|
|
||||||
(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.MCP_SERVER,
|
|
||||||
name=_identifier(name),
|
|
||||||
target=name,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
)
|
|
||||||
for name in sorted(config.tools.mcp_servers)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _skill_candidates(skills: SkillsLoader) -> list[ExtensionCandidate]:
|
|
||||||
candidates = []
|
|
||||||
for entry in skills.list_skills(filter_unavailable=False):
|
|
||||||
name = entry["name"]
|
|
||||||
metadata = skills.get_skill_metadata(name) or {}
|
|
||||||
requirements = skills.get_skill_requirements(name)
|
|
||||||
dependencies = tuple(
|
|
||||||
ExtensionDependency(DependencyKind.EXECUTABLE, value)
|
|
||||||
for value in requirements["bins"]
|
|
||||||
) + tuple(
|
|
||||||
ExtensionDependency(DependencyKind.ENVIRONMENT, value)
|
|
||||||
for value in requirements["env"]
|
|
||||||
)
|
|
||||||
scope = (
|
|
||||||
ExtensionScope.WORKSPACE
|
|
||||||
if entry["source"] == "workspace"
|
|
||||||
else ExtensionScope.BUILTIN
|
|
||||||
)
|
|
||||||
candidates.append(
|
|
||||||
_candidate(
|
|
||||||
f"nanobot.skill.{_identifier(name)}",
|
|
||||||
name,
|
|
||||||
(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.SKILL,
|
|
||||||
name=_identifier(name),
|
|
||||||
target=entry["path"],
|
|
||||||
description=str(metadata.get("description") or name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
dependencies=dependencies,
|
|
||||||
scope=scope,
|
|
||||||
location=Path(entry["path"]).parent,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_candidates(tools: ToolRegistry) -> list[ExtensionCandidate]:
|
|
||||||
grouped: dict[str, list[ExtensionContribution]] = defaultdict(list)
|
|
||||||
for name in tools.tool_names:
|
|
||||||
owner = tools.owner(name) or "nanobot.core"
|
|
||||||
tool = tools.get(name)
|
|
||||||
grouped[owner].append(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.TOOL,
|
|
||||||
name=_identifier(name),
|
|
||||||
description=tool.description if tool is not None else "",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
_candidate(
|
|
||||||
owner,
|
|
||||||
owner,
|
|
||||||
contributions,
|
|
||||||
scope=(
|
|
||||||
ExtensionScope.USER
|
|
||||||
if owner.startswith(("legacy.", "nanobot.mcp."))
|
|
||||||
else ExtensionScope.BUILTIN
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for owner, contributions in sorted(grouped.items())
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _command_candidates(commands: CommandRouter) -> list[ExtensionCandidate]:
|
|
||||||
grouped: dict[str, dict[str, ExtensionContribution]] = defaultdict(dict)
|
|
||||||
for _tier, command, owner in commands.registrations():
|
|
||||||
name = _identifier(command.lstrip("/").rstrip())
|
|
||||||
grouped[owner].setdefault(
|
|
||||||
name,
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.COMMAND,
|
|
||||||
name=name,
|
|
||||||
target=command,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
_candidate(owner, owner, contributions.values())
|
|
||||||
for owner, contributions in sorted(grouped.items())
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate(
|
|
||||||
extension_id: str,
|
|
||||||
name: str,
|
|
||||||
contributions: Iterable[ExtensionContribution],
|
|
||||||
*,
|
|
||||||
dependencies: tuple[ExtensionDependency, ...] = (),
|
|
||||||
scope: ExtensionScope = ExtensionScope.BUILTIN,
|
|
||||||
location: Path | None = None,
|
|
||||||
) -> ExtensionCandidate:
|
|
||||||
return ExtensionCandidate(
|
|
||||||
manifest=ExtensionManifest(
|
|
||||||
id=_identifier(extension_id),
|
|
||||||
name=name,
|
|
||||||
version=__version__,
|
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
contributions=tuple(contributions),
|
|
||||||
dependencies=dependencies,
|
|
||||||
),
|
|
||||||
scope=scope,
|
|
||||||
location=location,
|
|
||||||
enabled=True,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _python_dependencies(
|
|
||||||
requirements: tuple[str, ...],
|
|
||||||
) -> tuple[ExtensionDependency, ...]:
|
|
||||||
dependencies = []
|
|
||||||
for raw in requirements:
|
|
||||||
requirement = Requirement(raw)
|
|
||||||
if requirement.marker and not requirement.marker.evaluate():
|
|
||||||
continue
|
|
||||||
dependencies.append(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.PYTHON,
|
|
||||||
name=requirement.name,
|
|
||||||
specifier=str(requirement.specifier),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(dependencies)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_candidates(
|
|
||||||
candidates: Iterable[ExtensionCandidate],
|
|
||||||
) -> tuple[ExtensionCandidate, ...]:
|
|
||||||
merged: dict[tuple[str, ExtensionScope], ExtensionCandidate] = {}
|
|
||||||
for candidate in candidates:
|
|
||||||
key = (candidate.manifest.id, candidate.scope)
|
|
||||||
existing = merged.get(key)
|
|
||||||
if existing is None:
|
|
||||||
merged[key] = candidate
|
|
||||||
continue
|
|
||||||
manifest = existing.manifest
|
|
||||||
incoming = candidate.manifest
|
|
||||||
merged[key] = ExtensionCandidate(
|
|
||||||
manifest=ExtensionManifest(
|
|
||||||
id=manifest.id,
|
|
||||||
name=manifest.name,
|
|
||||||
version=manifest.version,
|
|
||||||
runtime=manifest.runtime,
|
|
||||||
contributions=manifest.contributions + incoming.contributions,
|
|
||||||
description=manifest.description or incoming.description,
|
|
||||||
dependencies=tuple(
|
|
||||||
dict.fromkeys(manifest.dependencies + incoming.dependencies)
|
|
||||||
),
|
|
||||||
permissions=tuple(
|
|
||||||
dict.fromkeys(manifest.permissions + incoming.permissions)
|
|
||||||
),
|
|
||||||
homepage=manifest.homepage or incoming.homepage,
|
|
||||||
license=manifest.license or incoming.license,
|
|
||||||
),
|
|
||||||
scope=existing.scope,
|
|
||||||
location=existing.location or candidate.location,
|
|
||||||
enabled=existing.enabled and candidate.enabled,
|
|
||||||
trusted=existing.trusted and candidate.trusted,
|
|
||||||
integrity_valid=(
|
|
||||||
existing.integrity_valid and candidate.integrity_valid
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return tuple(
|
|
||||||
sorted(
|
|
||||||
merged.values(),
|
|
||||||
key=lambda item: (item.scope, item.manifest.id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: str) -> str:
|
|
||||||
normalized = _NON_ID_CHARACTER.sub("-", value.strip().lower()).strip("._-")
|
|
||||||
return normalized or "unnamed"
|
|
||||||
@ -1,236 +0,0 @@
|
|||||||
"""Async process boundary for compatible JavaScript extension APIs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.extensions.protocol import (
|
|
||||||
NODE_PROTOCOL_VERSION,
|
|
||||||
NodeLoadResult,
|
|
||||||
NodeProtocolError,
|
|
||||||
)
|
|
||||||
|
|
||||||
_MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
class NodeSidecar:
|
|
||||||
"""One failure-isolated Node process hosting one trusted extension."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
node: str | None = None,
|
|
||||||
timeout: float = 30.0,
|
|
||||||
) -> None:
|
|
||||||
self._node = node or os.getenv("NANOBOT_NODE") or shutil.which("node")
|
|
||||||
self._timeout = timeout
|
|
||||||
self._process: asyncio.subprocess.Process | None = None
|
|
||||||
self._reader: asyncio.Task[None] | None = None
|
|
||||||
self._stderr_reader: asyncio.Task[None] | None = None
|
|
||||||
self._pending: dict[int, asyncio.Future[Any]] = {}
|
|
||||||
self._write_lock = asyncio.Lock()
|
|
||||||
self._next_id = 0
|
|
||||||
self.stderr: list[str] = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def running(self) -> bool:
|
|
||||||
return self._process is not None and self._process.returncode is None
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self.running:
|
|
||||||
return
|
|
||||||
if not self._node:
|
|
||||||
raise NodeProtocolError(
|
|
||||||
"Node.js is required for Pi and OpenClaw extensions; "
|
|
||||||
"install Node.js 20+ or set NANOBOT_NODE"
|
|
||||||
)
|
|
||||||
script = Path(__file__).with_name("node_sidecar.mjs")
|
|
||||||
self._process = await asyncio.create_subprocess_exec(
|
|
||||||
self._node,
|
|
||||||
str(script),
|
|
||||||
stdin=asyncio.subprocess.PIPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
limit=_MAX_MESSAGE_BYTES + 1,
|
|
||||||
)
|
|
||||||
self._reader = asyncio.create_task(self._read_stdout())
|
|
||||||
self._stderr_reader = asyncio.create_task(self._read_stderr())
|
|
||||||
try:
|
|
||||||
hello = await self.request("hello", {})
|
|
||||||
except Exception:
|
|
||||||
await self.close()
|
|
||||||
raise
|
|
||||||
if hello.get("protocol") != NODE_PROTOCOL_VERSION:
|
|
||||||
await self.close()
|
|
||||||
raise NodeProtocolError(
|
|
||||||
f"sidecar protocol mismatch: expected {NODE_PROTOCOL_VERSION}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def load(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
runtime: str,
|
|
||||||
entries: tuple[Path, ...],
|
|
||||||
root: Path,
|
|
||||||
extension_id: str,
|
|
||||||
name: str,
|
|
||||||
version: str,
|
|
||||||
config: dict[str, Any] | None = None,
|
|
||||||
workspace: Path | None = None,
|
|
||||||
) -> NodeLoadResult:
|
|
||||||
await self.start()
|
|
||||||
result = await self.request(
|
|
||||||
"extension.load",
|
|
||||||
{
|
|
||||||
"runtime": runtime,
|
|
||||||
"entries": [
|
|
||||||
str(entry.expanduser().resolve()) for entry in entries
|
|
||||||
],
|
|
||||||
"root": str(root.expanduser().resolve()),
|
|
||||||
"identity": {
|
|
||||||
"id": extension_id,
|
|
||||||
"name": name,
|
|
||||||
"version": version,
|
|
||||||
},
|
|
||||||
"config": config or {},
|
|
||||||
"workspace": str((workspace or Path.cwd()).resolve()),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return NodeLoadResult.from_mapping(result)
|
|
||||||
|
|
||||||
async def request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
params: dict[str, Any],
|
|
||||||
*,
|
|
||||||
timeout: float | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if method != "hello" and not self.running:
|
|
||||||
raise NodeProtocolError("sidecar is not running")
|
|
||||||
process = self._process
|
|
||||||
if process is None or process.stdin is None:
|
|
||||||
raise NodeProtocolError("sidecar failed to start")
|
|
||||||
|
|
||||||
self._next_id += 1
|
|
||||||
request_id = self._next_id
|
|
||||||
future = asyncio.get_running_loop().create_future()
|
|
||||||
self._pending[request_id] = future
|
|
||||||
message = json.dumps(
|
|
||||||
{
|
|
||||||
"protocol": NODE_PROTOCOL_VERSION,
|
|
||||||
"id": request_id,
|
|
||||||
"method": method,
|
|
||||||
"params": params,
|
|
||||||
},
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode()
|
|
||||||
if len(message) > _MAX_MESSAGE_BYTES:
|
|
||||||
self._pending.pop(request_id, None)
|
|
||||||
raise NodeProtocolError("sidecar request exceeds the 16 MB protocol limit")
|
|
||||||
try:
|
|
||||||
async with self._write_lock:
|
|
||||||
process.stdin.write(message + b"\n")
|
|
||||||
await process.stdin.drain()
|
|
||||||
result = await asyncio.wait_for(future, timeout or self._timeout)
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
self._pending.pop(request_id, None)
|
|
||||||
if process.returncode is None:
|
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
|
||||||
raise NodeProtocolError(
|
|
||||||
f"sidecar method {method!r} timed out"
|
|
||||||
) from exc
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
self._pending.pop(request_id, None)
|
|
||||||
future.cancel()
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
self._pending.pop(request_id, None)
|
|
||||||
raise
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
raise NodeProtocolError(f"sidecar method {method!r} returned a non-object result")
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
process = self._process
|
|
||||||
if process is None:
|
|
||||||
return
|
|
||||||
if process.returncode is None:
|
|
||||||
with suppress(Exception):
|
|
||||||
await self.request("shutdown", {}, timeout=2.0)
|
|
||||||
if process.returncode is None:
|
|
||||||
process.terminate()
|
|
||||||
with suppress(asyncio.TimeoutError):
|
|
||||||
await asyncio.wait_for(process.wait(), 2.0)
|
|
||||||
if process.returncode is None:
|
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
|
||||||
for task in (self._reader, self._stderr_reader):
|
|
||||||
if task is not None:
|
|
||||||
task.cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await task
|
|
||||||
self._fail_pending(NodeProtocolError("sidecar closed"))
|
|
||||||
self._process = None
|
|
||||||
|
|
||||||
async def _read_stdout(self) -> None:
|
|
||||||
assert self._process and self._process.stdout
|
|
||||||
try:
|
|
||||||
while line := await self._process.stdout.readline():
|
|
||||||
if len(line) > _MAX_MESSAGE_BYTES:
|
|
||||||
raise NodeProtocolError(
|
|
||||||
"sidecar response exceeds the 16 MB protocol limit"
|
|
||||||
)
|
|
||||||
message = json.loads(line)
|
|
||||||
request_id = message["id"]
|
|
||||||
future = self._pending.pop(request_id, None)
|
|
||||||
if future is None or future.done():
|
|
||||||
continue
|
|
||||||
if error := message.get("error"):
|
|
||||||
future.set_exception(
|
|
||||||
NodeProtocolError(
|
|
||||||
f"{error.get('code', 'sidecar_error')}: "
|
|
||||||
f"{error.get('message', 'unknown sidecar error')}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
future.set_result(message.get("result", {}))
|
|
||||||
except Exception as exc:
|
|
||||||
self._fail_pending(NodeProtocolError(f"invalid sidecar response: {exc}"))
|
|
||||||
if self._process and self._process.returncode is None:
|
|
||||||
self._process.kill()
|
|
||||||
finally:
|
|
||||||
if self._process and self._process.returncode is None:
|
|
||||||
await self._process.wait()
|
|
||||||
code = self._process.returncode if self._process else "unknown"
|
|
||||||
details = self.stderr[-1] if self.stderr else "no diagnostics"
|
|
||||||
self._fail_pending(
|
|
||||||
NodeProtocolError(f"sidecar exited with code {code}: {details}")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _read_stderr(self) -> None:
|
|
||||||
assert self._process and self._process.stderr
|
|
||||||
while line := await self._process.stderr.readline():
|
|
||||||
text = line.decode(errors="replace").rstrip()
|
|
||||||
if text:
|
|
||||||
self.stderr.append(text)
|
|
||||||
del self.stderr[:-100]
|
|
||||||
|
|
||||||
def _fail_pending(self, error: Exception) -> None:
|
|
||||||
for future in self._pending.values():
|
|
||||||
if not future.done():
|
|
||||||
future.set_exception(error)
|
|
||||||
self._pending.clear()
|
|
||||||
|
|
||||||
async def __aenter__(self) -> NodeSidecar:
|
|
||||||
await self.start()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_: object) -> None:
|
|
||||||
await self.close()
|
|
||||||
@ -1,426 +0,0 @@
|
|||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import readline from "node:readline";
|
|
||||||
|
|
||||||
const PROTOCOL = 1;
|
|
||||||
const invocations = new AsyncLocalStorage();
|
|
||||||
const state = {
|
|
||||||
runtime: null,
|
|
||||||
identity: null,
|
|
||||||
workspace: process.cwd(),
|
|
||||||
config: {},
|
|
||||||
tools: new Map(),
|
|
||||||
commands: new Map(),
|
|
||||||
hooks: new Map(),
|
|
||||||
registrations: [],
|
|
||||||
diagnostics: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeError = (...parts) => process.stderr.write(`${parts.map(String).join(" ")}\n`);
|
|
||||||
console.log = writeError;
|
|
||||||
console.info = writeError;
|
|
||||||
console.warn = writeError;
|
|
||||||
console.error = writeError;
|
|
||||||
|
|
||||||
function sanitize(value, depth = 0, seen = new WeakSet()) {
|
|
||||||
if (depth > 12) return "[max depth]";
|
|
||||||
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
|
|
||||||
if (typeof value === "bigint") return String(value);
|
|
||||||
if (typeof value === "function" || typeof value === "undefined") return undefined;
|
|
||||||
if (typeof value !== "object") return String(value);
|
|
||||||
if (seen.has(value)) return "[circular]";
|
|
||||||
seen.add(value);
|
|
||||||
if (Array.isArray(value)) return value.map((item) => sanitize(item, depth + 1, seen));
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value)
|
|
||||||
.map(([key, item]) => [key, sanitize(item, depth + 1, seen)])
|
|
||||||
.filter(([, item]) => item !== undefined),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRegistration(kind, name, options = {}) {
|
|
||||||
const registration = {
|
|
||||||
kind,
|
|
||||||
name: String(name),
|
|
||||||
description: String(options.description || ""),
|
|
||||||
...(options.schema ? { schema: sanitize(options.schema) } : {}),
|
|
||||||
...(options.metadata ? { metadata: sanitize(options.metadata) } : {}),
|
|
||||||
};
|
|
||||||
const index = state.registrations.findIndex(
|
|
||||||
(item) => item.kind === registration.kind && item.name === registration.name,
|
|
||||||
);
|
|
||||||
if (index >= 0) state.registrations[index] = registration;
|
|
||||||
else state.registrations.push(registration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsupported(name) {
|
|
||||||
if (!state.diagnostics.includes(`Unsupported compatibility API: ${name}`)) {
|
|
||||||
state.diagnostics.push(`Unsupported compatibility API: ${name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsupportedFacade(path) {
|
|
||||||
const fn = () => {
|
|
||||||
throw new Error(`${path} is not available in the nanobot compatibility host`);
|
|
||||||
};
|
|
||||||
return new Proxy(fn, {
|
|
||||||
get: (_, key) => unsupportedFacade(`${path}.${String(key)}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHook(name, handler, flavor) {
|
|
||||||
const names = Array.isArray(name) ? name : [name];
|
|
||||||
for (const item of names) {
|
|
||||||
const key = String(item);
|
|
||||||
const handlers = state.hooks.get(key) || [];
|
|
||||||
handlers.push({ handler, flavor });
|
|
||||||
state.hooks.set(key, handlers);
|
|
||||||
addRegistration("hook", key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTool(tool, flavor, options = {}) {
|
|
||||||
if (typeof tool === "function") {
|
|
||||||
const resolved = tool({
|
|
||||||
config: state.config,
|
|
||||||
runtimeConfig: state.config,
|
|
||||||
getRuntimeConfig: () => state.config,
|
|
||||||
workspaceDir: state.workspace,
|
|
||||||
sandboxed: false,
|
|
||||||
});
|
|
||||||
for (const item of Array.isArray(resolved) ? resolved : [resolved]) {
|
|
||||||
if (item) addTool(item, flavor, options);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!tool || typeof tool !== "object" || typeof tool.name !== "string") {
|
|
||||||
throw new Error("registered tool must define a name");
|
|
||||||
}
|
|
||||||
if (state.tools.has(tool.name)) {
|
|
||||||
throw new Error(`tool '${tool.name}' is already registered by this extension`);
|
|
||||||
}
|
|
||||||
state.tools.set(tool.name, { tool, flavor });
|
|
||||||
addRegistration("tool", tool.name, {
|
|
||||||
description: tool.description,
|
|
||||||
schema: tool.parameters || { type: "object", properties: {} },
|
|
||||||
metadata: {
|
|
||||||
label: tool.label,
|
|
||||||
optional: options.optional === true,
|
|
||||||
readOnly: tool.readOnly === true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function invocationOutput(value) {
|
|
||||||
const context = invocations.getStore();
|
|
||||||
if (context) context.outputs.push(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCommand(name, command, flavor) {
|
|
||||||
const normalized = String(name);
|
|
||||||
if (state.commands.has(normalized)) {
|
|
||||||
throw new Error(`command '${normalized}' is already registered by this extension`);
|
|
||||||
}
|
|
||||||
state.commands.set(normalized, { command, flavor });
|
|
||||||
addRegistration("command", normalized, { description: command?.description });
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventBus() {
|
|
||||||
return {
|
|
||||||
on: (name, handler) => addHook(`event:${name}`, handler, "pi-event"),
|
|
||||||
emit: async (name, data) => emitEvent(`event:${name}`, data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function piApi() {
|
|
||||||
const api = {
|
|
||||||
on: (name, handler) => addHook(name, handler, "pi"),
|
|
||||||
registerTool: (tool) => addTool(tool, "pi"),
|
|
||||||
registerCommand: (name, options) => addCommand(name, options, "pi"),
|
|
||||||
registerProvider: (nameOrProvider, config) => {
|
|
||||||
const provider =
|
|
||||||
typeof nameOrProvider === "string"
|
|
||||||
? { id: nameOrProvider, ...config }
|
|
||||||
: nameOrProvider;
|
|
||||||
addRegistration("llm_provider", provider.id || provider.name, {
|
|
||||||
description: provider.name,
|
|
||||||
metadata: provider,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
unregisterProvider: () => {},
|
|
||||||
sendMessage: (message) => invocationOutput(message?.content || message),
|
|
||||||
sendUserMessage: (message) => invocationOutput(message),
|
|
||||||
appendEntry: () => unsupported("pi.appendEntry"),
|
|
||||||
setSessionName: () => unsupported("pi.setSessionName"),
|
|
||||||
getSessionName: () => undefined,
|
|
||||||
setLabel: () => unsupported("pi.setLabel"),
|
|
||||||
getActiveTools: () => [],
|
|
||||||
getAllTools: () => [],
|
|
||||||
setActiveTools: () => unsupported("pi.setActiveTools"),
|
|
||||||
getCommands: () => [],
|
|
||||||
registerShortcut: () => unsupported("pi.registerShortcut"),
|
|
||||||
registerFlag: () => unsupported("pi.registerFlag"),
|
|
||||||
getFlag: () => undefined,
|
|
||||||
registerMessageRenderer: () => unsupported("pi.registerMessageRenderer"),
|
|
||||||
registerEntryRenderer: () => unsupported("pi.registerEntryRenderer"),
|
|
||||||
exec: unsupportedFacade("pi.exec"),
|
|
||||||
setModel: async () => false,
|
|
||||||
getThinkingLevel: () => "off",
|
|
||||||
setThinkingLevel: () => unsupported("pi.setThinkingLevel"),
|
|
||||||
events: eventBus(),
|
|
||||||
};
|
|
||||||
return new Proxy(api, {
|
|
||||||
get(target, key) {
|
|
||||||
if (key in target) return target[key];
|
|
||||||
unsupported(`pi.${String(key)}`);
|
|
||||||
return unsupportedFacade(`pi.${String(key)}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openClawApi() {
|
|
||||||
const identity = state.identity;
|
|
||||||
const api = {
|
|
||||||
id: identity.id,
|
|
||||||
name: identity.name,
|
|
||||||
version: identity.version,
|
|
||||||
source: state.entries[0],
|
|
||||||
rootDir: state.rootDir,
|
|
||||||
registrationMode: "activate",
|
|
||||||
config: state.config,
|
|
||||||
pluginConfig: state.config,
|
|
||||||
runtime: unsupportedFacade("openclaw.runtime"),
|
|
||||||
logger: {
|
|
||||||
debug: writeError,
|
|
||||||
info: writeError,
|
|
||||||
warn: writeError,
|
|
||||||
error: writeError,
|
|
||||||
},
|
|
||||||
registerTool: (tool, options) => addTool(tool, "openclaw", options),
|
|
||||||
registerCommand: (command) => addCommand(command.name, command, "openclaw"),
|
|
||||||
registerHook: (names, handler) => addHook(names, handler, "openclaw"),
|
|
||||||
on: (name, handler) => addHook(name, handler, "openclaw"),
|
|
||||||
registerProvider: (provider) =>
|
|
||||||
addRegistration("llm_provider", provider.id, {
|
|
||||||
description: provider.label,
|
|
||||||
metadata: provider,
|
|
||||||
}),
|
|
||||||
registerRealtimeTranscriptionProvider: (provider) =>
|
|
||||||
addRegistration("transcription_provider", provider.id, {
|
|
||||||
description: provider.label,
|
|
||||||
metadata: provider,
|
|
||||||
}),
|
|
||||||
registerImageGenerationProvider: (provider) =>
|
|
||||||
addRegistration("image_generation_provider", provider.id, {
|
|
||||||
description: provider.label,
|
|
||||||
metadata: provider,
|
|
||||||
}),
|
|
||||||
registerWebSearchProvider: (provider) =>
|
|
||||||
addRegistration("web_search_provider", provider.id, {
|
|
||||||
description: provider.label,
|
|
||||||
metadata: provider,
|
|
||||||
}),
|
|
||||||
resolvePath: (value) => new URL(value, pathToFileURL(`${state.rootDir}/`)).pathname,
|
|
||||||
};
|
|
||||||
const grouped = unsupportedFacade("openclaw");
|
|
||||||
api.session = grouped.session;
|
|
||||||
api.agent = grouped.agent;
|
|
||||||
api.runContext = grouped.runContext;
|
|
||||||
api.lifecycle = grouped.lifecycle;
|
|
||||||
return new Proxy(api, {
|
|
||||||
get(target, key) {
|
|
||||||
if (key in target) return target[key];
|
|
||||||
if (String(key).startsWith("register")) unsupported(`openclaw.${String(key)}`);
|
|
||||||
return (..._args) => unsupported(`openclaw.${String(key)}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function unwrapModule(value) {
|
|
||||||
const seen = new Set();
|
|
||||||
let current = value;
|
|
||||||
for (let index = 0; index < 12 && current && !seen.has(current); index += 1) {
|
|
||||||
seen.add(current);
|
|
||||||
if (typeof current === "function" || typeof current?.register === "function") return current;
|
|
||||||
current = current.default ?? current.module;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importModule(entry) {
|
|
||||||
try {
|
|
||||||
return await import(`${pathToFileURL(entry).href}?nanobot=${Date.now()}`);
|
|
||||||
} catch (error) {
|
|
||||||
if (![".ts", ".tsx", ".cts", ".mts"].some((suffix) => entry.endsWith(suffix))) throw error;
|
|
||||||
try {
|
|
||||||
const imported = createRequire(pathToFileURL(entry))("jiti");
|
|
||||||
const createJiti = imported.createJiti || imported.default || imported;
|
|
||||||
return await createJiti(import.meta.url, { interopDefault: true }).import(entry);
|
|
||||||
} catch (jitiError) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not load TypeScript extension. Use Node.js with type stripping or install jiti. ${jitiError.message}`,
|
|
||||||
{ cause: error },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadExtension(params) {
|
|
||||||
if (!["pi", "openclaw"].includes(params.runtime)) {
|
|
||||||
throw new Error(`unsupported Node extension runtime: ${params.runtime}`);
|
|
||||||
}
|
|
||||||
state.runtime = params.runtime;
|
|
||||||
state.identity = params.identity;
|
|
||||||
state.workspace = params.workspace;
|
|
||||||
state.config = params.config || {};
|
|
||||||
state.entries = params.entries;
|
|
||||||
state.rootDir = params.root;
|
|
||||||
state.tools.clear();
|
|
||||||
state.commands.clear();
|
|
||||||
state.hooks.clear();
|
|
||||||
state.registrations.length = 0;
|
|
||||||
state.diagnostics.length = 0;
|
|
||||||
|
|
||||||
for (const entry of params.entries) {
|
|
||||||
const loaded = unwrapModule(await importModule(entry));
|
|
||||||
const factory =
|
|
||||||
params.runtime === "openclaw" && typeof loaded?.register === "function"
|
|
||||||
? loaded.register
|
|
||||||
: loaded;
|
|
||||||
if (typeof factory !== "function") {
|
|
||||||
throw new Error(`extension entry does not export a factory: ${entry}`);
|
|
||||||
}
|
|
||||||
const result = factory(params.runtime === "pi" ? piApi() : openClawApi());
|
|
||||||
if (params.runtime === "openclaw" && result?.then) {
|
|
||||||
throw new Error("OpenClaw plugin register must be synchronous");
|
|
||||||
}
|
|
||||||
if (params.runtime === "pi") await result;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
registrations: state.registrations,
|
|
||||||
diagnostics: state.diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function contextApi() {
|
|
||||||
return {
|
|
||||||
mode: "rpc",
|
|
||||||
hasUI: false,
|
|
||||||
cwd: state.workspace,
|
|
||||||
signal: undefined,
|
|
||||||
ui: new Proxy(
|
|
||||||
{ notify: (message) => invocationOutput(message) },
|
|
||||||
{ get: (target, key) => target[key] || unsupportedFacade(`pi.ui.${String(key)}`) },
|
|
||||||
),
|
|
||||||
isIdle: () => true,
|
|
||||||
isProjectTrusted: () => true,
|
|
||||||
hasPendingMessages: () => false,
|
|
||||||
getContextUsage: () => undefined,
|
|
||||||
getSystemPrompt: () => "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultText(result, outputs = []) {
|
|
||||||
const values = [...outputs];
|
|
||||||
if (result !== undefined) values.push(result);
|
|
||||||
const text = [];
|
|
||||||
for (const value of values) {
|
|
||||||
if (typeof value === "string") text.push(value);
|
|
||||||
else if (typeof value?.text === "string") text.push(value.text);
|
|
||||||
else if (typeof value?.content === "string") text.push(value.content);
|
|
||||||
else if (Array.isArray(value?.content)) {
|
|
||||||
for (const item of value.content) {
|
|
||||||
if (typeof item === "string") text.push(item);
|
|
||||||
else if (typeof item?.text === "string") text.push(item.text);
|
|
||||||
}
|
|
||||||
} else if (value !== undefined) text.push(JSON.stringify(sanitize(value)));
|
|
||||||
}
|
|
||||||
return text.filter(Boolean).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callExtension(params) {
|
|
||||||
const context = { outputs: [] };
|
|
||||||
return invocations.run(context, async () => {
|
|
||||||
if (params.kind === "tool") {
|
|
||||||
const record = state.tools.get(params.name);
|
|
||||||
if (!record) throw new Error(`unknown tool: ${params.name}`);
|
|
||||||
const result = await record.tool.execute(
|
|
||||||
params.callId || "nanobot",
|
|
||||||
params.input || {},
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
...(record.flavor === "pi" ? [contextApi()] : []),
|
|
||||||
);
|
|
||||||
return { text: resultText(result, context.outputs), raw: sanitize(result) };
|
|
||||||
}
|
|
||||||
if (params.kind === "command") {
|
|
||||||
const record = state.commands.get(params.name);
|
|
||||||
if (!record) throw new Error(`unknown command: ${params.name}`);
|
|
||||||
const input = params.input || {};
|
|
||||||
const result =
|
|
||||||
record.flavor === "pi"
|
|
||||||
? await record.command.handler(input.args || "", contextApi())
|
|
||||||
: await record.command.handler({
|
|
||||||
args: input.args || "",
|
|
||||||
commandBody: input.raw || `/${params.name}`,
|
|
||||||
channel: input.channel || "websocket",
|
|
||||||
senderId: input.senderId,
|
|
||||||
isAuthorizedSender: true,
|
|
||||||
config: state.config,
|
|
||||||
sessionKey: input.sessionKey,
|
|
||||||
requestConversationBinding: async () => ({ ok: false }),
|
|
||||||
detachConversationBinding: async () => ({ removed: false }),
|
|
||||||
getCurrentConversationBinding: async () => null,
|
|
||||||
});
|
|
||||||
return { text: resultText(result, context.outputs), raw: sanitize(result) };
|
|
||||||
}
|
|
||||||
throw new Error(`unsupported callable kind: ${params.kind}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function emitEvent(name, event) {
|
|
||||||
const handlers = state.hooks.get(name) || [];
|
|
||||||
const results = [];
|
|
||||||
for (const { handler, flavor } of handlers) {
|
|
||||||
results.push(
|
|
||||||
await handler(event, flavor === "pi" ? contextApi() : { config: state.config }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { results: sanitize(results) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatch(method, params) {
|
|
||||||
if (method === "hello") {
|
|
||||||
return { protocol: PROTOCOL, node: process.version };
|
|
||||||
}
|
|
||||||
if (method === "extension.load") return loadExtension(params);
|
|
||||||
if (method === "extension.call") return callExtension(params);
|
|
||||||
if (method === "extension.event") return emitEvent(params.name, params.event);
|
|
||||||
if (method === "shutdown") {
|
|
||||||
queueMicrotask(() => process.exit(0));
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
throw new Error(`unknown method: ${method}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let queue = Promise.resolve();
|
|
||||||
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
||||||
lines.on("line", (line) => {
|
|
||||||
queue = queue.then(async () => {
|
|
||||||
let request;
|
|
||||||
try {
|
|
||||||
request = JSON.parse(line);
|
|
||||||
if (request.protocol !== PROTOCOL) throw new Error("protocol version mismatch");
|
|
||||||
const result = await dispatch(request.method, request.params || {});
|
|
||||||
process.stdout.write(`${JSON.stringify({ id: request.id, result: sanitize(result) })}\n`);
|
|
||||||
} catch (error) {
|
|
||||||
process.stdout.write(
|
|
||||||
`${JSON.stringify({
|
|
||||||
id: request?.id ?? null,
|
|
||||||
error: { code: "extension_error", message: String(error?.message || error) },
|
|
||||||
})}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
"""Translate Pi and OpenClaw package metadata into the nanobot manifest."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
|
||||||
from nanobot.extensions.manifest import (
|
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionPermission,
|
|
||||||
ExtensionRuntime,
|
|
||||||
)
|
|
||||||
|
|
||||||
_ID_CHARS = re.compile(r"[^a-z0-9._-]+")
|
|
||||||
_OPENCLAW_CONTRACT_KINDS = {
|
|
||||||
"tools": ContributionKind.TOOL,
|
|
||||||
"realtimeTranscriptionProviders": ContributionKind.TRANSCRIPTION_PROVIDER,
|
|
||||||
"imageGenerationProviders": ContributionKind.IMAGE_GENERATION_PROVIDER,
|
|
||||||
"webSearchProviders": ContributionKind.WEB_SEARCH_PROVIDER,
|
|
||||||
}
|
|
||||||
_TYPESCRIPT_SUFFIXES = (".ts", ".tsx", ".cts", ".mts")
|
|
||||||
_JITI_DEPENDENCY = ExtensionDependency(
|
|
||||||
kind=DependencyKind.NPM,
|
|
||||||
name="jiti",
|
|
||||||
specifier="^2.4.2",
|
|
||||||
)
|
|
||||||
_NODE_RUNTIME_PERMISSION = ExtensionPermission(
|
|
||||||
name="runtime.node",
|
|
||||||
reason="Run third-party JavaScript or TypeScript in a Node.js process.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AdaptedPackage:
|
|
||||||
"""Canonical metadata plus honest compatibility diagnostics."""
|
|
||||||
|
|
||||||
manifest: ExtensionManifest
|
|
||||||
diagnostics: tuple[str, ...] = ()
|
|
||||||
generated: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def adapt_package(root: Path) -> AdaptedPackage:
|
|
||||||
"""Load native metadata or adapt one supported JavaScript package."""
|
|
||||||
root = root.resolve()
|
|
||||||
canonical = root / MANIFEST_FILENAME
|
|
||||||
if canonical.is_file():
|
|
||||||
return AdaptedPackage(load_manifest(canonical))
|
|
||||||
|
|
||||||
package = _read_json(root / "package.json", "package.json")
|
|
||||||
if isinstance(package.get("pi"), dict):
|
|
||||||
return _adapt_pi(package)
|
|
||||||
if isinstance(package.get("openclaw"), dict):
|
|
||||||
return _adapt_openclaw(root, package)
|
|
||||||
raise ValueError(
|
|
||||||
f"{root} is not a nanobot, Pi, or OpenClaw extension package"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _adapt_pi(package: dict[str, Any]) -> AdaptedPackage:
|
|
||||||
pi = package["pi"]
|
|
||||||
entries = _string_list(pi.get("extensions"), "pi.extensions")
|
|
||||||
name = str(package.get("name") or "pi-extension")
|
|
||||||
return AdaptedPackage(
|
|
||||||
ExtensionManifest(
|
|
||||||
id=f"pi.{_identifier(name)}",
|
|
||||||
name=str(package.get("displayName") or name),
|
|
||||||
version=str(package.get("version") or "0.0.0"),
|
|
||||||
runtime=ExtensionRuntime.PI,
|
|
||||||
entries=tuple(entries),
|
|
||||||
dependencies=(_JITI_DEPENDENCY,) if _has_typescript(entries) else (),
|
|
||||||
permissions=(_NODE_RUNTIME_PERMISSION,),
|
|
||||||
description=str(package.get("description") or ""),
|
|
||||||
homepage=_homepage(package),
|
|
||||||
license=str(package.get("license") or ""),
|
|
||||||
),
|
|
||||||
generated=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _adapt_openclaw(root: Path, package: dict[str, Any]) -> AdaptedPackage:
|
|
||||||
openclaw = package["openclaw"]
|
|
||||||
entries = _optional_string_list(
|
|
||||||
openclaw.get("runtimeExtensions"),
|
|
||||||
"openclaw.runtimeExtensions",
|
|
||||||
) or _string_list(openclaw.get("extensions"), "openclaw.extensions")
|
|
||||||
plugin_path = root / "openclaw.plugin.json"
|
|
||||||
plugin = _read_json(plugin_path, "openclaw.plugin.json") if plugin_path.is_file() else {}
|
|
||||||
plugin_id = str(plugin.get("id") or package.get("name") or "openclaw-plugin")
|
|
||||||
contributions = _openclaw_contributions(plugin)
|
|
||||||
diagnostics: list[str] = []
|
|
||||||
contracts = plugin.get("contracts")
|
|
||||||
if isinstance(contracts, dict):
|
|
||||||
unsupported = sorted(
|
|
||||||
key for key, value in contracts.items()
|
|
||||||
if value and key not in _OPENCLAW_CONTRACT_KINDS
|
|
||||||
)
|
|
||||||
if unsupported:
|
|
||||||
diagnostics.append(
|
|
||||||
"OpenClaw capabilities retained as metadata but not executable: "
|
|
||||||
+ ", ".join(unsupported)
|
|
||||||
)
|
|
||||||
return AdaptedPackage(
|
|
||||||
ExtensionManifest(
|
|
||||||
id=f"openclaw.{_identifier(plugin_id)}",
|
|
||||||
name=str(plugin.get("name") or package.get("name") or plugin_id),
|
|
||||||
version=str(package.get("version") or plugin.get("version") or "0.0.0"),
|
|
||||||
runtime=ExtensionRuntime.OPENCLAW,
|
|
||||||
entries=tuple(entries),
|
|
||||||
contributions=contributions,
|
|
||||||
dependencies=_openclaw_dependencies(package, openclaw),
|
|
||||||
permissions=(_NODE_RUNTIME_PERMISSION,),
|
|
||||||
description=str(
|
|
||||||
plugin.get("description") or package.get("description") or ""
|
|
||||||
),
|
|
||||||
homepage=_homepage(package),
|
|
||||||
license=str(package.get("license") or ""),
|
|
||||||
),
|
|
||||||
diagnostics=tuple(diagnostics),
|
|
||||||
generated=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _openclaw_dependencies(
|
|
||||||
package: dict[str, Any],
|
|
||||||
openclaw: dict[str, Any],
|
|
||||||
) -> tuple[ExtensionDependency, ...]:
|
|
||||||
build = openclaw.get("build")
|
|
||||||
version = build.get("openclawVersion") if isinstance(build, dict) else None
|
|
||||||
peers = package.get("peerDependencies")
|
|
||||||
peer_version = peers.get("openclaw") if isinstance(peers, dict) else None
|
|
||||||
return (
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.NPM,
|
|
||||||
name="openclaw",
|
|
||||||
specifier=str(version or peer_version or "latest"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _openclaw_contributions(
|
|
||||||
plugin: dict[str, Any],
|
|
||||||
) -> tuple[ExtensionContribution, ...]:
|
|
||||||
rows: list[ExtensionContribution] = []
|
|
||||||
direct = {
|
|
||||||
"channels": ContributionKind.CHANNEL,
|
|
||||||
"providers": ContributionKind.LLM_PROVIDER,
|
|
||||||
"skills": ContributionKind.SKILL,
|
|
||||||
}
|
|
||||||
for field, kind in direct.items():
|
|
||||||
for name in _optional_string_list(plugin.get(field), field):
|
|
||||||
rows.append(ExtensionContribution(kind=kind, name=_identifier(name)))
|
|
||||||
contracts = plugin.get("contracts")
|
|
||||||
if isinstance(contracts, dict):
|
|
||||||
for field, kind in _OPENCLAW_CONTRACT_KINDS.items():
|
|
||||||
for name in _optional_string_list(contracts.get(field), field):
|
|
||||||
rows.append(ExtensionContribution(kind=kind, name=_identifier(name)))
|
|
||||||
for alias in plugin.get("commandAliases", []):
|
|
||||||
if isinstance(alias, dict) and isinstance(alias.get("name"), str):
|
|
||||||
rows.append(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.COMMAND,
|
|
||||||
name=_identifier(alias["name"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return tuple(dict.fromkeys(rows))
|
|
||||||
|
|
||||||
|
|
||||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ValueError(f"cannot read {label}: {exc}") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError(f"{label} must contain a JSON object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _string_list(value: object, label: str) -> list[str]:
|
|
||||||
rows = _optional_string_list(value, label)
|
|
||||||
if not rows:
|
|
||||||
raise ValueError(f"{label} must contain at least one entry")
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_string_list(value: object, label: str) -> list[str]:
|
|
||||||
if value is None:
|
|
||||||
return []
|
|
||||||
if not isinstance(value, list) or not all(
|
|
||||||
isinstance(item, str) and item for item in value
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label} must be an array of non-empty strings")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: str) -> str:
|
|
||||||
normalized = value.lower().replace("@", "").replace("/", ".")
|
|
||||||
normalized = _ID_CHARS.sub("-", normalized).strip(".-_")
|
|
||||||
return normalized or "extension"
|
|
||||||
|
|
||||||
|
|
||||||
def _homepage(package: dict[str, Any]) -> str:
|
|
||||||
homepage = package.get("homepage")
|
|
||||||
if isinstance(homepage, str):
|
|
||||||
return homepage
|
|
||||||
repository = package.get("repository")
|
|
||||||
if isinstance(repository, str):
|
|
||||||
return repository
|
|
||||||
if isinstance(repository, dict) and isinstance(repository.get("url"), str):
|
|
||||||
return repository["url"]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _has_typescript(entries: list[str]) -> bool:
|
|
||||||
return any(entry.lower().endswith(_TYPESCRIPT_SUFFIXES) for entry in entries)
|
|
||||||
@ -1,13 +1,11 @@
|
|||||||
"""Activation preflight for extension dependencies."""
|
"""Activation preflight for extension runtime prerequisites."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
|
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
|
||||||
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
|
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
|
||||||
@ -17,7 +15,7 @@ from nanobot.extensions.versioning import dependency_version_failure
|
|||||||
def evaluate_dependencies(
|
def evaluate_dependencies(
|
||||||
candidates: tuple[ExtensionCandidate, ...],
|
candidates: tuple[ExtensionCandidate, ...],
|
||||||
) -> tuple[tuple[ExtensionCandidate, ...], tuple[ExtensionDiagnostic, ...]]:
|
) -> tuple[tuple[ExtensionCandidate, ...], tuple[ExtensionDiagnostic, ...]]:
|
||||||
"""Disable candidates with missing hard dependencies and explain why."""
|
"""Disable candidates with missing required software and explain why."""
|
||||||
checked: list[ExtensionCandidate] = []
|
checked: list[ExtensionCandidate] = []
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
diagnostics: list[ExtensionDiagnostic] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
@ -25,12 +23,7 @@ def evaluate_dependencies(
|
|||||||
message
|
message
|
||||||
for dependency in candidate.manifest.dependencies
|
for dependency in candidate.manifest.dependencies
|
||||||
if not dependency.optional
|
if not dependency.optional
|
||||||
if (
|
if (message := _dependency_failure(dependency))
|
||||||
message := _dependency_failure(
|
|
||||||
dependency,
|
|
||||||
location=candidate.location,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
if failures:
|
if failures:
|
||||||
candidate = replace(candidate, enabled=False)
|
candidate = replace(candidate, enabled=False)
|
||||||
@ -48,8 +41,6 @@ def evaluate_dependencies(
|
|||||||
|
|
||||||
def _dependency_failure(
|
def _dependency_failure(
|
||||||
dependency: ExtensionDependency,
|
dependency: ExtensionDependency,
|
||||||
*,
|
|
||||||
location: Path | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
if dependency.kind is DependencyKind.EXECUTABLE:
|
if dependency.kind is DependencyKind.EXECUTABLE:
|
||||||
if shutil.which(dependency.name) is None:
|
if shutil.which(dependency.name) is None:
|
||||||
@ -65,28 +56,4 @@ def _dependency_failure(
|
|||||||
except importlib.metadata.PackageNotFoundError:
|
except importlib.metadata.PackageNotFoundError:
|
||||||
return f"Required Python package is not installed: {dependency.name}"
|
return f"Required Python package is not installed: {dependency.name}"
|
||||||
return dependency_version_failure(dependency, version, "Python package")
|
return dependency_version_failure(dependency, version, "Python package")
|
||||||
if dependency.kind is DependencyKind.NPM:
|
|
||||||
version = _npm_version(location, dependency.name)
|
|
||||||
if version is None:
|
|
||||||
return f"Required npm package is not installed: {dependency.name}"
|
|
||||||
# npm resolved the declared range while installing the package. Re-parsing
|
|
||||||
# npm semver here with Python's PEP 440 rules rejects valid ranges such as
|
|
||||||
# "latest", "^1.0.0", and "~2.3".
|
|
||||||
return ""
|
|
||||||
if dependency.kind is DependencyKind.EXTENSION:
|
|
||||||
# Extension dependencies are evaluated after policy selection so an
|
|
||||||
# installed but inactive package cannot satisfy an activation prerequisite.
|
|
||||||
return ""
|
|
||||||
return f"Unsupported dependency kind: {dependency.kind.value}"
|
return f"Unsupported dependency kind: {dependency.kind.value}"
|
||||||
|
|
||||||
|
|
||||||
def _npm_version(location: Path | None, name: str) -> str | None:
|
|
||||||
if location is None:
|
|
||||||
return None
|
|
||||||
package = location / "node_modules" / Path(*name.split("/")) / "package.json"
|
|
||||||
try:
|
|
||||||
value = json.loads(package.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
version = value.get("version") if isinstance(value, dict) else None
|
|
||||||
return version if isinstance(version, str) else None
|
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
"""Versioned messages shared with the Node compatibility sidecar."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
NODE_PROTOCOL_VERSION = 1
|
|
||||||
_CALLABLE_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
||||||
|
|
||||||
|
|
||||||
class NodeProtocolError(RuntimeError):
|
|
||||||
"""The sidecar returned an invalid message or reported an RPC failure."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class NodeRegistration:
|
|
||||||
"""One callable or inspectable contribution retained by the sidecar."""
|
|
||||||
|
|
||||||
kind: str
|
|
||||||
name: str
|
|
||||||
description: str = ""
|
|
||||||
schema: dict[str, Any] | None = None
|
|
||||||
metadata: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: object) -> NodeRegistration:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise NodeProtocolError("sidecar registration must be an object")
|
|
||||||
kind = value.get("kind")
|
|
||||||
name = value.get("name")
|
|
||||||
if (
|
|
||||||
not isinstance(kind, str)
|
|
||||||
or not isinstance(name, str)
|
|
||||||
or not kind.strip()
|
|
||||||
or not name.strip()
|
|
||||||
):
|
|
||||||
raise NodeProtocolError("sidecar registration requires non-empty string kind and name")
|
|
||||||
schema = value.get("schema")
|
|
||||||
metadata = value.get("metadata")
|
|
||||||
if schema is not None and not isinstance(schema, dict):
|
|
||||||
raise NodeProtocolError("sidecar registration schema must be an object")
|
|
||||||
if metadata is not None and not isinstance(metadata, dict):
|
|
||||||
raise NodeProtocolError("sidecar registration metadata must be an object")
|
|
||||||
if kind in {"tool", "command"}:
|
|
||||||
if not _CALLABLE_NAME.fullmatch(name):
|
|
||||||
raise NodeProtocolError(
|
|
||||||
f"sidecar {kind} name {name!r} must match "
|
|
||||||
"[A-Za-z0-9_-] and be at most 64 characters"
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
kind == "tool"
|
|
||||||
and schema is not None
|
|
||||||
and schema.get("type", "object") != "object"
|
|
||||||
):
|
|
||||||
raise NodeProtocolError(
|
|
||||||
f"sidecar tool {name!r} parameters must use an object schema"
|
|
||||||
)
|
|
||||||
return cls(
|
|
||||||
kind=kind,
|
|
||||||
name=name,
|
|
||||||
description=str(value.get("description") or ""),
|
|
||||||
schema=schema,
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class NodeLoadResult:
|
|
||||||
"""Metadata returned after a Pi or OpenClaw module registers itself."""
|
|
||||||
|
|
||||||
registrations: tuple[NodeRegistration, ...]
|
|
||||||
diagnostics: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: object) -> NodeLoadResult:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise NodeProtocolError("sidecar load result must be an object")
|
|
||||||
registrations = value.get("registrations", [])
|
|
||||||
diagnostics = value.get("diagnostics", [])
|
|
||||||
if not isinstance(registrations, list) or not isinstance(diagnostics, list):
|
|
||||||
raise NodeProtocolError("invalid sidecar load result")
|
|
||||||
return cls(
|
|
||||||
registrations=tuple(NodeRegistration.from_mapping(item) for item in registrations),
|
|
||||||
diagnostics=tuple(str(item) for item in diagnostics),
|
|
||||||
)
|
|
||||||
@ -1,102 +1,28 @@
|
|||||||
"""Deterministic extension selection and contribution ownership."""
|
"""Deterministic extension activation planning."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import IntEnum
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.extensions.manifest import (
|
from nanobot.extensions.manifest import ExtensionManifest
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionManifest,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.versioning import dependency_version_failure
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionScope(IntEnum):
|
|
||||||
"""Installation scope. Higher scopes shadow lower copies of the same ID."""
|
|
||||||
|
|
||||||
BUILTIN = 10
|
|
||||||
USER = 20
|
|
||||||
WORKSPACE = 30
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ExtensionCandidate:
|
class ExtensionCandidate:
|
||||||
"""One discovered installation of an extension manifest."""
|
"""One discovered extension package and its activation state."""
|
||||||
|
|
||||||
manifest: ExtensionManifest
|
manifest: ExtensionManifest
|
||||||
scope: ExtensionScope
|
|
||||||
location: Path | None = None
|
location: Path | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
trusted: bool = False
|
trusted: bool = False
|
||||||
integrity_valid: bool = True
|
integrity_valid: bool = True
|
||||||
granted_permissions: frozenset[str] = frozenset()
|
granted_permissions: frozenset[str] = frozenset()
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not isinstance(self.manifest, ExtensionManifest):
|
|
||||||
raise TypeError("extension candidate manifest must be an ExtensionManifest")
|
|
||||||
if not isinstance(self.scope, ExtensionScope):
|
|
||||||
raise TypeError("extension candidate scope must be an ExtensionScope")
|
|
||||||
if self.location is not None and not isinstance(self.location, Path):
|
|
||||||
raise TypeError("extension candidate location must be a Path or None")
|
|
||||||
if not isinstance(self.integrity_valid, bool):
|
|
||||||
raise TypeError("extension integrity state must be a boolean")
|
|
||||||
if not isinstance(self.granted_permissions, frozenset):
|
|
||||||
raise TypeError("extension granted permissions must be a frozenset")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionPolicy:
|
|
||||||
"""Host allow/deny policy applied after discovery."""
|
|
||||||
|
|
||||||
allow: frozenset[str] = frozenset()
|
|
||||||
deny: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not isinstance(self.allow, frozenset) or not isinstance(
|
|
||||||
self.deny, frozenset
|
|
||||||
):
|
|
||||||
raise TypeError("extension policy allow and deny values must be frozensets")
|
|
||||||
overlap = self.allow & self.deny
|
|
||||||
if overlap:
|
|
||||||
raise ValueError(
|
|
||||||
f"extension policy contains IDs in both allow and deny: {sorted(overlap)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def permits(self, candidate: ExtensionCandidate) -> bool:
|
|
||||||
extension_id = candidate.manifest.id
|
|
||||||
requested = {
|
|
||||||
permission.name for permission in candidate.manifest.permissions
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
candidate.enabled
|
|
||||||
and candidate.integrity_valid
|
|
||||||
and (
|
|
||||||
candidate.scope is ExtensionScope.BUILTIN
|
|
||||||
or (
|
|
||||||
candidate.trusted
|
|
||||||
and requested <= candidate.granted_permissions
|
|
||||||
)
|
|
||||||
)
|
|
||||||
and extension_id not in self.deny
|
|
||||||
and (not self.allow or extension_id in self.allow)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ResolvedContribution:
|
|
||||||
"""An active contribution and the extension that owns it."""
|
|
||||||
|
|
||||||
contribution: ExtensionContribution
|
|
||||||
owner: ExtensionCandidate
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ExtensionDiagnostic:
|
class ExtensionDiagnostic:
|
||||||
"""A non-fatal discovery or ownership problem."""
|
"""A non-fatal discovery or activation problem."""
|
||||||
|
|
||||||
code: str
|
code: str
|
||||||
extension_id: str
|
extension_id: str
|
||||||
@ -106,243 +32,51 @@ class ExtensionDiagnostic:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ExtensionSnapshot:
|
class ExtensionSnapshot:
|
||||||
"""Immutable result consumed by runtime adapters and management surfaces."""
|
"""Immutable activation plan consumed by the runtime."""
|
||||||
|
|
||||||
extensions: tuple[ExtensionCandidate, ...]
|
extensions: tuple[ExtensionCandidate, ...]
|
||||||
contributions: tuple[ResolvedContribution, ...]
|
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...]
|
diagnostics: tuple[ExtensionDiagnostic, ...]
|
||||||
|
|
||||||
def by_kind(
|
|
||||||
self,
|
|
||||||
kind: ContributionKind,
|
|
||||||
) -> tuple[ResolvedContribution, ...]:
|
|
||||||
return tuple(
|
|
||||||
item for item in self.contributions if item.contribution.kind is kind
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionRegistry:
|
class ExtensionRegistry:
|
||||||
"""Collect candidates and resolve one safe, deterministic active snapshot."""
|
"""Select trusted candidates and report missing permission grants."""
|
||||||
|
|
||||||
def __init__(self, policy: ExtensionPolicy | None = None) -> None:
|
def __init__(self) -> None:
|
||||||
self._policy = policy or ExtensionPolicy()
|
self._candidates: dict[str, ExtensionCandidate] = {}
|
||||||
self._candidates: dict[
|
|
||||||
tuple[str, ExtensionScope],
|
|
||||||
ExtensionCandidate,
|
|
||||||
] = {}
|
|
||||||
|
|
||||||
def register(self, candidate: ExtensionCandidate) -> None:
|
def register(self, candidate: ExtensionCandidate) -> None:
|
||||||
key = (candidate.manifest.id, candidate.scope)
|
extension_id = candidate.manifest.id
|
||||||
existing = self._candidates.get(key)
|
if extension_id in self._candidates:
|
||||||
if existing is not None:
|
raise ValueError(f"extension '{extension_id}' is already installed")
|
||||||
raise ValueError(
|
self._candidates[extension_id] = candidate
|
||||||
f"extension '{candidate.manifest.id}' is already registered in "
|
|
||||||
f"{candidate.scope.name.lower()} scope"
|
|
||||||
)
|
|
||||||
self._candidates[key] = candidate
|
|
||||||
|
|
||||||
def snapshot(self) -> ExtensionSnapshot:
|
def snapshot(self) -> ExtensionSnapshot:
|
||||||
active, selection_diagnostics = self._select_active_extensions()
|
active: list[ExtensionCandidate] = []
|
||||||
active, dependency_diagnostics = self._resolve_extension_dependencies(active)
|
|
||||||
resolved, resolution_diagnostics = self._resolve_contributions(active)
|
|
||||||
return ExtensionSnapshot(
|
|
||||||
extensions=self._activation_order(active),
|
|
||||||
contributions=tuple(
|
|
||||||
sorted(
|
|
||||||
resolved.values(),
|
|
||||||
key=lambda item: (
|
|
||||||
item.contribution.kind.value,
|
|
||||||
item.contribution.name,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
diagnostics=tuple(
|
|
||||||
selection_diagnostics
|
|
||||||
+ dependency_diagnostics
|
|
||||||
+ resolution_diagnostics
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _select_active_extensions(
|
|
||||||
self,
|
|
||||||
) -> tuple[dict[str, ExtensionCandidate], list[ExtensionDiagnostic]]:
|
|
||||||
active: dict[str, ExtensionCandidate] = {}
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
diagnostics: list[ExtensionDiagnostic] = []
|
||||||
for candidate in sorted(
|
for candidate in sorted(
|
||||||
self._candidates.values(),
|
self._candidates.values(),
|
||||||
key=lambda item: (item.scope, item.manifest.id),
|
key=lambda item: item.manifest.id,
|
||||||
):
|
):
|
||||||
if self._policy.permits(candidate):
|
requested = {
|
||||||
active[candidate.manifest.id] = candidate
|
permission.name for permission in candidate.manifest.permissions
|
||||||
continue
|
}
|
||||||
|
missing = sorted(requested - candidate.granted_permissions)
|
||||||
if (
|
if (
|
||||||
candidate.enabled
|
candidate.enabled
|
||||||
|
and candidate.integrity_valid
|
||||||
and candidate.trusted
|
and candidate.trusted
|
||||||
and candidate.scope is not ExtensionScope.BUILTIN
|
and not missing
|
||||||
):
|
):
|
||||||
requested = {
|
active.append(candidate)
|
||||||
permission.name
|
elif candidate.enabled and candidate.trusted and missing:
|
||||||
for permission in candidate.manifest.permissions
|
|
||||||
}
|
|
||||||
missing = sorted(requested - candidate.granted_permissions)
|
|
||||||
if missing:
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="permission_required",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=(
|
|
||||||
"Grant required extension permissions: "
|
|
||||||
+ ", ".join(missing)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return active, diagnostics
|
|
||||||
|
|
||||||
def _resolve_extension_dependencies(
|
|
||||||
self,
|
|
||||||
active: dict[str, ExtensionCandidate],
|
|
||||||
) -> tuple[dict[str, ExtensionCandidate], list[ExtensionDiagnostic]]:
|
|
||||||
active = dict(active)
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
while active:
|
|
||||||
failed: dict[str, list[str]] = {}
|
|
||||||
for extension_id, candidate in active.items():
|
|
||||||
for dependency in candidate.manifest.dependencies:
|
|
||||||
if (
|
|
||||||
dependency.optional
|
|
||||||
or dependency.kind is not DependencyKind.EXTENSION
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
required = active.get(dependency.name)
|
|
||||||
if required is None:
|
|
||||||
failed.setdefault(extension_id, []).append(
|
|
||||||
f"Required extension is not active: {dependency.name}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if message := dependency_version_failure(
|
|
||||||
dependency,
|
|
||||||
required.manifest.version,
|
|
||||||
"extension",
|
|
||||||
):
|
|
||||||
failed.setdefault(extension_id, []).append(message)
|
|
||||||
if failed:
|
|
||||||
for extension_id, messages in sorted(failed.items()):
|
|
||||||
active.pop(extension_id, None)
|
|
||||||
diagnostics.extend(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="dependency_missing",
|
|
||||||
extension_id=extension_id,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
for message in messages
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
cycle = self._dependency_cycle(active)
|
|
||||||
if not cycle:
|
|
||||||
break
|
|
||||||
for extension_id in sorted(cycle):
|
|
||||||
active.pop(extension_id, None)
|
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
ExtensionDiagnostic(
|
ExtensionDiagnostic(
|
||||||
code="dependency_cycle",
|
code="permission_required",
|
||||||
extension_id=extension_id,
|
|
||||||
message=(
|
|
||||||
"Extension dependency cycle contains: "
|
|
||||||
+ ", ".join(sorted(cycle))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return active, diagnostics
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dependency_cycle(
|
|
||||||
active: dict[str, ExtensionCandidate],
|
|
||||||
) -> set[str]:
|
|
||||||
state: dict[str, int] = {}
|
|
||||||
stack: list[str] = []
|
|
||||||
cycle: set[str] = set()
|
|
||||||
|
|
||||||
def visit(extension_id: str) -> None:
|
|
||||||
state[extension_id] = 1
|
|
||||||
stack.append(extension_id)
|
|
||||||
dependencies = (
|
|
||||||
dependency.name
|
|
||||||
for dependency in active[extension_id].manifest.dependencies
|
|
||||||
if not dependency.optional
|
|
||||||
and dependency.kind is DependencyKind.EXTENSION
|
|
||||||
and dependency.name in active
|
|
||||||
)
|
|
||||||
for dependency_id in sorted(dependencies):
|
|
||||||
if state.get(dependency_id, 0) == 0:
|
|
||||||
visit(dependency_id)
|
|
||||||
elif state.get(dependency_id) == 1:
|
|
||||||
cycle.update(stack[stack.index(dependency_id) :])
|
|
||||||
stack.pop()
|
|
||||||
state[extension_id] = 2
|
|
||||||
|
|
||||||
for extension_id in sorted(active):
|
|
||||||
if state.get(extension_id, 0) == 0:
|
|
||||||
visit(extension_id)
|
|
||||||
return cycle
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _activation_order(
|
|
||||||
active: dict[str, ExtensionCandidate],
|
|
||||||
) -> tuple[ExtensionCandidate, ...]:
|
|
||||||
ordered: list[ExtensionCandidate] = []
|
|
||||||
visited: set[str] = set()
|
|
||||||
|
|
||||||
def visit(extension_id: str) -> None:
|
|
||||||
if extension_id in visited:
|
|
||||||
return
|
|
||||||
visited.add(extension_id)
|
|
||||||
dependencies = (
|
|
||||||
dependency.name
|
|
||||||
for dependency in active[extension_id].manifest.dependencies
|
|
||||||
if dependency.kind is DependencyKind.EXTENSION
|
|
||||||
and dependency.name in active
|
|
||||||
)
|
|
||||||
for dependency_id in sorted(dependencies):
|
|
||||||
visit(dependency_id)
|
|
||||||
ordered.append(active[extension_id])
|
|
||||||
|
|
||||||
for extension_id in sorted(active):
|
|
||||||
visit(extension_id)
|
|
||||||
return tuple(ordered)
|
|
||||||
|
|
||||||
def _resolve_contributions(
|
|
||||||
self,
|
|
||||||
active: dict[str, ExtensionCandidate],
|
|
||||||
) -> tuple[
|
|
||||||
dict[tuple[ContributionKind, str], ResolvedContribution],
|
|
||||||
list[ExtensionDiagnostic],
|
|
||||||
]:
|
|
||||||
resolved: dict[
|
|
||||||
tuple[ContributionKind, str],
|
|
||||||
ResolvedContribution,
|
|
||||||
] = {}
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
for candidate in sorted(
|
|
||||||
active.values(),
|
|
||||||
key=lambda item: (item.scope, item.manifest.id),
|
|
||||||
):
|
|
||||||
for contribution in candidate.manifest.contributions:
|
|
||||||
key = (contribution.kind, contribution.name)
|
|
||||||
existing = resolved.get(key)
|
|
||||||
if existing is None:
|
|
||||||
resolved[key] = ResolvedContribution(contribution, candidate)
|
|
||||||
continue
|
|
||||||
existing_id = existing.owner.manifest.id
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="contribution_conflict",
|
|
||||||
extension_id=candidate.manifest.id,
|
extension_id=candidate.manifest.id,
|
||||||
message=(
|
message=(
|
||||||
f"{contribution.kind.value} '{contribution.name}' is already "
|
"Grant required extension permissions: "
|
||||||
f"owned by extension '{existing_id}'; disable one owner "
|
+ ", ".join(missing)
|
||||||
"before activating the other"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return resolved, diagnostics
|
return ExtensionSnapshot(tuple(active), tuple(diagnostics))
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Transactional activation of external extensions at existing registry edges."""
|
"""Transactional activation at nanobot's tool, command, and hook seams."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -6,17 +6,15 @@ import importlib
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from importlib.machinery import ModuleSpec
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
|
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.command.router import CommandRouter, Handler
|
from nanobot.command.router import CommandRouter, Handler
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.extensions.compatibility import CompatibleExtension
|
|
||||||
from nanobot.extensions.manifest import DependencyKind
|
|
||||||
from nanobot.extensions.node_host import NodeSidecar
|
|
||||||
from nanobot.extensions.registry import (
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
@ -24,20 +22,11 @@ from nanobot.extensions.registry import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ActivatedExtension:
|
|
||||||
"""One active runtime and the resources needed to deactivate it."""
|
|
||||||
|
|
||||||
candidate: ExtensionCandidate
|
|
||||||
compatible: CompatibleExtension | None = None
|
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ActivationResult:
|
class ActivationResult:
|
||||||
"""Immutable activation outcome consumed by the agent assembly layer."""
|
"""Immutable activation outcome consumed by the agent assembly layer."""
|
||||||
|
|
||||||
extensions: tuple[ActivatedExtension, ...]
|
extensions: tuple[ExtensionCandidate, ...]
|
||||||
hook_factories: tuple[AgentTurnHookFactory, ...]
|
hook_factories: tuple[AgentTurnHookFactory, ...]
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...]
|
diagnostics: tuple[ExtensionDiagnostic, ...]
|
||||||
|
|
||||||
@ -95,52 +84,21 @@ class ExtensionRuntimeManager:
|
|||||||
*,
|
*,
|
||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
commands: CommandRouter,
|
commands: CommandRouter,
|
||||||
config: Config,
|
|
||||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
hook_factories: list[AgentTurnHookFactory] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._tools = tools
|
self._tools = tools
|
||||||
self._commands = commands
|
self._commands = commands
|
||||||
self._config = config
|
self._active: list[ExtensionCandidate] = []
|
||||||
self._active: list[ActivatedExtension] = []
|
|
||||||
self._hook_factories = hook_factories if hook_factories is not None else []
|
self._hook_factories = hook_factories if hook_factories is not None else []
|
||||||
|
|
||||||
async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult:
|
async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult:
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
diagnostics: list[ExtensionDiagnostic] = []
|
||||||
activated_ids = {
|
|
||||||
candidate.manifest.id
|
|
||||||
for candidate in snapshot.extensions
|
|
||||||
if candidate.location is None
|
|
||||||
}
|
|
||||||
for candidate in snapshot.extensions:
|
for candidate in snapshot.extensions:
|
||||||
if candidate.location is None:
|
|
||||||
continue
|
|
||||||
missing = [
|
|
||||||
dependency.name
|
|
||||||
for dependency in candidate.manifest.dependencies
|
|
||||||
if not dependency.optional
|
|
||||||
and dependency.kind is DependencyKind.EXTENSION
|
|
||||||
and dependency.name not in activated_ids
|
|
||||||
]
|
|
||||||
if missing:
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="dependency_activation_failed",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=(
|
|
||||||
"Required extensions did not activate: "
|
|
||||||
+ ", ".join(sorted(missing))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
active = await self._activate_candidate(candidate)
|
active = self._activate_candidate(candidate)
|
||||||
if active is not None:
|
self._active.append(active)
|
||||||
self._active.append(active)
|
|
||||||
activated_ids.add(candidate.manifest.id)
|
|
||||||
diagnostics.extend(active.diagnostics)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._rollback_owner(candidate.manifest.id)
|
self._rollback_owner(candidate.manifest.id)
|
||||||
diagnostics.append(
|
diagnostics.append(
|
||||||
ExtensionDiagnostic(
|
ExtensionDiagnostic(
|
||||||
code="activation_failed",
|
code="activation_failed",
|
||||||
@ -156,83 +114,34 @@ class ExtensionRuntimeManager:
|
|||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
for active in reversed(self._active):
|
for active in reversed(self._active):
|
||||||
await self._rollback_owner(active.candidate.manifest.id, active)
|
self._rollback_owner(active.manifest.id)
|
||||||
|
_unload_extension_modules(active)
|
||||||
self._active.clear()
|
self._active.clear()
|
||||||
|
|
||||||
async def _activate_candidate(
|
def _activate_candidate(
|
||||||
self,
|
self,
|
||||||
candidate: ExtensionCandidate,
|
candidate: ExtensionCandidate,
|
||||||
) -> ActivatedExtension | None:
|
) -> ExtensionCandidate:
|
||||||
runtime = candidate.manifest.runtime.value
|
self._activate_python(candidate)
|
||||||
if runtime == "declarative":
|
return candidate
|
||||||
return ActivatedExtension(candidate)
|
|
||||||
entries = _resolve_entries(candidate)
|
|
||||||
if runtime == "python":
|
|
||||||
self._activate_python(candidate)
|
|
||||||
return ActivatedExtension(candidate)
|
|
||||||
if runtime not in {"pi", "openclaw"}:
|
|
||||||
raise ValueError(f"unsupported extension runtime: {runtime}")
|
|
||||||
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
entry_config = self._config.extensions.entries.get(candidate.manifest.id)
|
|
||||||
result = await host.load(
|
|
||||||
runtime=runtime,
|
|
||||||
entries=entries,
|
|
||||||
root=candidate.location,
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
name=candidate.manifest.name,
|
|
||||||
version=candidate.manifest.version,
|
|
||||||
config=entry_config.config if entry_config else {},
|
|
||||||
workspace=self._config.workspace_path,
|
|
||||||
)
|
|
||||||
compatible = CompatibleExtension(
|
|
||||||
host=host,
|
|
||||||
runtime=runtime,
|
|
||||||
owner=candidate.manifest.id,
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
self._register_compatible(candidate, compatible)
|
|
||||||
diagnostics = [
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="compatibility_notice",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
for message in result.diagnostics
|
|
||||||
]
|
|
||||||
diagnostics.extend(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="unsupported_compatible_contribution",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=(
|
|
||||||
f"{item.kind} '{item.name}' is visible in the catalog but "
|
|
||||||
"is not executable through this compatibility adapter"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for item in result.registrations
|
|
||||||
if item.kind not in {"tool", "command", "hook"}
|
|
||||||
)
|
|
||||||
return ActivatedExtension(candidate, compatible, tuple(diagnostics))
|
|
||||||
except Exception:
|
|
||||||
await host.close()
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _activate_python(self, candidate: ExtensionCandidate) -> None:
|
def _activate_python(self, candidate: ExtensionCandidate) -> None:
|
||||||
if len(candidate.manifest.activation_entries) != 1:
|
raw_entry = candidate.manifest.entry
|
||||||
raise ValueError("Python extensions must declare exactly one entry")
|
|
||||||
raw_entry = candidate.manifest.activation_entries[0]
|
|
||||||
module_name, separator, attribute = raw_entry.partition(":")
|
module_name, separator, attribute = raw_entry.partition(":")
|
||||||
if not separator:
|
if not separator:
|
||||||
module_name = raw_entry
|
module_name = raw_entry
|
||||||
attribute = "register"
|
attribute = "register"
|
||||||
assert candidate.location is not None
|
assert candidate.location is not None
|
||||||
importlib.invalidate_caches()
|
importlib.invalidate_caches()
|
||||||
_unload_modules_under(candidate.location)
|
module_prefix = _module_prefix(candidate.manifest.id)
|
||||||
_reject_module_collision(module_name, candidate.location)
|
_unload_extension_modules(candidate)
|
||||||
sys.path.insert(0, str(candidate.location))
|
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:
|
try:
|
||||||
module = importlib.import_module(module_name)
|
module = importlib.import_module(f"{module_prefix}.{module_name}")
|
||||||
module_path = getattr(module, "__file__", None)
|
module_path = getattr(module, "__file__", None)
|
||||||
if not module_path or not Path(module_path).resolve().is_relative_to(
|
if not module_path or not Path(module_path).resolve().is_relative_to(
|
||||||
candidate.location.resolve()
|
candidate.location.resolve()
|
||||||
@ -251,31 +160,12 @@ class ExtensionRuntimeManager:
|
|||||||
if result is not None:
|
if result is not None:
|
||||||
raise TypeError("Python extension register function must return None")
|
raise TypeError("Python extension register function must return None")
|
||||||
except Exception:
|
except Exception:
|
||||||
_unload_modules_under(candidate.location)
|
_unload_extension_modules(candidate)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
sys.path.remove(str(candidate.location))
|
|
||||||
|
|
||||||
def _register_compatible(
|
def _rollback_owner(
|
||||||
self,
|
|
||||||
candidate: ExtensionCandidate,
|
|
||||||
compatible: CompatibleExtension,
|
|
||||||
) -> None:
|
|
||||||
owner = candidate.manifest.id
|
|
||||||
for tool in compatible.tools:
|
|
||||||
if not self._tools.register_if_absent(tool, owner=owner):
|
|
||||||
existing = self._tools.owner(tool.name) or "unknown"
|
|
||||||
raise ValueError(
|
|
||||||
f"tool '{tool.name}' is already registered by '{existing}'"
|
|
||||||
)
|
|
||||||
compatible.register_commands(self._commands)
|
|
||||||
if hook := compatible.hook:
|
|
||||||
self._hook_factories.append(_constant_hook_factory(hook, owner))
|
|
||||||
|
|
||||||
async def _rollback_owner(
|
|
||||||
self,
|
self,
|
||||||
owner: str,
|
owner: str,
|
||||||
active: ActivatedExtension | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._tools.unregister_owner(owner)
|
self._tools.unregister_owner(owner)
|
||||||
self._commands.unregister_owner(owner)
|
self._commands.unregister_owner(owner)
|
||||||
@ -284,43 +174,6 @@ class ExtensionRuntimeManager:
|
|||||||
for factory in self._hook_factories
|
for factory in self._hook_factories
|
||||||
if getattr(factory, "__nanobot_extension_owner__", None) != owner
|
if getattr(factory, "__nanobot_extension_owner__", None) != owner
|
||||||
]
|
]
|
||||||
if active and active.compatible:
|
|
||||||
await active.compatible.close()
|
|
||||||
if (
|
|
||||||
active
|
|
||||||
and active.candidate.manifest.runtime.value == "python"
|
|
||||||
and active.candidate.location
|
|
||||||
):
|
|
||||||
_unload_modules_under(active.candidate.location)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_entries(candidate: ExtensionCandidate) -> tuple[Path, ...]:
|
|
||||||
manifest = candidate.manifest
|
|
||||||
location = candidate.location
|
|
||||||
entries = manifest.activation_entries
|
|
||||||
if location is None or not entries:
|
|
||||||
raise ValueError(f"extension '{manifest.id}' does not declare any entries")
|
|
||||||
if manifest.runtime.value == "python":
|
|
||||||
return (location,)
|
|
||||||
resolved: list[Path] = []
|
|
||||||
for raw_entry in entries:
|
|
||||||
entry = (location / raw_entry).resolve()
|
|
||||||
if not entry.is_relative_to(location.resolve()):
|
|
||||||
raise ValueError(f"extension '{manifest.id}' entry escapes its package")
|
|
||||||
if not entry.is_file():
|
|
||||||
raise ValueError(
|
|
||||||
f"extension '{manifest.id}' entry does not exist: {entry}"
|
|
||||||
)
|
|
||||||
resolved.append(entry)
|
|
||||||
return tuple(resolved)
|
|
||||||
|
|
||||||
|
|
||||||
def _constant_hook_factory(hook: AgentHook, owner: str) -> AgentTurnHookFactory:
|
|
||||||
def factory(_context: Any) -> AgentHook:
|
|
||||||
return hook
|
|
||||||
|
|
||||||
setattr(factory, "__nanobot_extension_owner__", owner)
|
|
||||||
return factory
|
|
||||||
|
|
||||||
|
|
||||||
def _owned_hook_factory(
|
def _owned_hook_factory(
|
||||||
@ -352,17 +205,14 @@ def _unload_modules_under(root: Path) -> None:
|
|||||||
shutil.rmtree(cache, ignore_errors=True)
|
shutil.rmtree(cache, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _reject_module_collision(module_name: str, root: Path) -> None:
|
def _module_prefix(extension_id: str) -> str:
|
||||||
package_root = root.resolve()
|
return "_nanobot_extension_" + extension_id.encode().hex()
|
||||||
parts = module_name.split(".")
|
|
||||||
for index in range(1, len(parts) + 1):
|
|
||||||
loaded = sys.modules.get(".".join(parts[:index]))
|
def _unload_extension_modules(candidate: ExtensionCandidate) -> None:
|
||||||
loaded_path = getattr(loaded, "__file__", None)
|
assert candidate.location is not None
|
||||||
if loaded is not None and (
|
prefix = _module_prefix(candidate.manifest.id)
|
||||||
not loaded_path
|
for name in tuple(sys.modules):
|
||||||
or not Path(loaded_path).resolve().is_relative_to(package_root)
|
if name == prefix or name.startswith(f"{prefix}."):
|
||||||
):
|
sys.modules.pop(name, None)
|
||||||
raise ValueError(
|
_unload_modules_under(candidate.location)
|
||||||
f"Python extension entry conflicts with loaded module: "
|
|
||||||
f"{'.'.join(parts[:index])}"
|
|
||||||
)
|
|
||||||
|
|||||||
@ -8,9 +8,9 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.extensions.host import ExtensionHost
|
from nanobot.extensions.host import ExtensionHost
|
||||||
from nanobot.extensions.market import ExtensionMarketplace
|
from nanobot.extensions.manifest import ExtensionManifest
|
||||||
from nanobot.extensions.registry import ExtensionCandidate, ExtensionScope
|
from nanobot.extensions.registry import ExtensionCandidate
|
||||||
from nanobot.extensions.store import ExtensionStore
|
from nanobot.extensions.store import ExtensionStore, InstalledExtension
|
||||||
|
|
||||||
|
|
||||||
class ExtensionService:
|
class ExtensionService:
|
||||||
@ -21,15 +21,14 @@ class ExtensionService:
|
|||||||
*,
|
*,
|
||||||
host: ExtensionHost | None = None,
|
host: ExtensionHost | None = None,
|
||||||
store: ExtensionStore | None = None,
|
store: ExtensionStore | None = None,
|
||||||
marketplace: ExtensionMarketplace | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.store = store or ExtensionStore()
|
self.store = store or ExtensionStore()
|
||||||
self.marketplace = marketplace or ExtensionMarketplace()
|
|
||||||
self._mutation_lock = asyncio.Lock()
|
self._mutation_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def status(self) -> dict[str, Any]:
|
async def status(self) -> dict[str, Any]:
|
||||||
catalog = self.host.snapshot.catalog if self.host and self.host.snapshot else None
|
snapshot = self.host.snapshot if self.host else None
|
||||||
|
catalog = snapshot.catalog if snapshot else None
|
||||||
if catalog is None:
|
if catalog is None:
|
||||||
discovery = self.store.discover()
|
discovery = self.store.discover()
|
||||||
candidates = discovery.candidates
|
candidates = discovery.candidates
|
||||||
@ -37,61 +36,33 @@ class ExtensionService:
|
|||||||
active_ids: set[str] = set()
|
active_ids: set[str] = set()
|
||||||
else:
|
else:
|
||||||
candidates = catalog.candidates
|
candidates = catalog.candidates
|
||||||
diagnostics = catalog.diagnostics
|
|
||||||
active_ids = {
|
active_ids = {
|
||||||
active.candidate.manifest.id
|
active.manifest.id
|
||||||
for active in self.host.snapshot.activation.extensions
|
for active in snapshot.activation.extensions
|
||||||
}
|
}
|
||||||
active_ids.update(
|
diagnostics = catalog.diagnostics + snapshot.activation.diagnostics
|
||||||
candidate.manifest.id
|
|
||||||
for candidate in catalog.snapshot.extensions
|
|
||||||
if candidate.location is None
|
|
||||||
)
|
|
||||||
if self.host and self.host.snapshot:
|
|
||||||
diagnostics += self.host.snapshot.activation.diagnostics
|
|
||||||
records = self.store.records()
|
records = self.store.records()
|
||||||
return {
|
return {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
_candidate_payload(candidate, active_ids, records.get(candidate.manifest.id))
|
_candidate_payload(candidate, active_ids, records.get(candidate.manifest.id))
|
||||||
for candidate in sorted(
|
for candidate in sorted(
|
||||||
candidates,
|
candidates,
|
||||||
key=lambda item: (item.scope, item.manifest.name.lower()),
|
key=lambda item: item.manifest.name.lower(),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
"diagnostics": [asdict(item) for item in diagnostics],
|
"diagnostics": [asdict(item) for item in diagnostics],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
query: str = "",
|
|
||||||
*,
|
|
||||||
ecosystem: str = "all",
|
|
||||||
limit: int = 30,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
packages = await asyncio.to_thread(
|
|
||||||
self.marketplace.search,
|
|
||||||
query,
|
|
||||||
ecosystem=ecosystem,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
return {"packages": [asdict(package) for package in packages]}
|
|
||||||
|
|
||||||
async def install(
|
async def install(
|
||||||
self,
|
self,
|
||||||
source: str,
|
source: str,
|
||||||
*,
|
*,
|
||||||
kind: str = "npm",
|
kind: str = "git",
|
||||||
ref: str = "",
|
ref: str = "",
|
||||||
trusted: bool = False,
|
trusted: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
async with self._mutation_lock:
|
async with self._mutation_lock:
|
||||||
if kind == "npm":
|
if kind == "git":
|
||||||
result = await asyncio.to_thread(
|
|
||||||
self.store.install_npm,
|
|
||||||
source,
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
elif kind == "git":
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
self.store.install_git,
|
self.store.install_git,
|
||||||
source,
|
source,
|
||||||
@ -109,8 +80,7 @@ class ExtensionService:
|
|||||||
await self._reload()
|
await self._reload()
|
||||||
return {
|
return {
|
||||||
"record": _record_payload(result.record),
|
"record": _record_payload(result.record),
|
||||||
"manifest": _manifest_payload(result.package.manifest),
|
"manifest": _manifest_payload(result.manifest),
|
||||||
"diagnostics": list(result.package.diagnostics),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]:
|
async def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]:
|
||||||
@ -150,46 +120,33 @@ class ExtensionService:
|
|||||||
def _candidate_payload(
|
def _candidate_payload(
|
||||||
candidate: ExtensionCandidate,
|
candidate: ExtensionCandidate,
|
||||||
active_ids: set[str],
|
active_ids: set[str],
|
||||||
record: Any | None,
|
record: InstalledExtension | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
manifest = candidate.manifest
|
manifest = candidate.manifest
|
||||||
requested = [permission.name for permission in manifest.permissions]
|
requested = [permission.name for permission in manifest.permissions]
|
||||||
return {
|
return {
|
||||||
**_manifest_payload(manifest),
|
**_manifest_payload(manifest),
|
||||||
"scope": candidate.scope.name.lower(),
|
|
||||||
"location": str(candidate.location) if candidate.location else None,
|
"location": str(candidate.location) if candidate.location else None,
|
||||||
"enabled": candidate.enabled,
|
"enabled": candidate.enabled,
|
||||||
"trusted": candidate.trusted,
|
"trusted": candidate.trusted,
|
||||||
"active": manifest.id in active_ids,
|
"active": manifest.id in active_ids,
|
||||||
"requested_permissions": requested,
|
"requested_permissions": requested,
|
||||||
"granted_permissions": sorted(candidate.granted_permissions),
|
"granted_permissions": sorted(candidate.granted_permissions),
|
||||||
"source": record.source.value if record else (
|
"source": record.source.value if record else "path",
|
||||||
"builtin" if candidate.scope is ExtensionScope.BUILTIN else "path"
|
|
||||||
),
|
|
||||||
"source_ref": record.source_ref if record else "",
|
"source_ref": record.source_ref if record else "",
|
||||||
"integrity": record.integrity if record else "",
|
"integrity": record.integrity if record else "",
|
||||||
"installed_at": record.installed_at if record else "",
|
"installed_at": record.installed_at if record else "",
|
||||||
"managed_by_store": record is not None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _manifest_payload(manifest: Any) -> dict[str, Any]:
|
def _manifest_payload(manifest: ExtensionManifest) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": manifest.id,
|
"id": manifest.id,
|
||||||
"name": manifest.name,
|
"name": manifest.name,
|
||||||
"version": manifest.version,
|
"version": manifest.version,
|
||||||
"runtime": manifest.runtime.value,
|
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"homepage": manifest.homepage,
|
"homepage": manifest.homepage,
|
||||||
"license": manifest.license,
|
"license": manifest.license,
|
||||||
"contributions": [
|
|
||||||
{
|
|
||||||
"kind": contribution.kind.value,
|
|
||||||
"name": contribution.name,
|
|
||||||
"description": contribution.description,
|
|
||||||
}
|
|
||||||
for contribution in manifest.contributions
|
|
||||||
],
|
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{
|
{
|
||||||
"kind": dependency.kind.value,
|
"kind": dependency.kind.value,
|
||||||
@ -206,8 +163,5 @@ def _manifest_payload(manifest: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _record_payload(record: Any) -> dict[str, Any]:
|
def _record_payload(record: InstalledExtension) -> dict[str, Any]:
|
||||||
return {
|
return record.model_dump(mode="json")
|
||||||
**asdict(record),
|
|
||||||
"source": record.source.value,
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,9 +8,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tarfile
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import asdict, dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -19,49 +18,34 @@ from urllib.parse import urlparse
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
from nanobot.extensions.codec import MANIFEST_FILENAME, dump_manifest, load_manifest
|
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
||||||
from nanobot.extensions.discovery import (
|
from nanobot.extensions.discovery import (
|
||||||
ExtensionDiscoveryResult,
|
ExtensionDiscoveryResult,
|
||||||
discover_manifest_root,
|
discover_manifest_root,
|
||||||
)
|
)
|
||||||
from nanobot.extensions.manifest import (
|
from nanobot.extensions.manifest import ExtensionManifest, validate_extension_id
|
||||||
DependencyKind,
|
from nanobot.extensions.registry import ExtensionDiagnostic
|
||||||
ExtensionManifest,
|
|
||||||
validate_extension_id,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
|
|
||||||
from nanobot.extensions.registry import ExtensionDiagnostic, ExtensionScope
|
|
||||||
|
|
||||||
_REGISTRY_FILENAME = ".registry.json"
|
_REGISTRY_FILENAME = ".registry.json"
|
||||||
_GIT_SCHEMES = frozenset({"git", "http", "https", "ssh"})
|
_GIT_SCHEMES = frozenset({"git", "http", "https", "ssh"})
|
||||||
_SCP_GIT_URL = re.compile(
|
_SCP_GIT_URL = re.compile(
|
||||||
r"(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?:\S+"
|
r"(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?:\S+"
|
||||||
)
|
)
|
||||||
_NPM_ALIAS = re.compile(
|
|
||||||
r"npm:(?:(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*)"
|
|
||||||
r"(?:@[^:/\\\s]+)?"
|
|
||||||
)
|
|
||||||
_NPM_PACKAGE_SPEC = re.compile(
|
|
||||||
r"(?:(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*)"
|
|
||||||
r"(?:@[^:/\\\s]+)?"
|
|
||||||
)
|
|
||||||
_SHA256_INTEGRITY = re.compile(r"sha256:[0-9a-f]{64}")
|
_SHA256_INTEGRITY = re.compile(r"sha256:[0-9a-f]{64}")
|
||||||
_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
|
|
||||||
_MAX_EXTRACTED_BYTES = 512 * 1024 * 1024
|
|
||||||
_MAX_ARCHIVE_MEMBERS = 50_000
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionSourceKind(str, Enum):
|
class ExtensionSourceKind(str, Enum):
|
||||||
LOCAL = "local"
|
LOCAL = "local"
|
||||||
GIT = "git"
|
GIT = "git"
|
||||||
NPM = "npm"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
class InstalledExtension(BaseModel):
|
||||||
class InstalledExtension:
|
|
||||||
"""Persistent installation and policy record."""
|
"""Persistent installation and policy record."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
version: str
|
version: str
|
||||||
source: ExtensionSourceKind
|
source: ExtensionSourceKind
|
||||||
@ -72,65 +56,39 @@ class InstalledExtension:
|
|||||||
trusted: bool = False
|
trusted: bool = False
|
||||||
granted_permissions: tuple[str, ...] = ()
|
granted_permissions: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
@field_validator("id")
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_mapping(cls, value: object) -> InstalledExtension:
|
def validate_id(cls, value: str) -> str:
|
||||||
if not isinstance(value, dict):
|
return validate_extension_id(value)
|
||||||
raise ValueError("extension registry record must be an object")
|
|
||||||
required = {
|
@field_validator("version", "source_ref", "installed_at")
|
||||||
"id",
|
@classmethod
|
||||||
"version",
|
def validate_metadata(cls, value: str) -> str:
|
||||||
"source",
|
if not value:
|
||||||
"source_ref",
|
|
||||||
"integrity",
|
|
||||||
"installed_at",
|
|
||||||
}
|
|
||||||
missing = sorted(required - value.keys())
|
|
||||||
if missing:
|
|
||||||
raise ValueError(
|
|
||||||
"extension registry record is missing: " + ", ".join(missing)
|
|
||||||
)
|
|
||||||
permissions = value.get("granted_permissions", ())
|
|
||||||
if not isinstance(permissions, (list, tuple)) or not all(
|
|
||||||
isinstance(permission, str) for permission in permissions
|
|
||||||
):
|
|
||||||
raise ValueError("extension granted permissions must be an array of strings")
|
|
||||||
if len(set(permissions)) != len(permissions):
|
|
||||||
raise ValueError("extension granted permissions cannot contain duplicates")
|
|
||||||
extension_id = validate_extension_id(value["id"])
|
|
||||||
strings = {
|
|
||||||
field: value[field]
|
|
||||||
for field in ("version", "source_ref", "integrity", "installed_at")
|
|
||||||
}
|
|
||||||
if not all(isinstance(item, str) and item for item in strings.values()):
|
|
||||||
raise ValueError("extension registry metadata must use non-empty strings")
|
raise ValueError("extension registry metadata must use non-empty strings")
|
||||||
if _SHA256_INTEGRITY.fullmatch(strings["integrity"]) is None:
|
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")
|
raise ValueError("extension registry integrity must be a sha256 digest")
|
||||||
source = value["source"]
|
return value
|
||||||
if not isinstance(source, str):
|
|
||||||
raise ValueError("extension registry source must be a string")
|
@field_validator("granted_permissions")
|
||||||
enabled = value.get("enabled", True)
|
@classmethod
|
||||||
trusted = value.get("trusted", False)
|
def reject_duplicate_permissions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||||
if not isinstance(enabled, bool) or not isinstance(trusted, bool):
|
if len(set(value)) != len(value):
|
||||||
raise ValueError("extension enabled and trusted values must be booleans")
|
raise ValueError("extension granted permissions cannot contain duplicates")
|
||||||
return cls(
|
return value
|
||||||
id=extension_id,
|
|
||||||
version=strings["version"],
|
|
||||||
source=ExtensionSourceKind(source),
|
|
||||||
source_ref=strings["source_ref"],
|
|
||||||
integrity=strings["integrity"],
|
|
||||||
installed_at=strings["installed_at"],
|
|
||||||
enabled=enabled,
|
|
||||||
trusted=trusted,
|
|
||||||
granted_permissions=tuple(permissions),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class InstallResult:
|
class InstallResult:
|
||||||
"""Installed package plus metadata-adapter notices."""
|
"""Installed package metadata."""
|
||||||
|
|
||||||
record: InstalledExtension
|
record: InstalledExtension
|
||||||
package: AdaptedPackage
|
manifest: ExtensionManifest
|
||||||
|
|
||||||
|
|
||||||
class ExtensionStore:
|
class ExtensionStore:
|
||||||
@ -154,7 +112,7 @@ class ExtensionStore:
|
|||||||
raise ValueError("extension registry extensions must be an array")
|
raise ValueError("extension registry extensions must be an array")
|
||||||
records: dict[str, InstalledExtension] = {}
|
records: dict[str, InstalledExtension] = {}
|
||||||
for item in rows:
|
for item in rows:
|
||||||
record = InstalledExtension.from_mapping(item)
|
record = InstalledExtension.model_validate(item)
|
||||||
if record.id in records:
|
if record.id in records:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"extension registry contains duplicate id: {record.id}"
|
f"extension registry contains duplicate id: {record.id}"
|
||||||
@ -176,7 +134,7 @@ class ExtensionStore:
|
|||||||
|
|
||||||
def discover(self) -> ExtensionDiscoveryResult:
|
def discover(self) -> ExtensionDiscoveryResult:
|
||||||
"""Discover packages and apply persisted enable/trust state."""
|
"""Discover packages and apply persisted enable/trust state."""
|
||||||
result = discover_manifest_root(self.root, scope=ExtensionScope.USER)
|
result = discover_manifest_root(self.root)
|
||||||
diagnostics = list(result.diagnostics)
|
diagnostics = list(result.diagnostics)
|
||||||
try:
|
try:
|
||||||
records = self.records(strict=True)
|
records = self.records(strict=True)
|
||||||
@ -191,15 +149,12 @@ class ExtensionStore:
|
|||||||
)
|
)
|
||||||
candidates = []
|
candidates = []
|
||||||
for candidate in result.candidates:
|
for candidate in result.candidates:
|
||||||
record = records.get(candidate.manifest.id, _DEFAULT_RECORD)
|
record = records.get(candidate.manifest.id)
|
||||||
trusted = record.trusted
|
trusted = record.trusted if record else False
|
||||||
integrity_valid = True
|
integrity_valid = True
|
||||||
if candidate.location is not None and record is not _DEFAULT_RECORD:
|
if candidate.location is not None and record is not None:
|
||||||
try:
|
try:
|
||||||
_reject_unsafe_files(
|
_reject_unsafe_files(candidate.location)
|
||||||
candidate.location,
|
|
||||||
allow_installed_node_links=True,
|
|
||||||
)
|
|
||||||
actual_integrity = _tree_hash(candidate.location)
|
actual_integrity = _tree_hash(candidate.location)
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
actual_integrity = ""
|
actual_integrity = ""
|
||||||
@ -226,10 +181,12 @@ class ExtensionStore:
|
|||||||
candidates.append(
|
candidates.append(
|
||||||
replace(
|
replace(
|
||||||
candidate,
|
candidate,
|
||||||
enabled=record.enabled,
|
enabled=record.enabled if record else True,
|
||||||
trusted=trusted,
|
trusted=trusted,
|
||||||
integrity_valid=integrity_valid,
|
integrity_valid=integrity_valid,
|
||||||
granted_permissions=frozenset(record.granted_permissions),
|
granted_permissions=frozenset(
|
||||||
|
record.granted_permissions if record else ()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return ExtensionDiscoveryResult(tuple(candidates), tuple(diagnostics))
|
return ExtensionDiscoveryResult(tuple(candidates), tuple(diagnostics))
|
||||||
@ -301,48 +258,6 @@ class ExtensionStore:
|
|||||||
trusted=trusted,
|
trusted=trusted,
|
||||||
)
|
)
|
||||||
|
|
||||||
def install_npm(
|
|
||||||
self,
|
|
||||||
spec: str,
|
|
||||||
*,
|
|
||||||
trusted: bool = False,
|
|
||||||
) -> InstallResult:
|
|
||||||
_validate_npm_package_spec(spec)
|
|
||||||
with tempfile.TemporaryDirectory(prefix="nanobot-extension-npm-") as raw:
|
|
||||||
temp = Path(raw)
|
|
||||||
output = _run(
|
|
||||||
[
|
|
||||||
"npm",
|
|
||||||
"pack",
|
|
||||||
"--ignore-scripts",
|
|
||||||
"--json",
|
|
||||||
"--pack-destination",
|
|
||||||
str(temp),
|
|
||||||
"--",
|
|
||||||
spec,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
rows = json.loads(output)
|
|
||||||
if not isinstance(rows, list) or not rows:
|
|
||||||
raise ValueError("npm pack did not return a package")
|
|
||||||
row = rows[0]
|
|
||||||
filename = row.get("filename") if isinstance(row, dict) else None
|
|
||||||
if not isinstance(filename, str) or not filename:
|
|
||||||
raise ValueError("npm pack returned invalid package metadata")
|
|
||||||
archive = (temp / filename).resolve()
|
|
||||||
if not archive.is_relative_to(temp.resolve()) or not archive.is_file():
|
|
||||||
raise ValueError("npm pack returned an invalid package archive")
|
|
||||||
checkout = temp / "checkout"
|
|
||||||
checkout.mkdir()
|
|
||||||
_extract_tar(archive, checkout)
|
|
||||||
package_root = checkout / "package"
|
|
||||||
return self._install_from_directory(
|
|
||||||
package_root,
|
|
||||||
source_kind=ExtensionSourceKind.NPM,
|
|
||||||
source_ref=spec,
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_enabled(self, extension_id: str, enabled: bool) -> InstalledExtension:
|
def set_enabled(self, extension_id: str, enabled: bool) -> InstalledExtension:
|
||||||
return self._update_record(extension_id, enabled=enabled)
|
return self._update_record(extension_id, enabled=enabled)
|
||||||
|
|
||||||
@ -420,9 +335,11 @@ class ExtensionStore:
|
|||||||
) -> InstallResult:
|
) -> InstallResult:
|
||||||
if not source.is_dir():
|
if not source.is_dir():
|
||||||
raise ValueError(f"extension source is not a directory: {source}")
|
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)
|
_reject_unsafe_files(source)
|
||||||
package = adapt_package(source)
|
manifest = load_manifest(source / MANIFEST_FILENAME)
|
||||||
extension_id = package.manifest.id
|
extension_id = manifest.id
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
staging = self.root / f".install-{uuid4().hex}"
|
staging = self.root / f".install-{uuid4().hex}"
|
||||||
target = self.root / extension_id
|
target = self.root / extension_id
|
||||||
@ -437,10 +354,7 @@ class ExtensionStore:
|
|||||||
staging,
|
staging,
|
||||||
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
|
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
|
||||||
)
|
)
|
||||||
if package.generated:
|
_reject_unsafe_files(staging)
|
||||||
dump_manifest(package.manifest, staging / MANIFEST_FILENAME)
|
|
||||||
_install_node_dependencies(staging, package.manifest)
|
|
||||||
_reject_unsafe_files(staging, allow_installed_node_links=True)
|
|
||||||
integrity = _tree_hash(staging)
|
integrity = _tree_hash(staging)
|
||||||
if target.exists():
|
if target.exists():
|
||||||
target.rename(backup)
|
target.rename(backup)
|
||||||
@ -448,12 +362,12 @@ class ExtensionStore:
|
|||||||
staging.rename(target)
|
staging.rename(target)
|
||||||
target_installed = True
|
target_installed = True
|
||||||
requested_permissions = {
|
requested_permissions = {
|
||||||
permission.name for permission in package.manifest.permissions
|
permission.name for permission in manifest.permissions
|
||||||
}
|
}
|
||||||
unchanged = bool(previous and previous.integrity == integrity)
|
unchanged = bool(previous and previous.integrity == integrity)
|
||||||
record = InstalledExtension(
|
record = InstalledExtension(
|
||||||
id=extension_id,
|
id=extension_id,
|
||||||
version=package.manifest.version,
|
version=manifest.version,
|
||||||
source=source_kind,
|
source=source_kind,
|
||||||
source_ref=source_ref,
|
source_ref=source_ref,
|
||||||
integrity=integrity,
|
integrity=integrity,
|
||||||
@ -473,7 +387,7 @@ class ExtensionStore:
|
|||||||
records[extension_id] = record
|
records[extension_id] = record
|
||||||
self._write_records(records)
|
self._write_records(records)
|
||||||
shutil.rmtree(backup, ignore_errors=True)
|
shutil.rmtree(backup, ignore_errors=True)
|
||||||
return InstallResult(record, package)
|
return InstallResult(record, manifest)
|
||||||
except Exception:
|
except Exception:
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
if target_installed:
|
if target_installed:
|
||||||
@ -498,7 +412,7 @@ class ExtensionStore:
|
|||||||
**changes: Any,
|
**changes: Any,
|
||||||
) -> InstalledExtension:
|
) -> InstalledExtension:
|
||||||
try:
|
try:
|
||||||
record = replace(records[extension_id], **changes)
|
record = records[extension_id].model_copy(update=changes)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"extension '{extension_id}' is not installed"
|
f"extension '{extension_id}' is not installed"
|
||||||
@ -512,10 +426,7 @@ class ExtensionStore:
|
|||||||
payload = {
|
payload = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"extensions": [
|
"extensions": [
|
||||||
{
|
record.model_dump(mode="json")
|
||||||
**asdict(record),
|
|
||||||
"source": record.source.value,
|
|
||||||
}
|
|
||||||
for record in sorted(records.values(), key=lambda item: item.id)
|
for record in sorted(records.values(), key=lambda item: item.id)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@ -524,71 +435,6 @@ class ExtensionStore:
|
|||||||
os.replace(temp, self.registry_path)
|
os.replace(temp, self.registry_path)
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_RECORD = InstalledExtension(
|
|
||||||
id="",
|
|
||||||
version="",
|
|
||||||
source=ExtensionSourceKind.LOCAL,
|
|
||||||
source_ref="",
|
|
||||||
integrity="",
|
|
||||||
installed_at="",
|
|
||||||
enabled=True,
|
|
||||||
trusted=False,
|
|
||||||
granted_permissions=(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _install_node_dependencies(root: Path, manifest: ExtensionManifest) -> None:
|
|
||||||
package_path = root / "package.json"
|
|
||||||
if not package_path.is_file():
|
|
||||||
return
|
|
||||||
original = package_path.read_text(encoding="utf-8")
|
|
||||||
package = json.loads(original)
|
|
||||||
if not isinstance(package, dict):
|
|
||||||
raise ValueError("extension package.json must be an object")
|
|
||||||
dependencies = _dependency_mapping(package, "dependencies")
|
|
||||||
peer_dependencies = _dependency_mapping(package, "peerDependencies")
|
|
||||||
peer_metadata = _dependency_mapping(package, "peerDependenciesMeta")
|
|
||||||
for name, specifier in peer_dependencies.items():
|
|
||||||
metadata = peer_metadata.get(name, {})
|
|
||||||
if metadata is not None and not isinstance(metadata, dict):
|
|
||||||
raise ValueError(
|
|
||||||
f"extension package.json peerDependenciesMeta.{name} must be an object"
|
|
||||||
)
|
|
||||||
if not metadata or metadata.get("optional") is not True:
|
|
||||||
dependencies.setdefault(name, specifier)
|
|
||||||
for dependency in manifest.dependencies:
|
|
||||||
if dependency.kind is not DependencyKind.NPM or dependency.optional:
|
|
||||||
continue
|
|
||||||
dependencies[dependency.name] = dependency.specifier or "latest"
|
|
||||||
optional = _dependency_mapping(package, "optionalDependencies")
|
|
||||||
for name, specifier in {**dependencies, **optional}.items():
|
|
||||||
_validate_npm_dependency_spec(str(name), specifier)
|
|
||||||
if not dependencies and not optional:
|
|
||||||
return
|
|
||||||
runtime_package = {
|
|
||||||
"name": "nanobot-extension-runtime",
|
|
||||||
"private": True,
|
|
||||||
"dependencies": dependencies,
|
|
||||||
"optionalDependencies": optional,
|
|
||||||
}
|
|
||||||
package_path.write_text(json.dumps(runtime_package), encoding="utf-8")
|
|
||||||
try:
|
|
||||||
_run(
|
|
||||||
[
|
|
||||||
"npm",
|
|
||||||
"install",
|
|
||||||
"--omit=dev",
|
|
||||||
"--ignore-scripts",
|
|
||||||
"--no-audit",
|
|
||||||
"--no-fund",
|
|
||||||
"--package-lock=false",
|
|
||||||
],
|
|
||||||
cwd=root,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
package_path.write_text(original, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _run(command: list[str], *, cwd: Path | None = None) -> str:
|
def _run(command: list[str], *, cwd: Path | None = None) -> str:
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
@ -605,15 +451,6 @@ def _run(command: list[str], *, cwd: Path | None = None) -> str:
|
|||||||
raise RuntimeError(f"{command[0]} failed: {detail}") from exc
|
raise RuntimeError(f"{command[0]} failed: {detail}") from exc
|
||||||
|
|
||||||
|
|
||||||
def _dependency_mapping(package: dict[str, Any], key: str) -> dict[str, Any]:
|
|
||||||
value = package.get(key)
|
|
||||||
if value is None:
|
|
||||||
return {}
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError(f"extension package.json {key} must be an object")
|
|
||||||
return dict(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_git_url(url: str) -> None:
|
def _validate_git_url(url: str) -> None:
|
||||||
if not isinstance(url, str) or not url.strip() or any(
|
if not isinstance(url, str) or not url.strip() or any(
|
||||||
character in url for character in ("\0", "\r", "\n")
|
character in url for character in ("\0", "\r", "\n")
|
||||||
@ -644,57 +481,14 @@ def _validate_git_url(url: str) -> None:
|
|||||||
raise ValueError("extension Git source must be a remote repository URL")
|
raise ValueError("extension Git source must be a remote repository URL")
|
||||||
|
|
||||||
|
|
||||||
def _validate_npm_package_spec(spec: str) -> None:
|
def _reject_unsafe_files(root: Path) -> None:
|
||||||
if (
|
|
||||||
not isinstance(spec, str)
|
|
||||||
or _NPM_PACKAGE_SPEC.fullmatch(spec.strip()) is None
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"extension npm source must be a registry package name with an "
|
|
||||||
"optional version or tag"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_npm_dependency_spec(name: str, specifier: object) -> None:
|
|
||||||
if not isinstance(specifier, str) or not specifier.strip():
|
|
||||||
raise ValueError(f"npm dependency '{name}' must use a non-empty registry specifier")
|
|
||||||
value = specifier.strip()
|
|
||||||
if any(character in value for character in ("\0", "\r", "\n")):
|
|
||||||
raise ValueError(f"npm dependency '{name}' contains invalid characters")
|
|
||||||
if value.startswith("npm:"):
|
|
||||||
if _NPM_ALIAS.fullmatch(value) is not None:
|
|
||||||
return
|
|
||||||
elif not any(character in value for character in (":", "/", "\\")):
|
|
||||||
return
|
|
||||||
raise ValueError(
|
|
||||||
f"npm dependency '{name}' must resolve through the npm registry"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_unsafe_files(
|
|
||||||
root: Path,
|
|
||||||
*,
|
|
||||||
allow_installed_node_links: bool = False,
|
|
||||||
) -> None:
|
|
||||||
package_root = root.resolve()
|
|
||||||
for path in root.rglob("*"):
|
for path in root.rglob("*"):
|
||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
relative = path.relative_to(root)
|
|
||||||
if (
|
|
||||||
allow_installed_node_links
|
|
||||||
and "node_modules" in relative.parts
|
|
||||||
and _link_target(path).is_relative_to(package_root)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
raise ValueError(f"extension packages cannot contain symlinks: {path}")
|
raise ValueError(f"extension packages cannot contain symlinks: {path}")
|
||||||
if not path.is_file() and not path.is_dir():
|
if not path.is_file() and not path.is_dir():
|
||||||
raise ValueError(f"extension package contains a special file: {path}")
|
raise ValueError(f"extension package contains a special file: {path}")
|
||||||
|
|
||||||
|
|
||||||
def _link_target(path: Path) -> Path:
|
|
||||||
return (path.parent / os.readlink(path)).resolve()
|
|
||||||
|
|
||||||
|
|
||||||
def _tree_hash(root: Path) -> str:
|
def _tree_hash(root: Path) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
for path in sorted(
|
for path in sorted(
|
||||||
@ -715,25 +509,3 @@ def _tree_hash(root: Path) -> str:
|
|||||||
while chunk := handle.read(1024 * 1024):
|
while chunk := handle.read(1024 * 1024):
|
||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
return f"sha256:{digest.hexdigest()}"
|
return f"sha256:{digest.hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
def _extract_tar(archive: Path, target: Path) -> None:
|
|
||||||
if archive.stat().st_size > _MAX_ARCHIVE_BYTES:
|
|
||||||
raise ValueError("npm package archive exceeds the 128 MB limit")
|
|
||||||
with tarfile.open(archive) as bundle:
|
|
||||||
members = bundle.getmembers()
|
|
||||||
if len(members) > _MAX_ARCHIVE_MEMBERS:
|
|
||||||
raise ValueError("npm package archive contains too many files")
|
|
||||||
extracted_bytes = 0
|
|
||||||
for member in members:
|
|
||||||
destination = (target / member.name).resolve()
|
|
||||||
if not destination.is_relative_to(target.resolve()):
|
|
||||||
raise ValueError("npm package archive contains a path traversal")
|
|
||||||
if member.issym() or member.islnk():
|
|
||||||
raise ValueError("npm package archive contains a link")
|
|
||||||
if not member.isfile() and not member.isdir():
|
|
||||||
raise ValueError("npm package archive contains a special file")
|
|
||||||
extracted_bytes += member.size
|
|
||||||
if extracted_bytes > _MAX_EXTRACTED_BYTES:
|
|
||||||
raise ValueError("npm package archive exceeds the 512 MB extracted limit")
|
|
||||||
bundle.extractall(target, members=members)
|
|
||||||
|
|||||||
@ -11,7 +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 import ExtensionHost
|
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 (
|
||||||
@ -334,7 +334,6 @@ class Nanobot:
|
|||||||
return
|
return
|
||||||
async with self._extensions_lock:
|
async with self._extensions_lock:
|
||||||
if not self._extensions_started:
|
if not self._extensions_started:
|
||||||
await self._loop._connect_mcp()
|
|
||||||
await self._extensions.reload()
|
await self._extensions.reload()
|
||||||
self._extensions_started = True
|
self._extensions_started = True
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,6 @@ class WebUIExtensionsRouter:
|
|||||||
*,
|
*,
|
||||||
service: ExtensionService | None,
|
service: ExtensionService | None,
|
||||||
check_api_token: Callable[[WsRequest], bool],
|
check_api_token: Callable[[WsRequest], bool],
|
||||||
parse_query: Callable[[str], dict[str, list[str]]],
|
|
||||||
json_response: Callable[[dict[str, Any]], Response],
|
json_response: Callable[[dict[str, Any]], Response],
|
||||||
error_response: Callable[[int, str | None], Response],
|
error_response: Callable[[int, str | None], Response],
|
||||||
allow_remote_package_install: bool = False,
|
allow_remote_package_install: bool = False,
|
||||||
@ -42,7 +41,6 @@ class WebUIExtensionsRouter:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self._service = service
|
self._service = service
|
||||||
self._check_api_token = check_api_token
|
self._check_api_token = check_api_token
|
||||||
self._parse_query = parse_query
|
|
||||||
self._json_response = json_response
|
self._json_response = json_response
|
||||||
self._error_response = error_response
|
self._error_response = error_response
|
||||||
self._allow_remote_package_install = allow_remote_package_install
|
self._allow_remote_package_install = allow_remote_package_install
|
||||||
@ -65,17 +63,6 @@ class WebUIExtensionsRouter:
|
|||||||
if _method(request) != "GET":
|
if _method(request) != "GET":
|
||||||
return self._error_response(405, "Method not allowed")
|
return self._error_response(405, "Method not allowed")
|
||||||
return self._json_response(await self._service.status())
|
return self._json_response(await self._service.status())
|
||||||
if path == "/api/extensions/market":
|
|
||||||
if _method(request) != "GET":
|
|
||||||
return self._error_response(405, "Method not allowed")
|
|
||||||
query = self._parse_query(request.path)
|
|
||||||
return self._json_response(
|
|
||||||
await self._service.search(
|
|
||||||
_first(query, "q"),
|
|
||||||
ecosystem=_first(query, "ecosystem") or "all",
|
|
||||||
limit=_int_value(_first(query, "limit"), default=30),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
action = _ACTION_PATHS.get(path)
|
action = _ACTION_PATHS.get(path)
|
||||||
if action is None:
|
if action is None:
|
||||||
return None
|
return None
|
||||||
@ -89,7 +76,7 @@ class WebUIExtensionsRouter:
|
|||||||
values = self._values(request)
|
values = self._values(request)
|
||||||
if (
|
if (
|
||||||
action == "install"
|
action == "install"
|
||||||
and str(values.get("kind") or "npm") == "local"
|
and str(values.get("kind") or "git") == "local"
|
||||||
and not is_local_browser_request(connection, request.headers)
|
and not is_local_browser_request(connection, request.headers)
|
||||||
):
|
):
|
||||||
return self._error_response(
|
return self._error_response(
|
||||||
@ -116,7 +103,7 @@ class WebUIExtensionsRouter:
|
|||||||
raise ValueError("Missing extension source")
|
raise ValueError("Missing extension source")
|
||||||
return await self._service.install(
|
return await self._service.install(
|
||||||
source,
|
source,
|
||||||
kind=str(values.get("kind") or "npm"),
|
kind=str(values.get("kind") or "git"),
|
||||||
ref=str(values.get("ref") or ""),
|
ref=str(values.get("ref") or ""),
|
||||||
trusted=False,
|
trusted=False,
|
||||||
)
|
)
|
||||||
@ -164,19 +151,5 @@ class WebUIExtensionsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _first(query: dict[str, list[str]], key: str) -> str:
|
|
||||||
values = query.get(key, [])
|
|
||||||
return values[0] if values else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _int_value(value: str, *, default: int) -> int:
|
|
||||||
if not value:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("Extension market limit must be a number") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _method(request: WsRequest) -> str:
|
def _method(request: WsRequest) -> str:
|
||||||
return str(getattr(request, "method", "GET")).upper()
|
return str(getattr(request, "method", "GET")).upper()
|
||||||
|
|||||||
@ -212,7 +212,6 @@ class GatewayHTTPHandler:
|
|||||||
self.extensions_routes = WebUIExtensionsRouter(
|
self.extensions_routes = WebUIExtensionsRouter(
|
||||||
service=extension_service,
|
service=extension_service,
|
||||||
check_api_token=self.check_api_token,
|
check_api_token=self.check_api_token,
|
||||||
parse_query=_parse_query,
|
|
||||||
json_response=_http_json_response,
|
json_response=_http_json_response,
|
||||||
error_response=_http_error,
|
error_response=_http_error,
|
||||||
allow_remote_package_install=allow_remote_package_install,
|
allow_remote_package_install=allow_remote_package_install,
|
||||||
|
|||||||
@ -122,7 +122,6 @@ allow-direct-references = true
|
|||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
include = [
|
include = [
|
||||||
"nanobot/**/*.py",
|
"nanobot/**/*.py",
|
||||||
"nanobot/extensions/**/*.mjs",
|
|
||||||
"nanobot/templates/**/*.md",
|
"nanobot/templates/**/*.md",
|
||||||
"nanobot/skills/**/*.md",
|
"nanobot/skills/**/*.md",
|
||||||
"nanobot/skills/**/*.sh",
|
"nanobot/skills/**/*.sh",
|
||||||
|
|||||||
@ -1461,11 +1461,10 @@ 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.ExtensionHost", _FakeExtensionHost), \
|
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
|
||||||
agent_loop._connect_mcp = AsyncMock(return_value=None)
|
|
||||||
agent_loop.process_direct = AsyncMock(
|
agent_loop.process_direct = AsyncMock(
|
||||||
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
|
return_value=OutboundMessage(channel="cli", chat_id="direct", content="mock-response"),
|
||||||
)
|
)
|
||||||
@ -1547,14 +1546,11 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.extensions.ExtensionHost", _FakeExtensionHost)
|
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)])
|
||||||
@ -1592,15 +1588,12 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
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.ExtensionHost", _FakeExtensionHost)
|
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)])
|
||||||
@ -1646,15 +1639,12 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
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.ExtensionHost", _FakeExtensionHost)
|
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(
|
||||||
@ -1706,15 +1696,12 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
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.ExtensionHost", _FakeExtensionHost)
|
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
|
||||||
)
|
)
|
||||||
@ -1901,7 +1888,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.ExtensionHost", _FakeExtensionHost)
|
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),
|
||||||
@ -1998,9 +1985,6 @@ def test_heartbeat_empty_response_still_retains_recent_messages(
|
|||||||
async def process_direct(self, *_args, **_kwargs):
|
async def process_direct(self, *_args, **_kwargs):
|
||||||
return SimpleNamespace(content="")
|
return SimpleNamespace(content="")
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -2487,10 +2471,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,
|
||||||
@ -2661,9 +2645,6 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
|||||||
async def submit_cron_turn(self, _msg: InboundMessage):
|
async def submit_cron_turn(self, _msg: InboundMessage):
|
||||||
raise AssertionError("unbound cron job must not run as a bound cron turn")
|
raise AssertionError("unbound cron job must not run as a bound cron turn")
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -2782,9 +2763,6 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
content="Checked the repo.",
|
content="Checked the repo.",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -3005,9 +2983,6 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
|
|||||||
self.runtime_resolver.invalidate.assert_called_once_with()
|
self.runtime_resolver.invalidate.assert_called_once_with()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -3252,9 +3227,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -3452,9 +3424,6 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
|
|||||||
finally:
|
finally:
|
||||||
seen["agent_task_cleaned_up"] = True
|
seen["agent_task_cleaned_up"] = True
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
raise AssertionError("gateway must not close MCP from the outer task")
|
raise AssertionError("gateway must not close MCP from the outer task")
|
||||||
|
|
||||||
@ -3554,9 +3523,6 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|||||||
finally:
|
finally:
|
||||||
seen["agent_task_cleaned_up"] = True
|
seen["agent_task_cleaned_up"] = True
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
raise AssertionError("gateway must not close MCP from the outer task")
|
raise AssertionError("gateway must not close MCP from the outer task")
|
||||||
|
|
||||||
@ -3676,6 +3642,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,
|
||||||
|
|||||||
@ -20,13 +20,10 @@ class _Service:
|
|||||||
"id": "sample",
|
"id": "sample",
|
||||||
"name": "Sample",
|
"name": "Sample",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"runtime": "pi",
|
|
||||||
"scope": "user",
|
|
||||||
"description": "Example extension",
|
"description": "Example extension",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"trusted": False,
|
"trusted": False,
|
||||||
"active": False,
|
"active": False,
|
||||||
"contributions": [],
|
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"permissions": [{"name": "network", "reason": "Fetch data"}],
|
"permissions": [{"name": "network", "reason": "Fetch data"}],
|
||||||
"granted_permissions": [],
|
"granted_permissions": [],
|
||||||
@ -35,10 +32,6 @@ class _Service:
|
|||||||
"diagnostics": [],
|
"diagnostics": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def search(self, query, *, ecosystem, limit):
|
|
||||||
self.calls.append(("search", (query, ecosystem, limit)))
|
|
||||||
return {"packages": []}
|
|
||||||
|
|
||||||
async def install(self, source, *, kind, ref, trusted):
|
async def install(self, source, *, kind, ref, trusted):
|
||||||
self.calls.append(("install", (source, kind, ref, trusted)))
|
self.calls.append(("install", (source, kind, ref, trusted)))
|
||||||
return {
|
return {
|
||||||
@ -80,27 +73,21 @@ def _runner(service: _Service):
|
|||||||
return CliRunner(), app, output
|
return CliRunner(), app, output
|
||||||
|
|
||||||
|
|
||||||
def test_extension_cli_inspects_and_searches() -> None:
|
def test_extension_cli_inspects() -> None:
|
||||||
service = _Service()
|
service = _Service()
|
||||||
runner, app, output = _runner(service)
|
runner, app, output = _runner(service)
|
||||||
|
|
||||||
inspected = runner.invoke(app, ["inspect", "sample"])
|
inspected = runner.invoke(app, ["inspect", "sample"])
|
||||||
searched = runner.invoke(app, ["search", "web", "--ecosystem", "pi", "--limit", "7"])
|
|
||||||
|
|
||||||
assert inspected.exit_code == 0
|
assert inspected.exit_code == 0
|
||||||
assert searched.exit_code == 0
|
|
||||||
assert "Sample" in output.getvalue()
|
assert "Sample" in output.getvalue()
|
||||||
assert service.calls == [
|
assert service.calls == [("status", None)]
|
||||||
("status", None),
|
|
||||||
("search", ("web", "pi", 7)),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_extension_cli_install_and_policy_commands() -> None:
|
def test_extension_cli_install_and_policy_commands() -> None:
|
||||||
service = _Service()
|
service = _Service()
|
||||||
runner, app, _output = _runner(service)
|
runner, app, _output = _runner(service)
|
||||||
|
|
||||||
assert runner.invoke(app, ["install", "pi-example"]).exit_code == 0
|
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, ["trust", "sample"]).exit_code == 0
|
||||||
assert runner.invoke(app, ["disable", "sample"]).exit_code == 0
|
assert runner.invoke(app, ["disable", "sample"]).exit_code == 0
|
||||||
assert runner.invoke(
|
assert runner.invoke(
|
||||||
@ -110,7 +97,7 @@ def test_extension_cli_install_and_policy_commands() -> None:
|
|||||||
assert runner.invoke(app, ["uninstall", "sample", "--yes"]).exit_code == 0
|
assert runner.invoke(app, ["uninstall", "sample", "--yes"]).exit_code == 0
|
||||||
|
|
||||||
assert service.calls == [
|
assert service.calls == [
|
||||||
("install", ("pi-example", "npm", "", False)),
|
("install", ("https://example.com/acme.git", "git", "", False)),
|
||||||
("trusted", ("sample", True)),
|
("trusted", ("sample", True)),
|
||||||
("enabled", ("sample", False)),
|
("enabled", ("sample", False)),
|
||||||
("permissions", ("sample", {"network", "filesystem.read"})),
|
("permissions", ("sample", {"network", "filesystem.read"})),
|
||||||
|
|||||||
@ -1,29 +1,7 @@
|
|||||||
import pytest
|
from nanobot.config.schema import Config
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config, ExtensionsConfig
|
|
||||||
|
|
||||||
|
|
||||||
def test_extensions_config_accepts_camel_case_workspace_trust() -> None:
|
def test_extensions_can_be_disabled_globally() -> None:
|
||||||
config = Config.model_validate(
|
config = Config.model_validate({"extensions": {"enabled": False}})
|
||||||
{
|
|
||||||
"extensions": {
|
|
||||||
"workspaceTrust": "allow",
|
|
||||||
"entries": {
|
|
||||||
"acme": {
|
|
||||||
"enabled": True,
|
|
||||||
"trusted": True,
|
|
||||||
"config": {"endpoint": "https://example.com"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert config.extensions.workspace_trust == "allow"
|
assert config.extensions.enabled is False
|
||||||
assert config.extensions.entries["acme"].trusted is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_extensions_config_rejects_overlapping_policy() -> None:
|
|
||||||
with pytest.raises(ValidationError, match="both allow and deny"):
|
|
||||||
ExtensionsConfig(allow=["acme"], deny=["acme"])
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.extensions import build_extension_catalog
|
from nanobot.extensions.catalog import build_extension_catalog
|
||||||
|
from nanobot.extensions.store import ExtensionStore
|
||||||
|
|
||||||
|
|
||||||
def _write_extension(root, extension_id: str) -> None:
|
def _write_extension(root, extension_id: str) -> None:
|
||||||
@ -14,60 +14,39 @@ def _write_extension(root, extension_id: str) -> None:
|
|||||||
"id": extension_id,
|
"id": extension_id,
|
||||||
"name": extension_id,
|
"name": extension_id,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"runtime": "python",
|
"entry": "extension:register",
|
||||||
"contributions": [{"kind": "tool", "name": f"{extension_id}_tool"}],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _catalog(config: Config, user_root):
|
def test_catalog_requires_trust_before_activation(tmp_path) -> None:
|
||||||
with (
|
|
||||||
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
|
||||||
patch("nanobot.providers.registry.PROVIDERS", ()),
|
|
||||||
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
|
||||||
patch(
|
|
||||||
"nanobot.providers.image_generation.image_gen_provider_names",
|
|
||||||
return_value=(),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
return build_extension_catalog(config, user_root=user_root)
|
|
||||||
|
|
||||||
|
|
||||||
def test_installed_extension_requires_explicit_trust(tmp_path) -> None:
|
|
||||||
_write_extension(tmp_path, "acme")
|
_write_extension(tmp_path, "acme")
|
||||||
|
|
||||||
catalog = _catalog(Config(), tmp_path)
|
catalog = build_extension_catalog(Config(), user_root=tmp_path)
|
||||||
|
|
||||||
assert [item.manifest.id for item in catalog.candidates] == ["acme"]
|
assert [item.manifest.id for item in catalog.candidates] == ["acme"]
|
||||||
assert catalog.snapshot.extensions == ()
|
assert catalog.snapshot.extensions == ()
|
||||||
|
|
||||||
|
|
||||||
def test_entry_config_trust_activates_installed_extension(tmp_path) -> None:
|
def test_store_policy_can_trust_an_extension(tmp_path) -> None:
|
||||||
_write_extension(tmp_path, "acme")
|
source = tmp_path / "source"
|
||||||
config = Config.model_validate(
|
_write_extension(source, "acme")
|
||||||
{
|
store = ExtensionStore(tmp_path / "installed")
|
||||||
"extensions": {
|
store.install_local(source / "acme")
|
||||||
"entries": {
|
store.set_trusted("acme", True)
|
||||||
"acme": {
|
|
||||||
"enabled": True,
|
|
||||||
"trusted": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
catalog = _catalog(config, tmp_path)
|
catalog = build_extension_catalog(Config(), user_root=store.root)
|
||||||
|
|
||||||
assert [item.manifest.id for item in catalog.snapshot.extensions] == ["acme"]
|
assert [item.manifest.id for item in catalog.snapshot.extensions] == ["acme"]
|
||||||
assert catalog.snapshot.contributions[0].contribution.name == "acme_tool"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extensions_disabled_keeps_native_catalog_only(tmp_path) -> None:
|
def test_disabled_catalog_discovers_nothing(tmp_path) -> None:
|
||||||
_write_extension(tmp_path, "acme")
|
_write_extension(tmp_path, "acme")
|
||||||
config = Config.model_validate({"extensions": {"enabled": False}})
|
|
||||||
|
|
||||||
catalog = _catalog(config, tmp_path)
|
catalog = build_extension_catalog(
|
||||||
|
Config.model_validate({"extensions": {"enabled": False}}),
|
||||||
|
user_root=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
assert all(item.manifest.id != "acme" for item in catalog.candidates)
|
assert catalog.candidates == ()
|
||||||
|
|||||||
@ -2,11 +2,8 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.extensions import (
|
from nanobot.extensions import ExtensionManifest
|
||||||
ContributionKind,
|
from nanobot.extensions.codec import (
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionRuntime,
|
|
||||||
ManifestFormatError,
|
ManifestFormatError,
|
||||||
dump_manifest,
|
dump_manifest,
|
||||||
load_manifest,
|
load_manifest,
|
||||||
@ -20,14 +17,7 @@ def test_manifest_json_round_trip(tmp_path) -> None:
|
|||||||
id="acme.tools",
|
id="acme.tools",
|
||||||
name="Acme Tools",
|
name="Acme Tools",
|
||||||
version="2.0.0",
|
version="2.0.0",
|
||||||
runtime=ExtensionRuntime.PI,
|
entry="acme_extension:register",
|
||||||
contributions=(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.TOOL,
|
|
||||||
name="acme_search",
|
|
||||||
target="./index.ts#search",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
dump_manifest(original, path)
|
dump_manifest(original, path)
|
||||||
@ -36,27 +26,17 @@ def test_manifest_json_round_trip(tmp_path) -> None:
|
|||||||
assert json.loads(path.read_text())["apiVersion"] == 1
|
assert json.loads(path.read_text())["apiVersion"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_rejects_unknown_fields() -> None:
|
def test_manifest_codec_rejects_unknown_fields() -> None:
|
||||||
with pytest.raises(ManifestFormatError, match="unknown fields: typo"):
|
with pytest.raises(ManifestFormatError, match="unknown fields: typo"):
|
||||||
manifest_from_mapping(
|
manifest_from_mapping(
|
||||||
{
|
{"id": "bad", "name": "Bad", "version": "1.0.0", "typo": True}
|
||||||
"id": "bad",
|
|
||||||
"name": "Bad",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"runtime": "python",
|
|
||||||
"typo": True,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ManifestFormatError, match="unknown fields: contributions"):
|
||||||
|
|
||||||
def test_manifest_rejects_unknown_contribution_kind() -> None:
|
|
||||||
with pytest.raises(ManifestFormatError, match="invalid extension contribution"):
|
|
||||||
manifest_from_mapping(
|
manifest_from_mapping(
|
||||||
{
|
{
|
||||||
"id": "bad",
|
"id": "bad",
|
||||||
"name": "Bad",
|
"name": "Bad",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"runtime": "python",
|
|
||||||
"contributions": [{"kind": "mystery", "name": "unknown"}],
|
"contributions": [{"kind": "mystery", "name": "unknown"}],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from nanobot.extensions import ExtensionScope
|
|
||||||
from nanobot.extensions.discovery import discover_manifest_root
|
from nanobot.extensions.discovery import discover_manifest_root
|
||||||
|
|
||||||
|
|
||||||
@ -13,7 +12,7 @@ def _write_manifest(root, name: str, extension_id: str) -> None:
|
|||||||
"id": extension_id,
|
"id": extension_id,
|
||||||
"name": extension_id,
|
"name": extension_id,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"runtime": "declarative",
|
"entry": "extension:register",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -23,14 +22,9 @@ def test_discovery_reads_metadata_without_importing_runtime(tmp_path) -> None:
|
|||||||
_write_manifest(tmp_path, "one", "one")
|
_write_manifest(tmp_path, "one", "one")
|
||||||
_write_manifest(tmp_path, "two", "two")
|
_write_manifest(tmp_path, "two", "two")
|
||||||
|
|
||||||
result = discover_manifest_root(
|
result = discover_manifest_root(tmp_path)
|
||||||
tmp_path,
|
|
||||||
scope=ExtensionScope.WORKSPACE,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [candidate.manifest.id for candidate in result.candidates] == ["one", "two"]
|
assert [candidate.manifest.id for candidate in result.candidates] == ["one", "two"]
|
||||||
assert all(candidate.trusted for candidate in result.candidates)
|
|
||||||
assert result.diagnostics == ()
|
assert result.diagnostics == ()
|
||||||
|
|
||||||
|
|
||||||
@ -40,23 +34,7 @@ def test_discovery_reports_bad_manifest_without_hiding_good_packages(tmp_path) -
|
|||||||
bad.mkdir()
|
bad.mkdir()
|
||||||
(bad / "nanobot.extension.json").write_text("{")
|
(bad / "nanobot.extension.json").write_text("{")
|
||||||
|
|
||||||
result = discover_manifest_root(tmp_path, scope=ExtensionScope.USER)
|
result = discover_manifest_root(tmp_path)
|
||||||
|
|
||||||
assert [candidate.manifest.id for candidate in result.candidates] == ["good"]
|
assert [candidate.manifest.id for candidate in result.candidates] == ["good"]
|
||||||
assert result.diagnostics[0].code == "invalid_manifest"
|
assert result.diagnostics[0].code == "invalid_manifest"
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_can_leave_unselected_packages_disabled(tmp_path) -> None:
|
|
||||||
_write_manifest(tmp_path, "one", "one")
|
|
||||||
_write_manifest(tmp_path, "two", "two")
|
|
||||||
|
|
||||||
result = discover_manifest_root(
|
|
||||||
tmp_path,
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
enabled_ids=frozenset({"two"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [(item.manifest.id, item.enabled) for item in result.candidates] == [
|
|
||||||
("one", False),
|
|
||||||
("two", True),
|
|
||||||
]
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@ class _Agent:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.tools = ToolRegistry()
|
self.tools = ToolRegistry()
|
||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
self.context = type("Context", (), {"skills": None})()
|
|
||||||
self._hook_factories = []
|
self._hook_factories = []
|
||||||
|
|
||||||
|
|
||||||
@ -44,7 +43,7 @@ async def test_host_reloads_and_closes_runtime(
|
|||||||
await host.close()
|
await host.close()
|
||||||
|
|
||||||
assert host.snapshot is None
|
assert host.snapshot is None
|
||||||
assert first.catalog.snapshot.extensions
|
assert first.catalog.snapshot.extensions == ()
|
||||||
assert second.catalog.snapshot.extensions
|
assert second.catalog.snapshot.extensions == ()
|
||||||
assert len(activated) == 2
|
assert len(activated) == 2
|
||||||
assert len(closed) == 2
|
assert len(closed) == 2
|
||||||
|
|||||||
@ -1,88 +1,53 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.extensions import (
|
from nanobot.extensions import (
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionDependency,
|
ExtensionDependency,
|
||||||
ExtensionManifest,
|
ExtensionManifest,
|
||||||
ExtensionPermission,
|
ExtensionPermission,
|
||||||
ExtensionRuntime,
|
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.manifest import DependencyKind
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_accepts_portable_contributions() -> None:
|
def test_manifest_defaults_to_native_python_entry() -> None:
|
||||||
manifest = ExtensionManifest(
|
manifest = ExtensionManifest(
|
||||||
id="acme.research",
|
id="acme.research",
|
||||||
name="Acme Research",
|
name="Acme Research",
|
||||||
version="1.2.0",
|
version="1.2.0",
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
contributions=(
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.TOOL,
|
|
||||||
name="research",
|
|
||||||
target="acme_nanobot:ResearchTool",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
permissions=(
|
permissions=(
|
||||||
ExtensionPermission(
|
ExtensionPermission(
|
||||||
name="network",
|
name="network",
|
||||||
reason="Fetch sources selected by the user.",
|
reason="Fetch user-selected sources.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert manifest.api_version == 1
|
assert manifest.entry == "extension:register"
|
||||||
assert manifest.contributions[0].name == "research"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("extension_id", ["Uppercase", "../escape", "two words", ""])
|
@pytest.mark.parametrize("extension_id", ["Uppercase", "../escape", "two words", ""])
|
||||||
def test_manifest_rejects_invalid_ids(extension_id: str) -> None:
|
def test_manifest_rejects_invalid_ids(extension_id: str) -> None:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
ExtensionManifest(
|
ExtensionManifest(id=extension_id, name="Invalid", version="1.0.0")
|
||||||
id=extension_id,
|
|
||||||
name="Invalid",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_rejects_duplicate_contributions() -> None:
|
def test_manifest_rejects_duplicate_contract_rows() -> None:
|
||||||
contribution = ExtensionContribution(
|
|
||||||
kind=ContributionKind.SKILL,
|
|
||||||
name="review",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="duplicate contributions"):
|
|
||||||
ExtensionManifest(
|
|
||||||
id="duplicate",
|
|
||||||
name="Duplicate",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
contributions=(contribution, contribution),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_rejects_duplicate_dependencies() -> None:
|
|
||||||
dependency = ExtensionDependency(
|
dependency = ExtensionDependency(
|
||||||
kind=DependencyKind.EXTENSION,
|
kind=DependencyKind.PYTHON,
|
||||||
name="acme.base",
|
name="httpx",
|
||||||
)
|
)
|
||||||
|
permission = ExtensionPermission(name="network")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="duplicate dependencies"):
|
with pytest.raises(ValueError, match="duplicate dependencies"):
|
||||||
ExtensionManifest(
|
ExtensionManifest(
|
||||||
id="duplicate",
|
id="duplicate",
|
||||||
name="Duplicate",
|
name="Duplicate",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
dependencies=(dependency, dependency),
|
dependencies=(dependency, dependency),
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ValueError, match="duplicate permissions"):
|
||||||
|
ExtensionManifest(
|
||||||
def test_dependency_optional_must_be_boolean() -> None:
|
id="duplicate",
|
||||||
with pytest.raises(TypeError, match="optional must be a boolean"):
|
name="Duplicate",
|
||||||
ExtensionDependency(
|
version="1.0.0",
|
||||||
kind=DependencyKind.EXTENSION,
|
permissions=(permission, permission),
|
||||||
name="acme.base",
|
|
||||||
optional="false", # type: ignore[arg-type]
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import json
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions.market import ExtensionMarketplace
|
|
||||||
|
|
||||||
|
|
||||||
def test_market_search_normalizes_npm_packages(monkeypatch) -> None:
|
|
||||||
payload = [
|
|
||||||
{
|
|
||||||
"name": "pi-example",
|
|
||||||
"version": "1.2.3",
|
|
||||||
"description": "Example",
|
|
||||||
"keywords": ["pi-package"],
|
|
||||||
"publisher": {"username": "alice"},
|
|
||||||
"links": {"repository": "https://example.com/repo"},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def run(*_args, **_kwargs):
|
|
||||||
return subprocess.CompletedProcess([], 0, json.dumps(payload), "")
|
|
||||||
|
|
||||||
monkeypatch.setattr(subprocess, "run", run)
|
|
||||||
|
|
||||||
package = ExtensionMarketplace().search("example", ecosystem="pi")[0]
|
|
||||||
|
|
||||||
assert package.name == "pi-example"
|
|
||||||
assert package.ecosystem == "pi"
|
|
||||||
assert package.publisher == "alice"
|
|
||||||
assert package.repository == "https://example.com/repo"
|
|
||||||
|
|
||||||
|
|
||||||
def test_market_rejects_unknown_ecosystem() -> None:
|
|
||||||
with pytest.raises(ValueError, match="unknown extension ecosystem"):
|
|
||||||
ExtensionMarketplace().search(ecosystem="other")
|
|
||||||
|
|
||||||
|
|
||||||
def test_market_ignores_fuzzy_npm_results(monkeypatch) -> None:
|
|
||||||
payload = [
|
|
||||||
{
|
|
||||||
"name": "unrelated",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"keywords": ["pi"],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
subprocess,
|
|
||||||
"run",
|
|
||||||
lambda *_args, **_kwargs: subprocess.CompletedProcess(
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
json.dumps(payload),
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert ExtensionMarketplace().search(ecosystem="pi") == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_market_rejects_invalid_npm_json(monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
subprocess,
|
|
||||||
"run",
|
|
||||||
lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "not json", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="invalid marketplace response"):
|
|
||||||
ExtensionMarketplace().search(ecosystem="pi")
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
import sys
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from nanobot.agent.skills import SkillsLoader
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.command.router import CommandRouter
|
|
||||||
from nanobot.config.schema import Config, MCPServerConfig
|
|
||||||
from nanobot.extensions import (
|
|
||||||
ContributionKind,
|
|
||||||
ExtensionRuntime,
|
|
||||||
discover_native_extensions,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.native import _python_dependencies
|
|
||||||
|
|
||||||
|
|
||||||
class _Tool(Tool):
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "acme_tool"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "Acme tool"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict:
|
|
||||||
return {"type": "object"}
|
|
||||||
|
|
||||||
async def execute(self, **kwargs):
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_inventory_preserves_runtime_ownership(tmp_path) -> None:
|
|
||||||
tools = ToolRegistry()
|
|
||||||
tools.register(_Tool(), owner="acme.extension")
|
|
||||||
commands = CommandRouter()
|
|
||||||
|
|
||||||
async def _handler(_ctx):
|
|
||||||
return None
|
|
||||||
|
|
||||||
commands.exact("/acme", _handler, owner="acme.extension")
|
|
||||||
config = Config()
|
|
||||||
config.tools.mcp_servers["docs"] = MCPServerConfig(url="https://example.com")
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
|
||||||
patch("nanobot.providers.registry.PROVIDERS", ()),
|
|
||||||
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
|
||||||
patch(
|
|
||||||
"nanobot.providers.image_generation.image_gen_provider_names",
|
|
||||||
return_value=(),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result = discover_native_extensions(
|
|
||||||
config,
|
|
||||||
skills=SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "missing"),
|
|
||||||
tools=tools,
|
|
||||||
commands=commands,
|
|
||||||
)
|
|
||||||
|
|
||||||
extensions = {item.manifest.id: item for item in result.candidates}
|
|
||||||
assert {
|
|
||||||
contribution.kind
|
|
||||||
for contribution in extensions["acme.extension"].manifest.contributions
|
|
||||||
} == {ContributionKind.TOOL, ContributionKind.COMMAND}
|
|
||||||
assert extensions["acme.extension"].manifest.runtime is ExtensionRuntime.DECLARATIVE
|
|
||||||
assert "nanobot.mcp.docs" in extensions
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_inventory_projects_workspace_skill(tmp_path) -> None:
|
|
||||||
skill_dir = tmp_path / "skills" / "release"
|
|
||||||
skill_dir.mkdir(parents=True)
|
|
||||||
(skill_dir / "SKILL.md").write_text(
|
|
||||||
"---\n"
|
|
||||||
"name: release\n"
|
|
||||||
"description: Prepare a release.\n"
|
|
||||||
"metadata:\n"
|
|
||||||
" nanobot:\n"
|
|
||||||
" requires:\n"
|
|
||||||
" bins: [gh]\n"
|
|
||||||
"---\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
|
||||||
patch("nanobot.providers.registry.PROVIDERS", ()),
|
|
||||||
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
|
||||||
patch(
|
|
||||||
"nanobot.providers.image_generation.image_gen_provider_names",
|
|
||||||
return_value=(),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result = discover_native_extensions(
|
|
||||||
Config(),
|
|
||||||
skills=SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "missing"),
|
|
||||||
)
|
|
||||||
|
|
||||||
skill = next(
|
|
||||||
item for item in result.candidates if item.manifest.id == "nanobot.skill.release"
|
|
||||||
)
|
|
||||||
assert skill.scope.name == "WORKSPACE"
|
|
||||||
assert skill.manifest.runtime is ExtensionRuntime.DECLARATIVE
|
|
||||||
assert skill.manifest.contributions[0].description == "Prepare a release."
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_inventory_collapses_command_routing_tiers() -> None:
|
|
||||||
commands = CommandRouter()
|
|
||||||
|
|
||||||
async def _handler(_ctx):
|
|
||||||
return None
|
|
||||||
|
|
||||||
commands.priority("/status", _handler)
|
|
||||||
commands.exact("/status", _handler)
|
|
||||||
commands.exact("/model", _handler)
|
|
||||||
commands.prefix("/model ", _handler)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("nanobot.channels.registry.discover_plugins", return_value={}),
|
|
||||||
patch("nanobot.providers.registry.PROVIDERS", ()),
|
|
||||||
patch("nanobot.audio.transcription_registry.TRANSCRIPTION_PROVIDERS", ()),
|
|
||||||
patch(
|
|
||||||
"nanobot.providers.image_generation.image_gen_provider_names",
|
|
||||||
return_value=(),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
result = discover_native_extensions(Config(), commands=commands)
|
|
||||||
|
|
||||||
core = next(
|
|
||||||
item for item in result.candidates if item.manifest.id == "nanobot.core"
|
|
||||||
)
|
|
||||||
assert [
|
|
||||||
contribution.name for contribution in core.manifest.contributions
|
|
||||||
] == ["model", "status"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_dependencies_project_distribution_identity_and_active_markers() -> None:
|
|
||||||
dependencies = _python_dependencies(
|
|
||||||
(
|
|
||||||
"httpx[http2]>=0.27",
|
|
||||||
f"platform-package>=1; sys_platform == '{sys.platform}'",
|
|
||||||
"inactive-package>=1; sys_platform == '__never__'",
|
|
||||||
"packaging>=24; python_version >= '3.11'",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert [(item.name, item.specifier) for item in dependencies] == [
|
|
||||||
("httpx", ">=0.27"),
|
|
||||||
("platform-package", ">=1"),
|
|
||||||
("packaging", ">=24"),
|
|
||||||
]
|
|
||||||
@ -1,342 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions.compatibility import CompatibleExtension
|
|
||||||
from nanobot.extensions.node_host import NodeSidecar
|
|
||||||
|
|
||||||
|
|
||||||
def _write(path: Path, content: str) -> Path:
|
|
||||||
path.write_text(content)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pi_extension_loads_tools_commands_and_events(tmp_path: Path) -> None:
|
|
||||||
entry = _write(
|
|
||||||
tmp_path / "pi-extension.mjs",
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "pi_echo",
|
|
||||||
label: "Echo",
|
|
||||||
description: "Echo input",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: { text: { type: "string" } },
|
|
||||||
required: ["text"]
|
|
||||||
},
|
|
||||||
async execute(_id, params) {
|
|
||||||
return { content: [{ type: "text", text: `pi:${params.text}` }] };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pi.registerCommand("hello", {
|
|
||||||
description: "Say hello",
|
|
||||||
async handler(args, ctx) { ctx.ui.notify(`hello:${args}`); }
|
|
||||||
});
|
|
||||||
pi.on("agent_start", (event) => {
|
|
||||||
if (!event.messages) throw new Error("missing messages");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
result = await host.load(
|
|
||||||
runtime="pi",
|
|
||||||
entries=(entry,),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.pi",
|
|
||||||
name="Pi test",
|
|
||||||
version="1.0.0",
|
|
||||||
workspace=tmp_path,
|
|
||||||
)
|
|
||||||
extension = CompatibleExtension(
|
|
||||||
host=host,
|
|
||||||
runtime="pi",
|
|
||||||
owner="test.pi",
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
assert [(item.kind, item.name) for item in result.registrations] == [
|
|
||||||
("tool", "pi_echo"),
|
|
||||||
("command", "hello"),
|
|
||||||
("hook", "agent_start"),
|
|
||||||
]
|
|
||||||
assert await extension.tools[0].execute(text="ok") == "pi:ok"
|
|
||||||
assert extension.hook is not None
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> None:
|
|
||||||
entry = _write(
|
|
||||||
tmp_path / "openclaw-plugin.cjs",
|
|
||||||
"""
|
|
||||||
module.exports = {
|
|
||||||
id: "test.openclaw",
|
|
||||||
register(api) {
|
|
||||||
api.registerTool({
|
|
||||||
name: "claw_echo",
|
|
||||||
description: "Echo input",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: { text: { type: "string" } },
|
|
||||||
required: ["text"]
|
|
||||||
},
|
|
||||||
async execute(_id, params) {
|
|
||||||
return { content: [{ type: "text", text: `claw:${params.text}` }] };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
api.registerCommand({
|
|
||||||
name: "status",
|
|
||||||
description: "Show status",
|
|
||||||
handler: async () => ({ text: "ready" })
|
|
||||||
});
|
|
||||||
api.on("agent_end", () => undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
result = await host.load(
|
|
||||||
runtime="openclaw",
|
|
||||||
entries=(entry,),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.openclaw",
|
|
||||||
name="OpenClaw test",
|
|
||||||
version="1.0.0",
|
|
||||||
workspace=tmp_path,
|
|
||||||
)
|
|
||||||
extension = CompatibleExtension(
|
|
||||||
host=host,
|
|
||||||
runtime="openclaw",
|
|
||||||
owner="test.openclaw",
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
assert await extension.tools[0].execute(text="ok") == "claw:ok"
|
|
||||||
assert {item.name for item in result.registrations} == {
|
|
||||||
"claw_echo",
|
|
||||||
"status",
|
|
||||||
"agent_end",
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pi_package_loads_every_declared_entry(tmp_path: Path) -> None:
|
|
||||||
first = _write(
|
|
||||||
tmp_path / "first.mjs",
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerCommand("first", { handler: async () => "first" });
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
second = _write(
|
|
||||||
tmp_path / "second.mjs",
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerCommand("second", { handler: async () => "second" });
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
result = await host.load(
|
|
||||||
runtime="pi",
|
|
||||||
entries=(first, second),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.multi",
|
|
||||||
name="Pi multi-entry test",
|
|
||||||
version="1.0.0",
|
|
||||||
workspace=tmp_path,
|
|
||||||
)
|
|
||||||
assert {
|
|
||||||
item.name for item in result.registrations if item.kind == "command"
|
|
||||||
} == {"first", "second"}
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("kind", ["tool", "command"])
|
|
||||||
async def test_pi_package_rejects_duplicate_registrations(
|
|
||||||
tmp_path: Path,
|
|
||||||
kind: str,
|
|
||||||
) -> None:
|
|
||||||
registration = (
|
|
||||||
"""
|
|
||||||
pi.registerTool({
|
|
||||||
name: "duplicate",
|
|
||||||
description: "Duplicate",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
async execute() { return "ok"; }
|
|
||||||
});
|
|
||||||
"""
|
|
||||||
if kind == "tool"
|
|
||||||
else 'pi.registerCommand("duplicate", { handler: async () => "ok" });'
|
|
||||||
)
|
|
||||||
first = _write(
|
|
||||||
tmp_path / "first.mjs",
|
|
||||||
f"export default function (pi) {{ {registration} }}",
|
|
||||||
)
|
|
||||||
second = _write(
|
|
||||||
tmp_path / "second.mjs",
|
|
||||||
f"export default function (pi) {{ {registration} }}",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
with pytest.raises(RuntimeError, match=f"{kind} 'duplicate' is already registered"):
|
|
||||||
await host.load(
|
|
||||||
runtime="pi",
|
|
||||||
entries=(first, second),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.duplicate",
|
|
||||||
name="Duplicate registrations",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sidecar_accepts_tool_results_larger_than_default_stream_limit(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
entry = _write(
|
|
||||||
tmp_path / "large-result.mjs",
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "large_result",
|
|
||||||
description: "Return a large result",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
async execute() { return "x".repeat(128 * 1024); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
result = await host.load(
|
|
||||||
runtime="pi",
|
|
||||||
entries=(entry,),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.large",
|
|
||||||
name="Large result",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
extension = CompatibleExtension(
|
|
||||||
host=host,
|
|
||||||
runtime="pi",
|
|
||||||
owner="test.large",
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
|
|
||||||
output = await extension.tools[0].execute()
|
|
||||||
|
|
||||||
assert len(output) == 128 * 1024
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sidecar_terminates_extension_after_request_timeout(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
entry = _write(
|
|
||||||
tmp_path / "timeout.mjs",
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "never_finishes",
|
|
||||||
description: "Never finishes",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
async execute() { await new Promise(() => {}); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
await host.load(
|
|
||||||
runtime="pi",
|
|
||||||
entries=(entry,),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.timeout",
|
|
||||||
name="Timeout",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="timed out"):
|
|
||||||
await host.request(
|
|
||||||
"extension.call",
|
|
||||||
{
|
|
||||||
"kind": "tool",
|
|
||||||
"name": "never_finishes",
|
|
||||||
"callId": "test",
|
|
||||||
"input": {},
|
|
||||||
},
|
|
||||||
timeout=0.05,
|
|
||||||
)
|
|
||||||
assert not host.running
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancelled_sidecar_call_does_not_fail_the_next_request(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
entry = _write(
|
|
||||||
tmp_path / "cancel.mjs",
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "slow",
|
|
||||||
description: "Finish later",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
async execute() {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
return "slow";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pi.registerTool({
|
|
||||||
name: "fast",
|
|
||||||
description: "Finish immediately",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
async execute() { return "fast"; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
host = NodeSidecar()
|
|
||||||
try:
|
|
||||||
result = await host.load(
|
|
||||||
runtime="pi",
|
|
||||||
entries=(entry,),
|
|
||||||
root=tmp_path,
|
|
||||||
extension_id="test.cancel",
|
|
||||||
name="Cancellation",
|
|
||||||
version="1.0.0",
|
|
||||||
)
|
|
||||||
extension = CompatibleExtension(
|
|
||||||
host=host,
|
|
||||||
runtime="pi",
|
|
||||||
owner="test.cancel",
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
|
|
||||||
slow = asyncio.create_task(extension.tools[0].execute())
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
slow.cancel()
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await slow
|
|
||||||
|
|
||||||
assert await extension.tools[1].execute() == "fast"
|
|
||||||
finally:
|
|
||||||
await host.close()
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.extensions import (
|
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionRuntime,
|
|
||||||
adapt_package,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_adapts_pi_package_metadata(tmp_path: Path) -> None:
|
|
||||||
(tmp_path / "package.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"name": "@acme/pi-tools",
|
|
||||||
"version": "1.2.3",
|
|
||||||
"description": "Pi tools",
|
|
||||||
"pi": {"extensions": ["./index.ts", "./review.ts"]},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = adapt_package(tmp_path)
|
|
||||||
|
|
||||||
assert result.generated
|
|
||||||
assert result.manifest.id == "pi.acme.pi-tools"
|
|
||||||
assert result.manifest.runtime is ExtensionRuntime.PI
|
|
||||||
assert result.manifest.activation_entries == ("./index.ts", "./review.ts")
|
|
||||||
assert len(result.manifest.dependencies) == 1
|
|
||||||
assert result.manifest.dependencies[0].name == "jiti"
|
|
||||||
assert result.manifest.dependencies[0].specifier == "^2.4.2"
|
|
||||||
assert result.manifest.permissions[0].name == "runtime.node"
|
|
||||||
|
|
||||||
|
|
||||||
def test_adapts_openclaw_contracts_without_loading_code(tmp_path: Path) -> None:
|
|
||||||
(tmp_path / "package.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"name": "@openclaw/search-plugin",
|
|
||||||
"version": "2.0.0",
|
|
||||||
"peerDependencies": {"openclaw": ">=2.0.0"},
|
|
||||||
"openclaw": {
|
|
||||||
"extensions": ["./index.ts"],
|
|
||||||
"runtimeExtensions": ["./dist/index.js"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
(tmp_path / "openclaw.plugin.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"id": "search",
|
|
||||||
"name": "Search",
|
|
||||||
"contracts": {
|
|
||||||
"tools": ["search_tool"],
|
|
||||||
"webSearchProviders": ["private-search"],
|
|
||||||
"speechProviders": ["speech"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = adapt_package(tmp_path)
|
|
||||||
|
|
||||||
assert result.manifest.id == "openclaw.search"
|
|
||||||
assert result.manifest.activation_entries == ("./dist/index.js",)
|
|
||||||
assert result.manifest.dependencies[0].kind is DependencyKind.NPM
|
|
||||||
assert result.manifest.dependencies[0].specifier == ">=2.0.0"
|
|
||||||
assert {
|
|
||||||
(item.kind, item.name) for item in result.manifest.contributions
|
|
||||||
} == {
|
|
||||||
(ContributionKind.TOOL, "search_tool"),
|
|
||||||
(ContributionKind.WEB_SEARCH_PROVIDER, "private-search"),
|
|
||||||
}
|
|
||||||
assert "speechProviders" in result.diagnostics[0]
|
|
||||||
assert result.manifest.permissions[0].name == "runtime.node"
|
|
||||||
@ -1,86 +1,53 @@
|
|||||||
from pathlib import Path
|
from nanobot.extensions import ExtensionDependency, ExtensionManifest
|
||||||
|
from nanobot.extensions.manifest import DependencyKind
|
||||||
from nanobot.extensions import (
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionRuntime,
|
|
||||||
ExtensionScope,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.preflight import evaluate_dependencies
|
from nanobot.extensions.preflight import evaluate_dependencies
|
||||||
|
from nanobot.extensions.registry import ExtensionCandidate
|
||||||
|
|
||||||
|
|
||||||
def _candidate(
|
def _candidate(dependency: ExtensionDependency) -> ExtensionCandidate:
|
||||||
dependency: ExtensionDependency,
|
|
||||||
*,
|
|
||||||
location: Path | None = None,
|
|
||||||
) -> ExtensionCandidate:
|
|
||||||
return ExtensionCandidate(
|
return ExtensionCandidate(
|
||||||
manifest=ExtensionManifest(
|
ExtensionManifest(
|
||||||
id="preflight.test",
|
id="preflight.test",
|
||||||
name="Preflight test",
|
name="Preflight test",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
dependencies=(dependency,),
|
dependencies=(dependency,),
|
||||||
),
|
),
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
location=location,
|
|
||||||
trusted=True,
|
trusted=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_missing_environment_dependency_disables_extension(
|
def test_missing_environment_dependency_disables_extension(monkeypatch) -> None:
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
|
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
|
||||||
candidate = _candidate(
|
|
||||||
ExtensionDependency(
|
candidates, diagnostics = evaluate_dependencies(
|
||||||
kind=DependencyKind.ENVIRONMENT,
|
(
|
||||||
name="NANOBOT_EXTENSION_TEST_KEY",
|
_candidate(
|
||||||
|
ExtensionDependency(
|
||||||
|
kind=DependencyKind.ENVIRONMENT,
|
||||||
|
name="NANOBOT_EXTENSION_TEST_KEY",
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates, diagnostics = evaluate_dependencies((candidate,))
|
|
||||||
|
|
||||||
assert not candidates[0].enabled
|
assert not candidates[0].enabled
|
||||||
assert diagnostics[0].code == "dependency_missing"
|
assert diagnostics[0].code == "dependency_missing"
|
||||||
assert "NANOBOT_EXTENSION_TEST_KEY" in diagnostics[0].message
|
|
||||||
|
|
||||||
|
|
||||||
def test_installed_npm_dependency_satisfies_preflight(tmp_path: Path) -> None:
|
def test_optional_dependency_does_not_disable_extension(monkeypatch) -> None:
|
||||||
package = tmp_path / "node_modules" / "openclaw"
|
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
|
||||||
package.mkdir(parents=True)
|
|
||||||
(package / "package.json").write_text('{"version":"2026.7.1"}')
|
candidates, diagnostics = evaluate_dependencies(
|
||||||
candidate = _candidate(
|
(
|
||||||
ExtensionDependency(
|
_candidate(
|
||||||
kind=DependencyKind.NPM,
|
ExtensionDependency(
|
||||||
name="openclaw",
|
kind=DependencyKind.ENVIRONMENT,
|
||||||
specifier=">=2026.7.0",
|
name="NANOBOT_EXTENSION_TEST_KEY",
|
||||||
),
|
optional=True,
|
||||||
location=tmp_path,
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
candidates, diagnostics = evaluate_dependencies((candidate,))
|
|
||||||
|
|
||||||
assert candidates[0].enabled
|
|
||||||
assert diagnostics == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_npm_dist_tag_is_left_to_npm_resolution(tmp_path: Path) -> None:
|
|
||||||
package = tmp_path / "node_modules" / "openclaw"
|
|
||||||
package.mkdir(parents=True)
|
|
||||||
(package / "package.json").write_text('{"version":"2026.7.1"}')
|
|
||||||
candidate = _candidate(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.NPM,
|
|
||||||
name="openclaw",
|
|
||||||
specifier="latest",
|
|
||||||
),
|
|
||||||
location=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates, diagnostics = evaluate_dependencies((candidate,))
|
|
||||||
|
|
||||||
assert candidates[0].enabled
|
assert candidates[0].enabled
|
||||||
assert diagnostics == ()
|
assert diagnostics == ()
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions.protocol import NodeProtocolError, NodeRegistration
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field", ["kind", "name"])
|
|
||||||
def test_node_registration_rejects_blank_identity(field: str) -> None:
|
|
||||||
value = {"kind": "tool", "name": "sample"}
|
|
||||||
value[field] = " "
|
|
||||||
|
|
||||||
with pytest.raises(NodeProtocolError, match="non-empty"):
|
|
||||||
NodeRegistration.from_mapping(value)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"kind",
|
|
||||||
("tool", "command"),
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"name",
|
|
||||||
("has space", "has.dot", "x" * 65),
|
|
||||||
)
|
|
||||||
def test_node_registration_rejects_unsafe_callable_names(
|
|
||||||
kind: str,
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
with pytest.raises(NodeProtocolError, match=f"{kind} name"):
|
|
||||||
NodeRegistration.from_mapping({"kind": kind, "name": name})
|
|
||||||
|
|
||||||
|
|
||||||
def test_node_registration_rejects_non_object_tool_schema() -> None:
|
|
||||||
with pytest.raises(NodeProtocolError, match="object schema"):
|
|
||||||
NodeRegistration.from_mapping(
|
|
||||||
{
|
|
||||||
"kind": "tool",
|
|
||||||
"name": "sample",
|
|
||||||
"schema": {"type": "string"},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@ -1,145 +1,48 @@
|
|||||||
from nanobot.extensions import (
|
import pytest
|
||||||
ContributionKind,
|
|
||||||
DependencyKind,
|
from nanobot.extensions import ExtensionManifest, ExtensionPermission
|
||||||
ExtensionCandidate,
|
from nanobot.extensions.registry import ExtensionCandidate, ExtensionRegistry
|
||||||
ExtensionContribution,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionPermission,
|
|
||||||
ExtensionPolicy,
|
|
||||||
ExtensionRegistry,
|
|
||||||
ExtensionRuntime,
|
|
||||||
ExtensionScope,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate(
|
def _candidate(
|
||||||
extension_id: str,
|
extension_id: str,
|
||||||
*,
|
*,
|
||||||
scope: ExtensionScope,
|
|
||||||
contribution_name: str = "",
|
|
||||||
trusted: bool = True,
|
trusted: bool = True,
|
||||||
dependencies: tuple[ExtensionDependency, ...] = (),
|
permissions: tuple[ExtensionPermission, ...] = (),
|
||||||
|
granted: frozenset[str] = frozenset(),
|
||||||
) -> ExtensionCandidate:
|
) -> ExtensionCandidate:
|
||||||
contributions = (
|
|
||||||
ExtensionContribution(
|
|
||||||
kind=ContributionKind.TOOL,
|
|
||||||
name=contribution_name,
|
|
||||||
),
|
|
||||||
) if contribution_name else ()
|
|
||||||
return ExtensionCandidate(
|
return ExtensionCandidate(
|
||||||
manifest=ExtensionManifest(
|
ExtensionManifest(
|
||||||
id=extension_id,
|
id=extension_id,
|
||||||
name=extension_id,
|
name=extension_id,
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
permissions=permissions,
|
||||||
contributions=contributions,
|
|
||||||
dependencies=dependencies,
|
|
||||||
),
|
),
|
||||||
scope=scope,
|
|
||||||
trusted=trusted,
|
trusted=trusted,
|
||||||
|
granted_permissions=granted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_copy_shadows_user_and_builtin_copy_of_same_extension() -> None:
|
def test_only_trusted_extensions_activate() -> None:
|
||||||
registry = ExtensionRegistry()
|
registry = ExtensionRegistry()
|
||||||
registry.register(_candidate("acme", scope=ExtensionScope.BUILTIN))
|
registry.register(_candidate("trusted"))
|
||||||
registry.register(_candidate("acme", scope=ExtensionScope.USER))
|
registry.register(_candidate("untrusted", trusted=False))
|
||||||
registry.register(_candidate("acme", scope=ExtensionScope.WORKSPACE))
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
assert [item.manifest.id for item in registry.snapshot().extensions] == ["trusted"]
|
||||||
|
|
||||||
assert len(snapshot.extensions) == 1
|
|
||||||
assert snapshot.extensions[0].scope is ExtensionScope.WORKSPACE
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_filters_extensions_before_contribution_resolution() -> None:
|
def test_every_requested_permission_must_be_granted() -> None:
|
||||||
registry = ExtensionRegistry(
|
|
||||||
ExtensionPolicy(allow=frozenset({"allowed"}), deny=frozenset())
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"allowed",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
contribution_name="allowed_tool",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"hidden",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
contribution_name="hidden_tool",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert [extension.manifest.id for extension in snapshot.extensions] == ["allowed"]
|
|
||||||
assert [
|
|
||||||
contribution.contribution.name for contribution in snapshot.contributions
|
|
||||||
] == ["allowed_tool"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_untrusted_external_extension_is_visible_to_discovery_but_not_active() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
registry = ExtensionRegistry()
|
||||||
registry.register(
|
registry.register(
|
||||||
_candidate(
|
_candidate(
|
||||||
"untrusted",
|
"permission.test",
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
contribution_name="unsafe_tool",
|
|
||||||
trusted=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert snapshot.extensions == ()
|
|
||||||
assert snapshot.contributions == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_package_integrity_cannot_be_overridden_by_trust() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
candidate = _candidate(
|
|
||||||
"tampered",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
contribution_name="unsafe_tool",
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
ExtensionCandidate(
|
|
||||||
manifest=candidate.manifest,
|
|
||||||
scope=candidate.scope,
|
|
||||||
trusted=True,
|
|
||||||
integrity_valid=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert snapshot.extensions == ()
|
|
||||||
assert snapshot.contributions == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_external_extension_requires_every_requested_permission() -> None:
|
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
manifest=ExtensionManifest(
|
|
||||||
id="permission.test",
|
|
||||||
name="Permission test",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
permissions=(
|
permissions=(
|
||||||
ExtensionPermission(name="network", reason="Fetch data."),
|
ExtensionPermission(name="network"),
|
||||||
ExtensionPermission(
|
ExtensionPermission(name="filesystem.read"),
|
||||||
name="filesystem.read",
|
|
||||||
reason="Read input.",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
granted=frozenset({"network"}),
|
||||||
scope=ExtensionScope.USER,
|
)
|
||||||
trusted=True,
|
|
||||||
granted_permissions=frozenset({"network"}),
|
|
||||||
)
|
)
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(candidate)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
snapshot = registry.snapshot()
|
||||||
|
|
||||||
@ -148,174 +51,9 @@ def test_external_extension_requires_every_requested_permission() -> None:
|
|||||||
assert "filesystem.read" in snapshot.diagnostics[0].message
|
assert "filesystem.read" in snapshot.diagnostics[0].message
|
||||||
|
|
||||||
|
|
||||||
def test_conflicting_contribution_does_not_silently_replace_owner() -> None:
|
def test_duplicate_extension_ids_are_rejected() -> None:
|
||||||
registry = ExtensionRegistry()
|
registry = ExtensionRegistry()
|
||||||
registry.register(
|
registry.register(_candidate("duplicate"))
|
||||||
_candidate(
|
|
||||||
"core",
|
|
||||||
scope=ExtensionScope.BUILTIN,
|
|
||||||
contribution_name="shell",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"third-party",
|
|
||||||
scope=ExtensionScope.WORKSPACE,
|
|
||||||
contribution_name="shell",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
with pytest.raises(ValueError, match="already installed"):
|
||||||
|
registry.register(_candidate("duplicate"))
|
||||||
assert snapshot.contributions[0].owner.manifest.id == "core"
|
|
||||||
assert snapshot.diagnostics[0].code == "contribution_conflict"
|
|
||||||
|
|
||||||
|
|
||||||
def test_higher_scope_extension_cannot_replace_another_owner() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"core",
|
|
||||||
scope=ExtensionScope.BUILTIN,
|
|
||||||
contribution_name="shell",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"replacement",
|
|
||||||
scope=ExtensionScope.WORKSPACE,
|
|
||||||
contribution_name="shell",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert snapshot.contributions[0].owner.manifest.id == "core"
|
|
||||||
assert snapshot.diagnostics[0].code == "contribution_conflict"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extension_dependency_must_be_active_and_starts_first() -> None:
|
|
||||||
dependency = ExtensionDependency(
|
|
||||||
kind=DependencyKind.EXTENSION,
|
|
||||||
name="z-base",
|
|
||||||
)
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"a-dependent",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
dependencies=(dependency,),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(_candidate("z-base", scope=ExtensionScope.USER))
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert [item.manifest.id for item in snapshot.extensions] == [
|
|
||||||
"z-base",
|
|
||||||
"a-dependent",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_extension_cannot_satisfy_dependency() -> None:
|
|
||||||
dependency = ExtensionDependency(
|
|
||||||
kind=DependencyKind.EXTENSION,
|
|
||||||
name="base",
|
|
||||||
)
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"dependent",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
dependencies=(dependency,),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate("base", scope=ExtensionScope.USER, trusted=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert snapshot.extensions == ()
|
|
||||||
assert any(
|
|
||||||
item.code == "dependency_missing"
|
|
||||||
and item.extension_id == "dependent"
|
|
||||||
for item in snapshot.diagnostics
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_extension_dependency_cycle_is_rejected() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"first",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
dependencies=(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.EXTENSION,
|
|
||||||
name="second",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"second",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
dependencies=(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.EXTENSION,
|
|
||||||
name="first",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert snapshot.extensions == ()
|
|
||||||
assert {
|
|
||||||
item.extension_id
|
|
||||||
for item in snapshot.diagnostics
|
|
||||||
if item.code == "dependency_cycle"
|
|
||||||
} == {"first", "second"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_optional_extension_dependency_cycle_is_allowed() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"first",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
dependencies=(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.EXTENSION,
|
|
||||||
name="second",
|
|
||||||
optional=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"second",
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
dependencies=(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.EXTENSION,
|
|
||||||
name="first",
|
|
||||||
optional=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert {item.manifest.id for item in snapshot.extensions} == {
|
|
||||||
"first",
|
|
||||||
"second",
|
|
||||||
}
|
|
||||||
assert not any(
|
|
||||||
item.code == "dependency_cycle" for item in snapshot.diagnostics
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,157 +1,46 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.command.router import CommandRouter
|
from nanobot.command.router import CommandRouter
|
||||||
from nanobot.config.schema import Config
|
from nanobot.extensions import ExtensionManifest
|
||||||
from nanobot.extensions import (
|
from nanobot.extensions.registry import (
|
||||||
DependencyKind,
|
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionRuntime,
|
|
||||||
ExtensionRuntimeManager,
|
|
||||||
ExtensionScope,
|
|
||||||
ExtensionSnapshot,
|
ExtensionSnapshot,
|
||||||
)
|
)
|
||||||
|
from nanobot.extensions.runtime import ExtensionRuntimeManager
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(candidate: ExtensionCandidate) -> ExtensionSnapshot:
|
def _candidate(root: Path, extension_id: str = "test.python") -> ExtensionCandidate:
|
||||||
return ExtensionSnapshot((candidate,), (), ())
|
return ExtensionCandidate(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runtime_activation_and_close_are_transactional(tmp_path: Path) -> None:
|
|
||||||
(tmp_path / "index.mjs").write_text(
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "remote_echo",
|
|
||||||
description: "Echo",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
execute: async () => ({ content: [{ type: "text", text: "ok" }] })
|
|
||||||
});
|
|
||||||
pi.registerCommand("remote", {
|
|
||||||
description: "Remote command",
|
|
||||||
handler: async () => undefined
|
|
||||||
});
|
|
||||||
pi.on("agent_start", () => undefined);
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
ExtensionManifest(
|
||||||
id="test.remote",
|
id=extension_id,
|
||||||
name="Remote",
|
name="Python extension",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
runtime=ExtensionRuntime.PI,
|
entry="extension:register",
|
||||||
entry="index.mjs",
|
|
||||||
),
|
),
|
||||||
ExtensionScope.USER,
|
location=root,
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
trusted=True,
|
||||||
)
|
)
|
||||||
tools = ToolRegistry()
|
|
||||||
commands = CommandRouter()
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=tools,
|
|
||||||
commands=commands,
|
|
||||||
config=Config(),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(candidate))
|
|
||||||
|
|
||||||
assert not result.diagnostics
|
|
||||||
assert tools.owner("remote_echo") == "test.remote"
|
|
||||||
assert commands.owner("exact", "/remote") == "test.remote"
|
|
||||||
assert len(result.hook_factories) == 1
|
|
||||||
|
|
||||||
await manager.close()
|
|
||||||
|
|
||||||
assert tools.owner("remote_echo") is None
|
|
||||||
assert commands.owner("exact", "/remote") is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def _snapshot(*candidates: ExtensionCandidate) -> ExtensionSnapshot:
|
||||||
async def test_runtime_rolls_back_partial_registration(tmp_path: Path) -> None:
|
return ExtensionSnapshot(candidates, ())
|
||||||
(tmp_path / "index.mjs").write_text(
|
|
||||||
"""
|
|
||||||
export default function (pi) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "duplicate",
|
|
||||||
description: "Duplicate",
|
|
||||||
parameters: {},
|
|
||||||
execute: async () => ({ content: [] })
|
|
||||||
});
|
|
||||||
pi.registerCommand("duplicate", {
|
|
||||||
handler: async () => undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="test.duplicate",
|
|
||||||
name="Duplicate",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PI,
|
|
||||||
entry="index.mjs",
|
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
commands = CommandRouter()
|
|
||||||
|
|
||||||
async def core_handler(_ctx):
|
|
||||||
return None
|
|
||||||
|
|
||||||
commands.exact("/duplicate", core_handler)
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=ToolRegistry(),
|
|
||||||
commands=commands,
|
|
||||||
config=Config(),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(candidate))
|
|
||||||
|
|
||||||
assert result.extensions == ()
|
|
||||||
assert result.diagnostics[0].code == "activation_failed"
|
|
||||||
assert commands.owner("exact", "/duplicate") == "nanobot.core"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def _write_extension(root: Path, result: str = "ok") -> None:
|
||||||
async def test_python_runtime_reloads_updated_source(tmp_path: Path) -> None:
|
(root / "extension.py").write_text(
|
||||||
source = tmp_path / "plugin.py"
|
f"""
|
||||||
candidate = ExtensionCandidate(
|
from nanobot.agent.hook import AgentHook
|
||||||
ExtensionManifest(
|
|
||||||
id="test.python",
|
|
||||||
name="Python",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
entry="plugin:register",
|
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
tools = ToolRegistry()
|
|
||||||
|
|
||||||
def write_plugin(result: str) -> None:
|
|
||||||
source.write_text(
|
|
||||||
f"""
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
class ReloadTool(Tool):
|
class ExtensionTool(Tool):
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return "reload_test"
|
return "extension_echo"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
return "Reload test"
|
return "Echo from an extension"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parameters(self):
|
def parameters(self):
|
||||||
@ -160,143 +49,50 @@ class ReloadTool(Tool):
|
|||||||
async def execute(self):
|
async def execute(self):
|
||||||
return "{result}"
|
return "{result}"
|
||||||
|
|
||||||
def register(api):
|
async def command(_context):
|
||||||
api.register_tool(ReloadTool())
|
return None
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
write_plugin("first")
|
def hook_factory(_context):
|
||||||
first = ExtensionRuntimeManager(
|
|
||||||
tools=tools,
|
|
||||||
commands=CommandRouter(),
|
|
||||||
config=Config(),
|
|
||||||
)
|
|
||||||
first_result = await first.activate(_snapshot(candidate))
|
|
||||||
assert first_result.diagnostics == ()
|
|
||||||
assert await tools.get("reload_test").execute() == "first"
|
|
||||||
await first.close()
|
|
||||||
|
|
||||||
write_plugin("later")
|
|
||||||
second = ExtensionRuntimeManager(
|
|
||||||
tools=tools,
|
|
||||||
commands=CommandRouter(),
|
|
||||||
config=Config(),
|
|
||||||
)
|
|
||||||
second_result = await second.activate(_snapshot(candidate))
|
|
||||||
assert second_result.diagnostics == ()
|
|
||||||
assert await tools.get("reload_test").execute() == "later"
|
|
||||||
await second.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_python_runtime_accepts_single_entries_form(tmp_path: Path) -> None:
|
|
||||||
(tmp_path / "plugin.py").write_text(
|
|
||||||
"""
|
|
||||||
def register(_api):
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="test.python-entries",
|
|
||||||
name="Python entries",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
entries=("plugin:register",),
|
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=ToolRegistry(),
|
|
||||||
commands=CommandRouter(),
|
|
||||||
config=Config(),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(candidate))
|
|
||||||
|
|
||||||
assert result.diagnostics == ()
|
|
||||||
await manager.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_python_runtime_rejects_module_outside_package(tmp_path: Path) -> None:
|
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="test.collision",
|
|
||||||
name="Collision",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
entry="json:register",
|
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=ToolRegistry(),
|
|
||||||
commands=CommandRouter(),
|
|
||||||
config=Config(),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(candidate))
|
|
||||||
|
|
||||||
assert result.extensions == ()
|
|
||||||
assert result.diagnostics[0].code == "activation_failed"
|
|
||||||
assert "conflicts with loaded module" in result.diagnostics[0].message
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_python_hook_factory_is_removed_without_clearing_core_hooks(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
(tmp_path / "plugin.py").write_text(
|
|
||||||
"""
|
|
||||||
from nanobot.agent.hook import AgentHook
|
|
||||||
|
|
||||||
def extension_hook(_context):
|
|
||||||
return AgentHook()
|
return AgentHook()
|
||||||
|
|
||||||
def register(api):
|
def register(api):
|
||||||
api.register_hook_factory(extension_hook)
|
api.register_tool(ExtensionTool())
|
||||||
|
api.register_command("extension", command)
|
||||||
|
api.register_hook_factory(hook_factory)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="test.hook",
|
|
||||||
name="Hook",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
entry="plugin:register",
|
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def core_hook(_context):
|
|
||||||
return None
|
|
||||||
|
|
||||||
hooks = [core_hook]
|
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(
|
manager = ExtensionRuntimeManager(
|
||||||
tools=ToolRegistry(),
|
tools=tools,
|
||||||
commands=CommandRouter(),
|
commands=commands,
|
||||||
config=Config(),
|
|
||||||
hook_factories=hooks,
|
hook_factories=hooks,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(candidate))
|
result = await manager.activate(_snapshot(_candidate(tmp_path)))
|
||||||
|
|
||||||
assert result.diagnostics == ()
|
assert result.diagnostics == ()
|
||||||
assert len(hooks) == 2
|
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()
|
await manager.close()
|
||||||
assert hooks == [core_hook]
|
|
||||||
|
assert tools.owner("extension_echo") is None
|
||||||
|
assert commands.owner("exact", "/extension") is None
|
||||||
|
assert hooks == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_failed_registration_rolls_back_partial_state(tmp_path: Path) -> None:
|
||||||
async def test_python_runtime_cannot_overwrite_core_tool(tmp_path: Path) -> None:
|
(tmp_path / "extension.py").write_text(
|
||||||
(tmp_path / "plugin.py").write_text(
|
|
||||||
"""
|
"""
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
@ -304,88 +100,81 @@ class DuplicateTool(Tool):
|
|||||||
name = "duplicate"
|
name = "duplicate"
|
||||||
description = "Duplicate"
|
description = "Duplicate"
|
||||||
parameters = {"type": "object", "properties": {}}
|
parameters = {"type": "object", "properties": {}}
|
||||||
|
|
||||||
async def execute(self):
|
async def execute(self):
|
||||||
return "extension"
|
return "ok"
|
||||||
|
|
||||||
|
async def command(_context):
|
||||||
|
return None
|
||||||
|
|
||||||
def register(api):
|
def register(api):
|
||||||
api.register_tool(DuplicateTool())
|
api.register_tool(DuplicateTool())
|
||||||
|
api.register_command("duplicate", command)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="test.overwrite",
|
|
||||||
name="Overwrite",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
entry="plugin:register",
|
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
core_tool = SimpleNamespace(name="duplicate")
|
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
tools.register(core_tool)
|
commands = CommandRouter()
|
||||||
|
|
||||||
|
async def core_handler(_context):
|
||||||
|
return None
|
||||||
|
|
||||||
|
commands.exact("/duplicate", core_handler)
|
||||||
manager = ExtensionRuntimeManager(
|
manager = ExtensionRuntimeManager(
|
||||||
tools=tools,
|
tools=tools,
|
||||||
commands=CommandRouter(),
|
commands=commands,
|
||||||
config=Config(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(candidate))
|
result = await manager.activate(_snapshot(_candidate(tmp_path)))
|
||||||
|
|
||||||
assert result.extensions == ()
|
assert result.extensions == ()
|
||||||
assert result.diagnostics[0].code == "activation_failed"
|
assert result.diagnostics[0].code == "activation_failed"
|
||||||
assert tools.get("duplicate") is core_tool
|
assert tools.get("duplicate") is None
|
||||||
assert tools.owner("duplicate") == "nanobot.core"
|
assert commands.owner("exact", "/duplicate") == "nanobot.core"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
async def test_python_extension_reloads_updated_source(tmp_path: Path) -> None:
|
||||||
async def test_failed_extension_prevents_dependent_activation(tmp_path: Path) -> None:
|
tools = ToolRegistry()
|
||||||
base_root = tmp_path / "base"
|
candidate = _candidate(tmp_path)
|
||||||
dependent_root = tmp_path / "dependent"
|
_write_extension(tmp_path, "first")
|
||||||
base_root.mkdir()
|
first = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
|
||||||
dependent_root.mkdir()
|
|
||||||
base = ExtensionCandidate(
|
await first.activate(_snapshot(candidate))
|
||||||
ExtensionManifest(
|
assert await tools.get("extension_echo").execute() == "first"
|
||||||
id="base",
|
await first.close()
|
||||||
name="Base",
|
|
||||||
version="1.0.0",
|
_write_extension(tmp_path, "later")
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
second = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
|
||||||
entry="missing:register",
|
await second.activate(_snapshot(candidate))
|
||||||
),
|
|
||||||
ExtensionScope.USER,
|
assert await tools.get("extension_echo").execute() == "later"
|
||||||
location=base_root,
|
await second.close()
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
dependent = ExtensionCandidate(
|
async def test_extensions_with_the_same_entry_module_are_isolated(
|
||||||
ExtensionManifest(
|
tmp_path: Path,
|
||||||
id="dependent",
|
) -> None:
|
||||||
name="Dependent",
|
tools = ToolRegistry()
|
||||||
version="1.0.0",
|
manager = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
first_root = tmp_path / "first"
|
||||||
dependencies=(
|
second_root = tmp_path / "second"
|
||||||
ExtensionDependency(
|
first_root.mkdir()
|
||||||
kind=DependencyKind.EXTENSION,
|
second_root.mkdir()
|
||||||
name="base",
|
_write_extension(first_root, "first")
|
||||||
),
|
_write_extension(second_root, "second")
|
||||||
),
|
second_source = (second_root / "extension.py").read_text()
|
||||||
),
|
(second_root / "extension.py").write_text(
|
||||||
ExtensionScope.USER,
|
second_source
|
||||||
location=dependent_root,
|
.replace("extension_echo", "second_echo")
|
||||||
trusted=True,
|
.replace('register_command("extension"', 'register_command("second"')
|
||||||
)
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=ToolRegistry(),
|
|
||||||
commands=CommandRouter(),
|
|
||||||
config=Config(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await manager.activate(ExtensionSnapshot((base, dependent), (), ()))
|
result = await manager.activate(
|
||||||
|
_snapshot(
|
||||||
|
_candidate(first_root, "test.first"),
|
||||||
|
_candidate(second_root, "test.second"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
assert result.extensions == ()
|
assert result.diagnostics == ()
|
||||||
assert [item.code for item in result.diagnostics] == [
|
assert await tools.get("extension_echo").execute() == "first"
|
||||||
"activation_failed",
|
assert await tools.get("second_echo").execute() == "second"
|
||||||
"dependency_activation_failed",
|
await manager.close()
|
||||||
]
|
|
||||||
|
|||||||
@ -1,103 +1,69 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from nanobot.extensions import (
|
from nanobot.extensions import ExtensionManifest
|
||||||
|
from nanobot.extensions.registry import (
|
||||||
ExtensionCandidate,
|
ExtensionCandidate,
|
||||||
ExtensionDiagnostic,
|
ExtensionDiagnostic,
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionRuntime,
|
|
||||||
ExtensionScope,
|
|
||||||
ExtensionSnapshot,
|
ExtensionSnapshot,
|
||||||
)
|
)
|
||||||
from nanobot.extensions.service import ExtensionService
|
from nanobot.extensions.service import ExtensionService
|
||||||
from nanobot.extensions.store import ExtensionStore
|
from nanobot.extensions.store import ExtensionStore
|
||||||
|
|
||||||
|
|
||||||
async def test_service_installs_untrusted_extension_and_reports_status(
|
def _package(root: Path) -> Path:
|
||||||
tmp_path: Path,
|
root.mkdir()
|
||||||
) -> None:
|
(root / "extension.py").write_text("def register(api):\n pass\n")
|
||||||
source = tmp_path / "source"
|
(root / "nanobot.extension.json").write_text(
|
||||||
source.mkdir()
|
json.dumps(
|
||||||
(source / "nanobot.extension.json").write_text(
|
{
|
||||||
"""
|
"id": "sample",
|
||||||
{
|
"name": "Sample",
|
||||||
"apiVersion": 1,
|
"version": "1.0.0",
|
||||||
"id": "sample",
|
"entry": "extension:register",
|
||||||
"name": "Sample",
|
}
|
||||||
"version": "1.0.0",
|
)
|
||||||
"runtime": "declarative"
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_installs_untrusted_and_updates_policy(tmp_path: Path) -> None:
|
||||||
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
|
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
|
||||||
|
|
||||||
installed = await service.install(str(source), kind="local")
|
installed = await service.install(
|
||||||
status = await service.status()
|
str(_package(tmp_path / "source")),
|
||||||
|
kind="local",
|
||||||
assert installed["record"]["trusted"] is False
|
|
||||||
assert status["extensions"][0]["id"] == "sample"
|
|
||||||
assert status["extensions"][0]["active"] is False
|
|
||||||
assert status["extensions"][0]["managed_by_store"] is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_service_updates_policy_and_uninstalls(tmp_path: Path) -> None:
|
|
||||||
source = tmp_path / "source"
|
|
||||||
source.mkdir()
|
|
||||||
(source / "nanobot.extension.json").write_text(
|
|
||||||
"""
|
|
||||||
{
|
|
||||||
"apiVersion": 1,
|
|
||||||
"id": "sample",
|
|
||||||
"name": "Sample",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"runtime": "declarative"
|
|
||||||
}
|
|
||||||
""",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
)
|
||||||
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
|
|
||||||
await service.install(str(source), kind="local")
|
|
||||||
|
|
||||||
trusted = await service.set_trusted("sample", True)
|
trusted = await service.set_trusted("sample", True)
|
||||||
await service.set_enabled("sample", False)
|
await service.set_enabled("sample", False)
|
||||||
removed = await service.uninstall("sample")
|
|
||||||
|
|
||||||
|
assert installed["record"]["trusted"] is False
|
||||||
|
assert "runtime" not in installed["manifest"]
|
||||||
assert trusted["record"]["trusted"] is True
|
assert trusted["record"]["trusted"] is True
|
||||||
assert removed == {"removed": "sample"}
|
assert (await service.status())["extensions"][0]["enabled"] is False
|
||||||
assert (await service.status())["extensions"] == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_service_reports_active_only_after_runtime_activation(
|
async def test_service_reports_activation_failure(tmp_path: Path) -> None:
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
candidate = ExtensionCandidate(
|
candidate = ExtensionCandidate(
|
||||||
manifest=ExtensionManifest(
|
ExtensionManifest(id="broken", name="Broken", version="1.0.0"),
|
||||||
id="broken",
|
|
||||||
name="Broken",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PYTHON,
|
|
||||||
entry="missing:register",
|
|
||||||
),
|
|
||||||
scope=ExtensionScope.USER,
|
|
||||||
location=tmp_path,
|
location=tmp_path,
|
||||||
trusted=True,
|
trusted=True,
|
||||||
)
|
)
|
||||||
snapshot = ExtensionSnapshot((candidate,), (), ())
|
|
||||||
host = SimpleNamespace(
|
host = SimpleNamespace(
|
||||||
snapshot=SimpleNamespace(
|
snapshot=SimpleNamespace(
|
||||||
catalog=SimpleNamespace(
|
catalog=SimpleNamespace(
|
||||||
candidates=(candidate,),
|
candidates=(candidate,),
|
||||||
diagnostics=(),
|
diagnostics=(),
|
||||||
snapshot=snapshot,
|
snapshot=ExtensionSnapshot((candidate,), ()),
|
||||||
),
|
),
|
||||||
activation=SimpleNamespace(
|
activation=SimpleNamespace(
|
||||||
extensions=(),
|
extensions=(),
|
||||||
diagnostics=(
|
diagnostics=(
|
||||||
ExtensionDiagnostic(
|
ExtensionDiagnostic(
|
||||||
code="activation_failed",
|
"activation_failed",
|
||||||
extension_id="broken",
|
"broken",
|
||||||
message="missing module",
|
"missing module",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -110,41 +76,13 @@ async def test_service_reports_active_only_after_runtime_activation(
|
|||||||
|
|
||||||
status = await service.status()
|
status = await service.status()
|
||||||
|
|
||||||
assert status["extensions"][0]["active"] is False
|
assert not status["extensions"][0]["active"]
|
||||||
assert status["extensions"][0]["managed_by_store"] is False
|
|
||||||
assert status["diagnostics"][0]["code"] == "activation_failed"
|
assert status["diagnostics"][0]["code"] == "activation_failed"
|
||||||
|
|
||||||
|
|
||||||
async def test_service_reports_projected_native_capability_as_active(
|
async def test_service_uninstalls_package(tmp_path: Path) -> None:
|
||||||
tmp_path: Path,
|
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
|
||||||
) -> None:
|
await service.install(str(_package(tmp_path / "source")), kind="local")
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
manifest=ExtensionManifest(
|
|
||||||
id="nanobot.core",
|
|
||||||
name="nanobot core",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
),
|
|
||||||
scope=ExtensionScope.BUILTIN,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
snapshot = ExtensionSnapshot((candidate,), (), ())
|
|
||||||
host = SimpleNamespace(
|
|
||||||
snapshot=SimpleNamespace(
|
|
||||||
catalog=SimpleNamespace(
|
|
||||||
candidates=(candidate,),
|
|
||||||
diagnostics=(),
|
|
||||||
snapshot=snapshot,
|
|
||||||
),
|
|
||||||
activation=SimpleNamespace(extensions=(), diagnostics=()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
service = ExtensionService(
|
|
||||||
host=host,
|
|
||||||
store=ExtensionStore(tmp_path / "installed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
status = await service.status()
|
assert await service.uninstall("sample") == {"removed": "sample"}
|
||||||
|
assert (await service.status())["extensions"] == []
|
||||||
assert status["extensions"][0]["active"] is True
|
|
||||||
assert status["extensions"][0]["managed_by_store"] is False
|
|
||||||
|
|||||||
@ -1,603 +1,144 @@
|
|||||||
import json
|
import json
|
||||||
import tarfile
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.extensions import (
|
from nanobot.extensions.store import (
|
||||||
DependencyKind,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionPermission,
|
|
||||||
ExtensionRuntime,
|
|
||||||
ExtensionSourceKind,
|
ExtensionSourceKind,
|
||||||
ExtensionStore,
|
ExtensionStore,
|
||||||
InstalledExtension,
|
InstalledExtension,
|
||||||
dump_manifest,
|
|
||||||
)
|
)
|
||||||
from nanobot.extensions.store import _extract_tar
|
|
||||||
|
|
||||||
|
|
||||||
def _pi_package(root: Path, *, version: str = "1.0.0") -> Path:
|
def _package(root: Path, *, version: str = "1.0.0") -> Path:
|
||||||
root.mkdir()
|
root.mkdir()
|
||||||
(root / "index.mjs").write_text("export default function () {}")
|
(root / "extension.py").write_text("def register(api):\n pass\n")
|
||||||
(root / "package.json").write_text(
|
(root / "nanobot.extension.json").write_text(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
"name": "store-test",
|
"id": "store.test",
|
||||||
|
"name": "Store test",
|
||||||
"version": version,
|
"version": version,
|
||||||
"pi": {"extensions": ["./index.mjs"]},
|
"entry": "extension:register",
|
||||||
|
"permissions": [
|
||||||
|
{"name": "network", "reason": "Fetch selected sources."}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
dump_manifest(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="pi.store-test",
|
|
||||||
name="store-test",
|
|
||||||
version=version,
|
|
||||||
runtime=ExtensionRuntime.PI,
|
|
||||||
entry="./index.mjs",
|
|
||||||
permissions=(
|
|
||||||
ExtensionPermission(name="network"),
|
|
||||||
ExtensionPermission(name="filesystem.read"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
root / "nanobot.extension.json",
|
|
||||||
)
|
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def test_store_installs_and_applies_trust_state(tmp_path: Path) -> None:
|
def test_local_install_is_atomic_and_untrusted_by_default(tmp_path: Path) -> None:
|
||||||
source = _pi_package(tmp_path / "source")
|
store = ExtensionStore(tmp_path / "installed")
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
installed = store.install_local(source)
|
result = store.install_local(_package(tmp_path / "source"))
|
||||||
|
|
||||||
assert installed.record.source is ExtensionSourceKind.LOCAL
|
assert result.record.source is ExtensionSourceKind.LOCAL
|
||||||
assert installed.record.integrity.startswith("sha256:")
|
assert result.record.integrity.startswith("sha256:")
|
||||||
|
assert result.manifest.id == "store.test"
|
||||||
assert not store.discover().candidates[0].trusted
|
assert not store.discover().candidates[0].trusted
|
||||||
|
|
||||||
store.set_trusted(installed.record.id, True)
|
|
||||||
store.set_enabled(installed.record.id, False)
|
def test_policy_updates_survive_discovery(tmp_path: Path) -> None:
|
||||||
store.set_permissions(installed.record.id, {"network", "filesystem.read"})
|
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]
|
candidate = store.discover().candidates[0]
|
||||||
assert candidate.trusted
|
assert candidate.trusted
|
||||||
assert not candidate.enabled
|
assert not candidate.enabled
|
||||||
assert candidate.granted_permissions == frozenset(
|
assert candidate.granted_permissions == frozenset({"network"})
|
||||||
{"network", "filesystem.read"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_permissions_must_be_a_string_array() -> None:
|
def test_unknown_permission_cannot_be_granted(tmp_path: Path) -> None:
|
||||||
with pytest.raises(ValueError, match="array of strings"):
|
store = ExtensionStore(tmp_path / "installed")
|
||||||
InstalledExtension.from_mapping(
|
store.install_local(_package(tmp_path / "source"))
|
||||||
{
|
|
||||||
"id": "sample",
|
with pytest.raises(ValueError, match="not requested"):
|
||||||
"version": "1.0.0",
|
store.set_permissions("store.test", {"filesystem.write"})
|
||||||
"source": "npm",
|
|
||||||
"source_ref": "sample",
|
|
||||||
"integrity": "sha256:test",
|
|
||||||
"installed_at": "2026-01-01T00:00:00Z",
|
|
||||||
"granted_permissions": "network",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_modified_install_loses_effective_trust(tmp_path: Path) -> None:
|
||||||
("field", "value", "message"),
|
store = ExtensionStore(tmp_path / "installed")
|
||||||
[
|
store.install_local(_package(tmp_path / "source"), trusted=True)
|
||||||
("source", 3, "source must be a string"),
|
(store.root / "store.test" / "extension.py").write_text("changed = True\n")
|
||||||
("integrity", "sha256:test", "sha256 digest"),
|
|
||||||
(
|
|
||||||
"granted_permissions",
|
|
||||||
["network", "network"],
|
|
||||||
"cannot contain duplicates",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_registry_record_rejects_ambiguous_metadata(
|
|
||||||
field: str,
|
|
||||||
value: object,
|
|
||||||
message: str,
|
|
||||||
) -> None:
|
|
||||||
payload = {
|
|
||||||
"id": "sample",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "npm",
|
|
||||||
"source_ref": "sample",
|
|
||||||
"integrity": f"sha256:{'0' * 64}",
|
|
||||||
"installed_at": "2026-01-01T00:00:00Z",
|
|
||||||
}
|
|
||||||
payload[field] = value
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match=message):
|
|
||||||
InstalledExtension.from_mapping(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_registry_is_diagnostic_and_cannot_be_overwritten(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
installed = store.install_local(source)
|
|
||||||
original = "{broken"
|
|
||||||
store.registry_path.write_text(original)
|
|
||||||
|
|
||||||
discovery = store.discover()
|
|
||||||
|
|
||||||
assert discovery.diagnostics[0].code == "invalid_extension_registry"
|
|
||||||
assert not discovery.candidates[0].trusted
|
|
||||||
with pytest.raises(ValueError, match="invalid extension registry"):
|
|
||||||
store.set_trusted(installed.record.id, True)
|
|
||||||
assert store.registry_path.read_text() == original
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"payload",
|
|
||||||
[
|
|
||||||
{},
|
|
||||||
{"version": 2, "extensions": []},
|
|
||||||
{"version": 1, "extensions": {}},
|
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"extensions": [
|
|
||||||
{
|
|
||||||
"id": "duplicate",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "npm",
|
|
||||||
"source_ref": "duplicate",
|
|
||||||
"integrity": "sha256:test",
|
|
||||||
"installed_at": "2026-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "duplicate",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "npm",
|
|
||||||
"source_ref": "duplicate",
|
|
||||||
"integrity": "sha256:test",
|
|
||||||
"installed_at": "2026-01-01T00:00:00Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_store_rejects_invalid_registry_schema(
|
|
||||||
tmp_path: Path,
|
|
||||||
payload: object,
|
|
||||||
) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
store.registry_path.write_text(json.dumps(payload))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="invalid extension registry"):
|
|
||||||
store.records(strict=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_updates_and_uninstalls_atomically(tmp_path: Path) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
first = store.install_local(source, trusted=True)
|
|
||||||
package_json = source / "package.json"
|
|
||||||
payload = json.loads(package_json.read_text())
|
|
||||||
payload["version"] = "2.0.0"
|
|
||||||
package_json.write_text(json.dumps(payload))
|
|
||||||
manifest_path = source / "nanobot.extension.json"
|
|
||||||
manifest = json.loads(manifest_path.read_text())
|
|
||||||
manifest["version"] = "2.0.0"
|
|
||||||
manifest_path.write_text(json.dumps(manifest))
|
|
||||||
|
|
||||||
second = store.install_local(source)
|
|
||||||
|
|
||||||
assert second.record.version == "2.0.0"
|
|
||||||
assert not second.record.trusted
|
|
||||||
store.uninstall(first.record.id)
|
|
||||||
assert store.records() == {}
|
|
||||||
assert not (store.root / first.record.id).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_preserves_trust_only_for_identical_reinstall(tmp_path: Path) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
first = store.install_local(source, trusted=True)
|
|
||||||
store.set_permissions(first.record.id, {"network"})
|
|
||||||
|
|
||||||
second = store.install_local(source)
|
|
||||||
|
|
||||||
assert second.record.trusted
|
|
||||||
assert second.record.granted_permissions == ("network",)
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_revokes_effective_trust_when_package_changes_on_disk(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
installed = store.install_local(source, trusted=True)
|
|
||||||
target = store.root / installed.record.id
|
|
||||||
(target / "index.mjs").write_text("export default function changed() {}")
|
|
||||||
|
|
||||||
discovery = store.discover()
|
discovery = store.discover()
|
||||||
|
|
||||||
assert not discovery.candidates[0].trusted
|
assert not discovery.candidates[0].trusted
|
||||||
assert not discovery.candidates[0].integrity_valid
|
assert not discovery.candidates[0].integrity_valid
|
||||||
assert any(
|
assert discovery.diagnostics[0].code == "extension_integrity_mismatch"
|
||||||
item.code == "extension_integrity_mismatch"
|
|
||||||
for item in discovery.diagnostics
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_ignores_interrupted_transaction_directories(tmp_path: Path) -> None:
|
def test_changed_reinstall_revokes_trust_but_identical_reinstall_preserves_it(
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
tmp_path: Path,
|
||||||
hidden = _pi_package(store.root / ".install-interrupted")
|
) -> 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()
|
discovery = store.discover()
|
||||||
|
|
||||||
assert hidden.is_dir()
|
assert discovery.diagnostics[0].code == "invalid_extension_registry"
|
||||||
assert discovery.candidates == ()
|
with pytest.raises(ValueError, match="invalid extension registry"):
|
||||||
|
store.set_trusted("store.test", True)
|
||||||
|
assert store.registry_path.read_text() == "{broken"
|
||||||
def test_store_rejects_symlinked_package_content(tmp_path: Path) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
(source / "outside").symlink_to(tmp_path)
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
try:
|
|
||||||
store.install_local(source)
|
|
||||||
except ValueError as exc:
|
|
||||||
assert "symlink" in str(exc)
|
|
||||||
else:
|
|
||||||
raise AssertionError("symlinked package was accepted")
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_accepts_internal_node_dependency_links(tmp_path: Path) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
def install_dependencies(root: Path, _manifest) -> None:
|
|
||||||
executable = root / "node_modules" / "tool" / "cli.js"
|
|
||||||
executable.parent.mkdir(parents=True)
|
|
||||||
executable.write_text("export {}")
|
|
||||||
bin_dir = root / "node_modules" / ".bin"
|
|
||||||
bin_dir.mkdir()
|
|
||||||
(bin_dir / "tool").symlink_to("../tool/cli.js")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"nanobot.extensions.store._install_node_dependencies",
|
|
||||||
side_effect=install_dependencies,
|
|
||||||
):
|
|
||||||
installed = store.install_local(source)
|
|
||||||
|
|
||||||
candidate = store.discover().candidates[0]
|
|
||||||
assert candidate.manifest.id == installed.record.id
|
|
||||||
assert candidate.integrity_valid
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_rejects_node_dependency_links_outside_package(tmp_path: Path) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
outside = tmp_path / "outside.js"
|
|
||||||
outside.write_text("export {}")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
def install_dependencies(root: Path, _manifest) -> None:
|
|
||||||
bin_dir = root / "node_modules" / ".bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
(bin_dir / "tool").symlink_to(outside)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"nanobot.extensions.store._install_node_dependencies",
|
|
||||||
side_effect=install_dependencies,
|
|
||||||
):
|
|
||||||
with pytest.raises(ValueError, match="symlink"):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_only_grants_permissions_requested_by_current_manifest(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = tmp_path / "source"
|
|
||||||
source.mkdir()
|
|
||||||
dump_manifest(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="permission-test",
|
|
||||||
name="Permission test",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.DECLARATIVE,
|
|
||||||
permissions=(ExtensionPermission(name="network.http"),),
|
|
||||||
),
|
|
||||||
source / "nanobot.extension.json",
|
|
||||||
)
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
granted = store.set_permissions("permission-test", {"network.http"})
|
|
||||||
|
|
||||||
assert granted.granted_permissions == ("network.http",)
|
|
||||||
with pytest.raises(ValueError, match="not requested"):
|
|
||||||
store.set_permissions("permission-test", {"workspace.write"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_restores_previous_package_when_registry_write_fails(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
first = store.install_local(source)
|
|
||||||
installed_package = store.root / first.record.id / "package.json"
|
|
||||||
payload = json.loads((source / "package.json").read_text())
|
|
||||||
payload["version"] = "2.0.0"
|
|
||||||
(source / "package.json").write_text(json.dumps(payload))
|
|
||||||
|
|
||||||
with patch.object(store, "_write_records", side_effect=OSError("disk full")):
|
|
||||||
try:
|
|
||||||
store.install_local(source)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
raise AssertionError("registry failure did not abort installation")
|
|
||||||
|
|
||||||
restored = json.loads(installed_package.read_text())
|
|
||||||
assert restored["version"] == "1.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_preserves_previous_package_when_backup_rename_fails(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
first = store.install_local(source)
|
|
||||||
target = store.root / first.record.id
|
|
||||||
original_rename = Path.rename
|
|
||||||
|
|
||||||
def fail_backup_rename(path: Path, destination: Path) -> Path:
|
|
||||||
if path == target:
|
|
||||||
raise OSError("rename failed")
|
|
||||||
return original_rename(path, destination)
|
|
||||||
|
|
||||||
with patch.object(Path, "rename", fail_backup_rename):
|
|
||||||
with pytest.raises(OSError, match="rename failed"):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
assert target.is_dir()
|
|
||||||
assert first.record.id in store.records(strict=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_removes_first_package_when_registry_write_fails(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
with patch.object(store, "_write_records", side_effect=OSError("disk full")):
|
|
||||||
with pytest.raises(OSError, match="disk full"):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
assert not (store.root / "pi.store-test").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_restores_package_when_uninstall_registry_write_fails(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
installed = store.install_local(source)
|
|
||||||
target = store.root / installed.record.id
|
|
||||||
|
|
||||||
with patch.object(store, "_write_records", side_effect=OSError("disk full")):
|
|
||||||
with pytest.raises(OSError, match="disk full"):
|
|
||||||
store.uninstall(installed.record.id)
|
|
||||||
|
|
||||||
assert target.exists()
|
|
||||||
assert installed.record.id in store.records()
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_installs_declared_npm_runtime_dependency(tmp_path: Path) -> None:
|
|
||||||
source = tmp_path / "source"
|
|
||||||
source.mkdir()
|
|
||||||
(source / "index.mjs").write_text("export default function () {}")
|
|
||||||
(source / "package.json").write_text("{}")
|
|
||||||
dump_manifest(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="openclaw.test",
|
|
||||||
name="OpenClaw test",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.OPENCLAW,
|
|
||||||
entry="./index.mjs",
|
|
||||||
dependencies=(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.NPM,
|
|
||||||
name="openclaw",
|
|
||||||
specifier="2026.7.1",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
source / "nanobot.extension.json",
|
|
||||||
)
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
runtime_package: dict[str, object] = {}
|
|
||||||
|
|
||||||
def capture_runtime_package(
|
|
||||||
_command: list[str],
|
|
||||||
*,
|
|
||||||
cwd: Path | None = None,
|
|
||||||
) -> str:
|
|
||||||
assert cwd is not None
|
|
||||||
runtime_package.update(json.loads((cwd / "package.json").read_text()))
|
|
||||||
return ""
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"nanobot.extensions.store._run",
|
|
||||||
side_effect=capture_runtime_package,
|
|
||||||
) as run:
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
command = run.call_args.args[0]
|
|
||||||
assert "--omit=dev" in command
|
|
||||||
assert "--package-lock=false" in command
|
|
||||||
assert runtime_package["dependencies"] == {"openclaw": "2026.7.1"}
|
|
||||||
assert json.loads((store.root / "openclaw.test" / "package.json").read_text()) == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_git_install_fetches_branch_tag_or_commit_ref(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
result = object()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("nanobot.extensions.store._run") as run,
|
|
||||||
patch.object(store, "_install_from_directory", return_value=result),
|
|
||||||
):
|
|
||||||
installed = store.install_git(
|
|
||||||
"https://example.com/acme/extension.git",
|
|
||||||
ref="abc123",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert installed is result
|
|
||||||
commands = [call.args[0] for call in run.call_args_list]
|
|
||||||
assert commands[0][:4] == ["git", "clone", "--filter=blob:none", "--no-checkout"]
|
|
||||||
assert commands[1][-5:] == ["--depth", "1", "--", "origin", "abc123"]
|
|
||||||
assert commands[2][-3:] == ["checkout", "--detach", "FETCH_HEAD"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"url",
|
|
||||||
[
|
|
||||||
"ext::sh -c touch /tmp/owned",
|
|
||||||
"file:///tmp/extension",
|
|
||||||
"/tmp/extension",
|
|
||||||
"https://token@example.com/acme/extension.git",
|
|
||||||
"https://example.com/acme/extension.git?token=secret",
|
|
||||||
"git@example.com:acme/extension.git#main",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_git_install_rejects_unsafe_or_local_sources(
|
|
||||||
tmp_path: Path,
|
|
||||||
url: str,
|
|
||||||
) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
with patch("nanobot.extensions.store._run") as run:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
store.install_git(url)
|
|
||||||
|
|
||||||
run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"spec",
|
|
||||||
(
|
|
||||||
"file:/tmp/private",
|
|
||||||
"../local-package",
|
|
||||||
"https://example.com/package.tgz",
|
|
||||||
"git+ssh://git@example.com/package.git",
|
|
||||||
"npm:safe-package@1.0.0",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
def test_install_npm_rejects_non_registry_package_source(
|
|
||||||
tmp_path: Path,
|
|
||||||
spec: str,
|
|
||||||
) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
with patch("nanobot.extensions.store._run") as run:
|
|
||||||
with pytest.raises(ValueError, match="registry package name"):
|
|
||||||
store.install_npm(spec)
|
|
||||||
|
|
||||||
run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"spec",
|
|
||||||
("safe-package", "safe-package@latest", "@scope/safe-package@1.2.3"),
|
|
||||||
)
|
|
||||||
def test_install_npm_accepts_registry_package_source(
|
|
||||||
tmp_path: Path,
|
|
||||||
spec: str,
|
|
||||||
) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"nanobot.extensions.store._run",
|
|
||||||
side_effect=RuntimeError("validation passed"),
|
|
||||||
) as run:
|
|
||||||
with pytest.raises(RuntimeError, match="validation passed"):
|
|
||||||
store.install_npm(spec)
|
|
||||||
|
|
||||||
assert run.call_args.args[0][-1] == spec
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_npm_rejects_invalid_pack_metadata(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
with patch("nanobot.extensions.store._run", return_value="[{}]"):
|
|
||||||
with pytest.raises(ValueError, match="invalid package metadata"):
|
|
||||||
store.install_npm("safe-package")
|
|
||||||
|
|
||||||
|
|
||||||
def test_npm_archive_rejects_extracted_size_over_limit(tmp_path: Path) -> None:
|
|
||||||
archive = tmp_path / "package.tgz"
|
|
||||||
payload = b"too-large"
|
|
||||||
with tarfile.open(archive, "w:gz") as bundle:
|
|
||||||
member = tarfile.TarInfo("package/index.mjs")
|
|
||||||
member.size = len(payload)
|
|
||||||
bundle.addfile(member, BytesIO(payload))
|
|
||||||
|
|
||||||
with patch("nanobot.extensions.store._MAX_EXTRACTED_BYTES", len(payload) - 1):
|
|
||||||
with pytest.raises(ValueError, match="extracted limit"):
|
|
||||||
_extract_tar(archive, tmp_path / "checkout")
|
|
||||||
|
|
||||||
|
|
||||||
def test_node_dependency_install_rejects_non_registry_specs(tmp_path: Path) -> None:
|
|
||||||
source = tmp_path / "source"
|
|
||||||
source.mkdir()
|
|
||||||
(source / "index.mjs").write_text("export default function () {}")
|
|
||||||
(source / "package.json").write_text(
|
|
||||||
json.dumps({"dependencies": {"unsafe": "file:/tmp/package"}})
|
|
||||||
)
|
|
||||||
dump_manifest(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="pi.unsafe-dependency",
|
|
||||||
name="Unsafe dependency",
|
|
||||||
version="1.0.0",
|
|
||||||
runtime=ExtensionRuntime.PI,
|
|
||||||
entry="./index.mjs",
|
|
||||||
),
|
|
||||||
source / "nanobot.extension.json",
|
|
||||||
)
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
with patch("nanobot.extensions.store._run") as run:
|
|
||||||
with pytest.raises(ValueError, match="npm registry"):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_node_dependency_install_includes_required_peers_only(tmp_path: Path) -> None:
|
|
||||||
source = _pi_package(tmp_path / "source")
|
|
||||||
package_path = source / "package.json"
|
|
||||||
package = json.loads(package_path.read_text())
|
|
||||||
package.update(
|
|
||||||
{
|
|
||||||
"peerDependencies": {
|
|
||||||
"required-peer": "^1.0.0",
|
|
||||||
"optional-peer": "^2.0.0",
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"optional-peer": {"optional": True},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
package_path.write_text(json.dumps(package))
|
|
||||||
store = ExtensionStore(tmp_path / "extensions")
|
|
||||||
|
|
||||||
def inspect_install(command: list[str], *, cwd: Path | None = None) -> str:
|
|
||||||
assert command[:2] == ["npm", "install"]
|
|
||||||
assert cwd is not None
|
|
||||||
runtime_package = json.loads((cwd / "package.json").read_text())
|
|
||||||
assert runtime_package["dependencies"]["required-peer"] == "^1.0.0"
|
|
||||||
assert "optional-peer" not in runtime_package["dependencies"]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
with patch("nanobot.extensions.store._run", side_effect=inspect_install):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from urllib.parse import parse_qs, quote, urlsplit
|
from urllib.parse import quote
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from websockets.datastructures import Headers
|
from websockets.datastructures import Headers
|
||||||
@ -19,10 +19,6 @@ class _Service:
|
|||||||
self.calls.append(("status", None))
|
self.calls.append(("status", None))
|
||||||
return {"extensions": [], "diagnostics": []}
|
return {"extensions": [], "diagnostics": []}
|
||||||
|
|
||||||
async def search(self, query, *, ecosystem, limit):
|
|
||||||
self.calls.append(("search", (query, ecosystem, limit)))
|
|
||||||
return {"packages": []}
|
|
||||||
|
|
||||||
async def install(self, source, *, kind, ref, trusted):
|
async def install(self, source, *, kind, ref, trusted):
|
||||||
self.calls.append(("install", (source, kind, ref, trusted)))
|
self.calls.append(("install", (source, kind, ref, trusted)))
|
||||||
return {"record": {"id": "sample"}}
|
return {"record": {"id": "sample"}}
|
||||||
@ -31,6 +27,10 @@ class _Service:
|
|||||||
self.calls.append(("trust", (extension_id, trusted)))
|
self.calls.append(("trust", (extension_id, trusted)))
|
||||||
return {"record": {"id": extension_id}}
|
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(
|
def _router(
|
||||||
service: _Service,
|
service: _Service,
|
||||||
@ -41,7 +41,6 @@ def _router(
|
|||||||
return WebUIExtensionsRouter(
|
return WebUIExtensionsRouter(
|
||||||
service=service,
|
service=service,
|
||||||
check_api_token=lambda _request: authorized,
|
check_api_token=lambda _request: authorized,
|
||||||
parse_query=lambda path: parse_qs(urlsplit(path).query),
|
|
||||||
json_response=http_json_response,
|
json_response=http_json_response,
|
||||||
error_response=lambda status, message: http_json_response(
|
error_response=lambda status, message: http_json_response(
|
||||||
{"error": message},
|
{"error": message},
|
||||||
@ -58,12 +57,10 @@ def _request(
|
|||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
values: dict[str, object] | None = None,
|
values: dict[str, object] | None = None,
|
||||||
host: str = "127.0.0.1:8765",
|
host: str = "127.0.0.1:8765",
|
||||||
encode_values: bool = False,
|
|
||||||
):
|
):
|
||||||
headers = Headers([("Host", host)])
|
headers = Headers([("Host", host)])
|
||||||
if values is not None:
|
if values is not None:
|
||||||
payload = json.dumps(values)
|
headers["X-Nanobot-Extension-Values"] = quote(json.dumps(values))
|
||||||
headers["X-Nanobot-Extension-Values"] = quote(payload) if encode_values else payload
|
|
||||||
return SimpleNamespace(path=path, method=method, headers=headers)
|
return SimpleNamespace(path=path, method=method, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@ -98,56 +95,42 @@ async def test_extension_status_requires_auth_and_get() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extension_market_parses_query() -> None:
|
async def test_local_install_is_untrusted() -> None:
|
||||||
service = _Service()
|
|
||||||
response = await _router(service).dispatch(
|
|
||||||
_LOCAL,
|
|
||||||
_request("/api/extensions/market?q=web&ecosystem=pi&limit=7"),
|
|
||||||
"/api/extensions/market",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response is not None and response.status_code == 200
|
|
||||||
assert service.calls == [("search", ("web", "pi", 7))]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extension_install_is_local_and_untrusted() -> None:
|
|
||||||
service = _Service()
|
service = _Service()
|
||||||
response = await _router(service).dispatch(
|
response = await _router(service).dispatch(
|
||||||
_LOCAL,
|
_LOCAL,
|
||||||
_request(
|
_request(
|
||||||
"/api/extensions/install",
|
"/api/extensions/install",
|
||||||
method="POST",
|
method="POST",
|
||||||
values={"source": "中文扩展", "kind": "npm"},
|
values={"source": "https://example.com/acme.git", "kind": "git"},
|
||||||
encode_values=True,
|
|
||||||
),
|
),
|
||||||
"/api/extensions/install",
|
"/api/extensions/install",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response is not None and response.status_code == 200
|
assert response is not None and response.status_code == 200
|
||||||
assert service.calls == [
|
assert service.calls == [
|
||||||
("install", ("中文扩展", "npm", "", False)),
|
("install", ("https://example.com/acme.git", "git", "", False)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remote_install_policy_never_exposes_server_local_paths() -> None:
|
async def test_remote_policy_allows_git_but_never_local_paths() -> None:
|
||||||
service = _Service()
|
service = _Service()
|
||||||
denied = await _router(service).dispatch(
|
denied = await _router(service).dispatch(
|
||||||
_REMOTE,
|
_REMOTE,
|
||||||
_request(
|
_request(
|
||||||
"/api/extensions/install",
|
"/api/extensions/install",
|
||||||
method="POST",
|
method="POST",
|
||||||
values={"source": "pi-example", "kind": "npm"},
|
values={"source": "https://example.com/acme.git", "kind": "git"},
|
||||||
),
|
),
|
||||||
"/api/extensions/install",
|
"/api/extensions/install",
|
||||||
)
|
)
|
||||||
npm_allowed = await _router(service, allow_remote=True).dispatch(
|
allowed = await _router(service, allow_remote=True).dispatch(
|
||||||
_REMOTE,
|
_REMOTE,
|
||||||
_request(
|
_request(
|
||||||
"/api/extensions/install",
|
"/api/extensions/install",
|
||||||
method="POST",
|
method="POST",
|
||||||
values={"source": "pi-example", "kind": "npm"},
|
values={"source": "https://example.com/acme.git", "kind": "git"},
|
||||||
),
|
),
|
||||||
"/api/extensions/install",
|
"/api/extensions/install",
|
||||||
)
|
)
|
||||||
@ -162,17 +145,16 @@ async def test_remote_install_policy_never_exposes_server_local_paths() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert denied is not None and denied.status_code == 403
|
assert denied is not None and denied.status_code == 403
|
||||||
assert npm_allowed is not None and npm_allowed.status_code == 200
|
assert allowed is not None and allowed.status_code == 200
|
||||||
assert local_denied is not None and local_denied.status_code == 403
|
assert local_denied is not None and local_denied.status_code == 403
|
||||||
assert service.calls == [
|
assert service.calls == [
|
||||||
("install", ("pi-example", "npm", "", False)),
|
("install", ("https://example.com/acme.git", "git", "", False)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_remote_install_policy_does_not_grant_remote_trust() -> None:
|
async def test_remote_clients_cannot_change_trust() -> None:
|
||||||
service = _Service()
|
service = _Service()
|
||||||
|
|
||||||
response = await _router(service, allow_remote=True).dispatch(
|
response = await _router(service, allow_remote=True).dispatch(
|
||||||
_REMOTE,
|
_REMOTE,
|
||||||
_request(
|
_request(
|
||||||
@ -185,3 +167,18 @@ async def test_remote_install_policy_does_not_grant_remote_trust() -> None:
|
|||||||
|
|
||||||
assert response is not None and response.status_code == 403
|
assert response is not None and response.status_code == 403
|
||||||
assert service.calls == []
|
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
|
||||||
|
|||||||
@ -24,7 +24,6 @@ import {
|
|||||||
ExtensionMark,
|
ExtensionMark,
|
||||||
MetaItem,
|
MetaItem,
|
||||||
NamedItems,
|
NamedItems,
|
||||||
RuntimeBadge,
|
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
} from "./extension-ui";
|
} from "./extension-ui";
|
||||||
|
|
||||||
@ -52,8 +51,6 @@ export function ExtensionDetailSheet({
|
|||||||
const [uninstallOpen, setUninstallOpen] = useState(false);
|
const [uninstallOpen, setUninstallOpen] = useState(false);
|
||||||
if (!extension) return null;
|
if (!extension) return null;
|
||||||
|
|
||||||
const configManaged =
|
|
||||||
extension.scope !== "builtin" && !extension.managed_by_store;
|
|
||||||
const requested = new Set(extension.requested_permissions);
|
const requested = new Set(extension.requested_permissions);
|
||||||
const granted = new Set(extension.granted_permissions);
|
const granted = new Set(extension.granted_permissions);
|
||||||
const allGranted = [...requested].every((permission) => granted.has(permission));
|
const allGranted = [...requested].every((permission) => granted.has(permission));
|
||||||
@ -67,7 +64,7 @@ export function ExtensionDetailSheet({
|
|||||||
>
|
>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||||
<div className="flex items-start gap-3 pr-8">
|
<div className="flex items-start gap-3 pr-8">
|
||||||
<ExtensionMark runtime={extension.runtime} large />
|
<ExtensionMark large />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
<SheetTitle className="truncate text-[20px] font-semibold">
|
||||||
{extension.name}
|
{extension.name}
|
||||||
@ -76,7 +73,6 @@ export function ExtensionDetailSheet({
|
|||||||
{extension.description || extension.id}
|
{extension.description || extension.id}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
<RuntimeBadge runtime={extension.runtime} />
|
|
||||||
<DetailPill>{extension.version}</DetailPill>
|
<DetailPill>{extension.version}</DetailPill>
|
||||||
<StatusBadge extension={extension} />
|
<StatusBadge extension={extension} />
|
||||||
</div>
|
</div>
|
||||||
@ -91,10 +87,6 @@ export function ExtensionDetailSheet({
|
|||||||
label={t("extensions.details.source")}
|
label={t("extensions.details.source")}
|
||||||
value={extension.source}
|
value={extension.source}
|
||||||
/>
|
/>
|
||||||
<MetaItem
|
|
||||||
label={t("extensions.details.scope")}
|
|
||||||
value={extension.scope}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
<MetaItem
|
||||||
label={t("extensions.details.license")}
|
label={t("extensions.details.license")}
|
||||||
value={extension.license || "—"}
|
value={extension.license || "—"}
|
||||||
@ -113,13 +105,6 @@ export function ExtensionDetailSheet({
|
|||||||
) : null}
|
) : null}
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<NamedItems
|
|
||||||
title={t("extensions.details.contributions")}
|
|
||||||
rows={extension.contributions.map((item) => ({
|
|
||||||
name: item.name,
|
|
||||||
meta: item.kind,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<NamedItems
|
<NamedItems
|
||||||
title={t("extensions.details.dependencies")}
|
title={t("extensions.details.dependencies")}
|
||||||
rows={extension.dependencies.map((item) => ({
|
rows={extension.dependencies.map((item) => ({
|
||||||
@ -138,15 +123,11 @@ export function ExtensionDetailSheet({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[13px] font-medium text-foreground">
|
<div className="text-[13px] font-medium text-foreground">
|
||||||
{permission.name === "runtime.node"
|
{permission.name}
|
||||||
? t("extensions.knownPermissions.runtimeNode.label")
|
|
||||||
: permission.name}
|
|
||||||
</div>
|
</div>
|
||||||
{permission.reason ? (
|
{permission.reason ? (
|
||||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
||||||
{permission.name === "runtime.node"
|
{permission.reason}
|
||||||
? t("extensions.knownPermissions.runtimeNode.reason")
|
|
||||||
: permission.reason}
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -164,24 +145,22 @@ export function ExtensionDetailSheet({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{extension.managed_by_store ? (
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
disabled={busy !== null}
|
||||||
disabled={busy !== null}
|
onClick={() =>
|
||||||
onClick={() =>
|
void onAction("permissions", {
|
||||||
void onAction("permissions", {
|
id: extension.id,
|
||||||
id: extension.id,
|
permissions: allGranted ? [] : [...requested],
|
||||||
permissions: allGranted ? [] : [...requested],
|
})
|
||||||
})
|
}
|
||||||
}
|
className="rounded-full"
|
||||||
className="rounded-full"
|
>
|
||||||
>
|
{allGranted
|
||||||
{allGranted
|
? t("extensions.revokePermissions")
|
||||||
? t("extensions.revokePermissions")
|
: t("extensions.grantPermissions")}
|
||||||
: t("extensions.grantPermissions")}
|
</Button>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
@ -212,56 +191,50 @@ export function ExtensionDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{extension.managed_by_store ? (
|
<div className="flex flex-wrap items-center gap-2 border-t border-border/45 bg-background/95 px-5 py-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 border-t border-border/45 bg-background/95 px-5 py-4">
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant={extension.trusted ? "outline" : "default"}
|
||||||
variant={extension.trusted ? "outline" : "default"}
|
disabled={busy !== null || (!extension.trusted && !allGranted)}
|
||||||
disabled={busy !== null || (!extension.trusted && !allGranted)}
|
onClick={() =>
|
||||||
onClick={() =>
|
void onAction(extension.trusted ? "untrust" : "trust", {
|
||||||
void onAction(extension.trusted ? "untrust" : "trust", {
|
id: extension.id,
|
||||||
id: extension.id,
|
})
|
||||||
})
|
}
|
||||||
}
|
className="rounded-full"
|
||||||
className="rounded-full"
|
>
|
||||||
>
|
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||||
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
{extension.trusted
|
||||||
{extension.trusted
|
? t("extensions.revokeTrust")
|
||||||
? t("extensions.revokeTrust")
|
: t("extensions.trust")}
|
||||||
: t("extensions.trust")}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
disabled={busy !== null || !extension.trusted}
|
||||||
disabled={busy !== null || !extension.trusted}
|
onClick={() =>
|
||||||
onClick={() =>
|
void onAction(extension.enabled ? "disable" : "enable", {
|
||||||
void onAction(extension.enabled ? "disable" : "enable", {
|
id: extension.id,
|
||||||
id: extension.id,
|
})
|
||||||
})
|
}
|
||||||
}
|
className="rounded-full"
|
||||||
className="rounded-full"
|
>
|
||||||
>
|
{extension.enabled
|
||||||
{extension.enabled
|
? t("extensions.disable")
|
||||||
? t("extensions.disable")
|
: t("extensions.enable")}
|
||||||
: t("extensions.enable")}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
size="icon"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
variant="ghost"
|
disabled={busy !== null}
|
||||||
disabled={busy !== null}
|
aria-label={t("extensions.uninstall")}
|
||||||
aria-label={t("extensions.uninstall")}
|
title={t("extensions.uninstall")}
|
||||||
title={t("extensions.uninstall")}
|
onClick={() => setUninstallOpen(true)}
|
||||||
onClick={() => setUninstallOpen(true)}
|
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
|
||||||
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
|
>
|
||||||
>
|
<Trash2 className="h-4 w-4" aria-hidden />
|
||||||
<Trash2 className="h-4 w-4" aria-hidden />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
) : configManaged ? (
|
|
||||||
<div className="border-t border-border/45 bg-background/95 px-5 py-4 text-[12px] text-muted-foreground">
|
|
||||||
{t("extensions.configManaged")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Check, CircleAlert, Download, Loader2, Search } from "lucide-react";
|
import { CircleAlert, Download, Search } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -7,42 +7,33 @@ import { Input } from "@/components/ui/input";
|
|||||||
import {
|
import {
|
||||||
fetchExtensions,
|
fetchExtensions,
|
||||||
runExtensionAction,
|
runExtensionAction,
|
||||||
searchExtensions,
|
|
||||||
type ExtensionAction,
|
type ExtensionAction,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type {
|
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
|
||||||
ExtensionDiagnosticInfo,
|
|
||||||
ExtensionInfo,
|
|
||||||
ExtensionMarketPackage,
|
|
||||||
} from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
import { ExtensionDetailSheet } from "./ExtensionDetailSheet";
|
import { ExtensionDetailSheet } from "./ExtensionDetailSheet";
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
type ExtensionEcosystem,
|
|
||||||
ExtensionMark,
|
ExtensionMark,
|
||||||
type ExtensionTab,
|
|
||||||
filterExtensions,
|
filterExtensions,
|
||||||
filterPackages,
|
|
||||||
LoadingState,
|
LoadingState,
|
||||||
RuntimeBadge,
|
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
} from "./extension-ui";
|
} from "./extension-ui";
|
||||||
|
|
||||||
|
type InstallKind = "git" | "local";
|
||||||
|
|
||||||
export function ExtensionsCatalog() {
|
export function ExtensionsCatalog() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const [tab, setTab] = useState<ExtensionTab>("installed");
|
|
||||||
const [ecosystem, setEcosystem] = useState<ExtensionEcosystem>("all");
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [kind, setKind] = useState<InstallKind>("git");
|
||||||
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
|
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
|
||||||
const [diagnostics, setDiagnostics] = useState<ExtensionDiagnosticInfo[]>([]);
|
const [diagnostics, setDiagnostics] = useState<ExtensionDiagnosticInfo[]>([]);
|
||||||
const [packages, setPackages] = useState<ExtensionMarketPackage[]>([]);
|
|
||||||
const [selected, setSelected] = useState<ExtensionInfo | null>(null);
|
const [selected, setSelected] = useState<ExtensionInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -69,49 +60,6 @@ export function ExtensionsCatalog() {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tab !== "discover") return;
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setSearching(true);
|
|
||||||
searchExtensions(token, query, ecosystem)
|
|
||||||
.then((payload) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setPackages(payload.packages);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((reason) => {
|
|
||||||
if (!cancelled) setError((reason as Error).message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setSearching(false);
|
|
||||||
});
|
|
||||||
}, 220);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [ecosystem, query, tab, token]);
|
|
||||||
|
|
||||||
const installed = useMemo(
|
|
||||||
() => extensions.filter((extension) => extension.scope !== "builtin"),
|
|
||||||
[extensions],
|
|
||||||
);
|
|
||||||
const builtin = useMemo(
|
|
||||||
() => extensions.filter((extension) => extension.scope === "builtin"),
|
|
||||||
[extensions],
|
|
||||||
);
|
|
||||||
const installedIds = useMemo(
|
|
||||||
() =>
|
|
||||||
new Set(
|
|
||||||
installed.flatMap((extension) =>
|
|
||||||
[extension.id, extension.source_ref].filter(Boolean),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[installed],
|
|
||||||
);
|
|
||||||
|
|
||||||
const mutate = useCallback(
|
const mutate = useCallback(
|
||||||
async (
|
async (
|
||||||
action: ExtensionAction,
|
action: ExtensionAction,
|
||||||
@ -132,41 +80,50 @@ export function ExtensionsCatalog() {
|
|||||||
[refresh, token],
|
[refresh, token],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabs: Array<{ key: ExtensionTab; count?: number }> = [
|
const visible = useMemo(
|
||||||
{ key: "installed", count: installed.length },
|
() => filterExtensions(extensions, query),
|
||||||
{ key: "discover" },
|
[extensions, query],
|
||||||
{ key: "builtin", count: builtin.length },
|
);
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<section className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<form
|
||||||
<div
|
className="flex flex-col gap-2 sm:flex-row"
|
||||||
className="inline-flex w-fit rounded-[12px] bg-muted/55 p-1"
|
onSubmit={(event) => {
|
||||||
aria-label={t("extensions.tabs.label")}
|
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"
|
||||||
>
|
>
|
||||||
{tabs.map((item) => (
|
<option value="git">{t("extensions.source.git")}</option>
|
||||||
<button
|
<option value="local">{t("extensions.source.local")}</option>
|
||||||
key={item.key}
|
</select>
|
||||||
type="button"
|
<Input
|
||||||
aria-pressed={tab === item.key}
|
value={source}
|
||||||
onClick={() => setTab(item.key)}
|
onChange={(event) => setSource(event.target.value)}
|
||||||
className={cn(
|
placeholder={t(
|
||||||
"h-8 rounded-[9px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
|
kind === "git"
|
||||||
tab === item.key && "bg-background text-foreground shadow-sm",
|
? "extensions.installGitPlaceholder"
|
||||||
)}
|
: "extensions.installLocalPlaceholder",
|
||||||
>
|
)}
|
||||||
{t(`extensions.tabs.${item.key}`)}
|
className="h-11 flex-1 rounded-[14px] border-border/55 bg-settings-surface text-[13px] shadow-none"
|
||||||
{item.count === undefined ? null : (
|
/>
|
||||||
<span className="ml-1.5 text-muted-foreground">{item.count}</span>
|
<Button
|
||||||
)}
|
type="submit"
|
||||||
</button>
|
disabled={!source.trim() || busy === "install"}
|
||||||
))}
|
className="h-11 rounded-[14px] px-4"
|
||||||
</div>
|
>
|
||||||
{tab === "discover" ? (
|
<Download className="mr-2 h-4 w-4" aria-hidden />
|
||||||
<EcosystemFilter value={ecosystem} onChange={setEcosystem} />
|
{t("extensions.installAction")}
|
||||||
) : null}
|
</Button>
|
||||||
</section>
|
</form>
|
||||||
|
|
||||||
<label className="relative block">
|
<label className="relative block">
|
||||||
<Search
|
<Search
|
||||||
@ -176,11 +133,7 @@ export function ExtensionsCatalog() {
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder={t(
|
placeholder={t("extensions.searchInstalled")}
|
||||||
tab === "discover"
|
|
||||||
? "extensions.searchMarket"
|
|
||||||
: "extensions.searchInstalled",
|
|
||||||
)}
|
|
||||||
className="h-11 rounded-[14px] border-border/55 bg-settings-surface pl-10 text-[13px] shadow-none"
|
className="h-11 rounded-[14px] border-border/55 bg-settings-surface pl-10 text-[13px] shadow-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -191,29 +144,12 @@ export function ExtensionsCatalog() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tab === "discover" ? (
|
<ExtensionList
|
||||||
<MarketList
|
extensions={visible}
|
||||||
packages={filterPackages(packages, query)}
|
diagnostics={diagnostics}
|
||||||
installedIds={installedIds}
|
loading={loading}
|
||||||
loading={searching}
|
onSelect={setSelected}
|
||||||
busy={busy}
|
/>
|
||||||
onInstall={(item) =>
|
|
||||||
void mutate(
|
|
||||||
"install",
|
|
||||||
{ source: item.name, kind: "npm" },
|
|
||||||
`install:${item.name}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ExtensionList
|
|
||||||
extensions={filterExtensions(tab === "builtin" ? builtin : installed, query)}
|
|
||||||
diagnostics={diagnostics}
|
|
||||||
loading={loading}
|
|
||||||
emptyKey={tab}
|
|
||||||
onSelect={setSelected}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ExtensionDetailSheet
|
<ExtensionDetailSheet
|
||||||
extension={selected}
|
extension={selected}
|
||||||
@ -233,59 +169,28 @@ export function ExtensionsCatalog() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EcosystemFilter({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: ExtensionEcosystem;
|
|
||||||
onChange: (value: ExtensionEcosystem) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const items: ExtensionEcosystem[] = ["all", "nanobot", "pi", "openclaw"];
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 overflow-x-auto">
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={value === item}
|
|
||||||
onClick={() => onChange(item)}
|
|
||||||
className={cn(
|
|
||||||
"h-8 shrink-0 rounded-full px-2.5 text-[12px] text-muted-foreground transition-colors",
|
|
||||||
value === item && "bg-muted text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`extensions.ecosystem.${item}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExtensionList({
|
function ExtensionList({
|
||||||
extensions,
|
extensions,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
loading,
|
loading,
|
||||||
emptyKey,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
extensions: ExtensionInfo[];
|
extensions: ExtensionInfo[];
|
||||||
diagnostics: ExtensionDiagnosticInfo[];
|
diagnostics: ExtensionDiagnosticInfo[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
emptyKey: "installed" | "builtin";
|
|
||||||
onSelect: (extension: ExtensionInfo) => void;
|
onSelect: (extension: ExtensionInfo) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (loading) return <LoadingState />;
|
if (loading) return <LoadingState />;
|
||||||
if (!extensions.length) {
|
if (!extensions.length) {
|
||||||
return <EmptyState label={t(`extensions.empty.${emptyKey}`)} />;
|
return <EmptyState label={t("extensions.empty.installed")} />;
|
||||||
}
|
}
|
||||||
const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id));
|
const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id));
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
|
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
|
||||||
{extensions.map((extension, index) => (
|
{extensions.map((extension, index) => (
|
||||||
<button
|
<button
|
||||||
key={`${extension.scope}:${extension.id}`}
|
key={extension.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(extension)}
|
onClick={() => onSelect(extension)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -293,20 +198,22 @@ function ExtensionList({
|
|||||||
index > 0 && "border-t border-border/40",
|
index > 0 && "border-t border-border/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ExtensionMark runtime={extension.runtime} />
|
<ExtensionMark />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h3 className="truncate text-[14px] font-medium text-foreground">
|
<h3 className="truncate text-[14px] font-medium text-foreground">
|
||||||
{extension.name}
|
{extension.name}
|
||||||
</h3>
|
</h3>
|
||||||
<RuntimeBadge runtime={extension.runtime} />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
|
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
|
||||||
{extension.description || extension.id}
|
{extension.description || extension.id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{diagnosticIds.has(extension.id) ? (
|
{diagnosticIds.has(extension.id) ? (
|
||||||
<CircleAlert className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
|
<CircleAlert
|
||||||
|
className="h-4 w-4 shrink-0 text-amber-500"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<StatusBadge extension={extension} />
|
<StatusBadge extension={extension} />
|
||||||
</button>
|
</button>
|
||||||
@ -314,80 +221,3 @@ function ExtensionList({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarketList({
|
|
||||||
packages,
|
|
||||||
installedIds,
|
|
||||||
loading,
|
|
||||||
busy,
|
|
||||||
onInstall,
|
|
||||||
}: {
|
|
||||||
packages: ExtensionMarketPackage[];
|
|
||||||
installedIds: Set<string>;
|
|
||||||
loading: boolean;
|
|
||||||
busy: string | null;
|
|
||||||
onInstall: (item: ExtensionMarketPackage) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (loading) return <LoadingState />;
|
|
||||||
if (!packages.length) return <EmptyState label={t("extensions.empty.discover")} />;
|
|
||||||
return (
|
|
||||||
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
|
|
||||||
{packages.map((item, index) => {
|
|
||||||
const installed = installedIds.has(item.name);
|
|
||||||
const actionKey = `install:${item.name}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${item.ecosystem}:${item.name}`}
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 items-center gap-3 px-4 py-3.5",
|
|
||||||
index > 0 && "border-t border-border/40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ExtensionMark runtime={item.ecosystem} />
|
|
||||||
<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">
|
|
||||||
{item.name}
|
|
||||||
</h3>
|
|
||||||
<RuntimeBadge runtime={item.ecosystem} />
|
|
||||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
|
||||||
{item.version}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 line-clamp-1 text-[12px] text-muted-foreground">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={installed || busy === actionKey}
|
|
||||||
aria-label={
|
|
||||||
installed
|
|
||||||
? t("extensions.installed")
|
|
||||||
: t("extensions.install", { name: item.name })
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
installed
|
|
||||||
? t("extensions.installed")
|
|
||||||
: t("extensions.install", { name: item.name })
|
|
||||||
}
|
|
||||||
onClick={() => onInstall(item)}
|
|
||||||
className="h-9 w-9 shrink-0 rounded-full bg-muted/55"
|
|
||||||
>
|
|
||||||
{busy === actionKey ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
||||||
) : installed ? (
|
|
||||||
<Check className="h-4 w-4" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Download className="h-4 w-4" aria-hidden />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,21 +1,11 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Box, Loader2, PackageOpen } from "lucide-react";
|
import { Loader2, PackageOpen } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { ExtensionInfo, ExtensionMarketPackage } from "@/lib/types";
|
import type { ExtensionInfo } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type ExtensionTab = "installed" | "discover" | "builtin";
|
export function ExtensionMark({ large = false }: { large?: boolean }) {
|
||||||
export type ExtensionEcosystem = "all" | "nanobot" | "pi" | "openclaw";
|
|
||||||
|
|
||||||
export function ExtensionMark({
|
|
||||||
runtime,
|
|
||||||
large = false,
|
|
||||||
}: {
|
|
||||||
runtime: string;
|
|
||||||
large?: boolean;
|
|
||||||
}) {
|
|
||||||
const Icon = runtime === "pi" || runtime === "openclaw" ? Box : PackageOpen;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -23,19 +13,15 @@ export function ExtensionMark({
|
|||||||
large ? "h-12 w-12" : "h-10 w-10",
|
large ? "h-12 w-12" : "h-10 w-10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={large ? "h-5 w-5" : "h-4 w-4"} strokeWidth={1.8} aria-hidden />
|
<PackageOpen
|
||||||
|
className={large ? "h-5 w-5" : "h-4 w-4"}
|
||||||
|
strokeWidth={1.8}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RuntimeBadge({ runtime }: { runtime: string }) {
|
|
||||||
return (
|
|
||||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
||||||
{runtime}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ extension }: { extension: ExtensionInfo }) {
|
export function StatusBadge({ extension }: { extension: ExtensionInfo }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [key, tone] = extension.active
|
const [key, tone] = extension.active
|
||||||
@ -137,20 +123,7 @@ export function filterExtensions(
|
|||||||
const term = query.trim().toLowerCase();
|
const term = query.trim().toLowerCase();
|
||||||
if (!term) return items;
|
if (!term) return items;
|
||||||
return items.filter((item) =>
|
return items.filter((item) =>
|
||||||
[item.name, item.id, item.description, item.runtime].some((value) =>
|
[item.name, item.id, item.description].some((value) =>
|
||||||
value.toLowerCase().includes(term),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterPackages(
|
|
||||||
items: ExtensionMarketPackage[],
|
|
||||||
query: string,
|
|
||||||
): ExtensionMarketPackage[] {
|
|
||||||
const term = query.trim().toLowerCase();
|
|
||||||
if (!term) return items;
|
|
||||||
return items.filter((item) =>
|
|
||||||
[item.name, item.description, item.publisher].some((value) =>
|
|
||||||
value.toLowerCase().includes(term),
|
value.toLowerCase().includes(term),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1266,34 +1266,23 @@
|
|||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "Back to chat",
|
"backToChat": "Back to chat",
|
||||||
"title": "Extensions",
|
"title": "Extensions",
|
||||||
"tabs": {
|
|
||||||
"label": "Extension views",
|
|
||||||
"installed": "Installed",
|
|
||||||
"discover": "Discover",
|
|
||||||
"builtin": "Built in"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "All",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "Search extension packages",
|
|
||||||
"searchInstalled": "Search extensions",
|
"searchInstalled": "Search extensions",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "No external extensions installed.",
|
"installed": "No extensions installed."
|
||||||
"discover": "No matching extension packages.",
|
|
||||||
"builtin": "No built-in capabilities found."
|
|
||||||
},
|
},
|
||||||
"installed": "Installed",
|
"installKind": "Installation source",
|
||||||
"install": "Install {{name}}",
|
"source": {
|
||||||
|
"git": "Git repository",
|
||||||
|
"local": "Local folder"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/absolute/path/to/extension",
|
||||||
|
"installAction": "Install",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"scope": "Scope",
|
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"homepage": "Homepage",
|
"homepage": "Homepage",
|
||||||
"contributions": "Contributions",
|
|
||||||
"dependencies": "Dependencies",
|
"dependencies": "Dependencies",
|
||||||
"permissions": "Permissions",
|
"permissions": "Permissions",
|
||||||
"diagnostics": "Diagnostics"
|
"diagnostics": "Diagnostics"
|
||||||
@ -1302,14 +1291,7 @@
|
|||||||
"permissionPending": "Not granted",
|
"permissionPending": "Not granted",
|
||||||
"revokePermissions": "Revoke permissions",
|
"revokePermissions": "Revoke permissions",
|
||||||
"grantPermissions": "Grant permissions",
|
"grantPermissions": "Grant permissions",
|
||||||
"noPermissions": "No host permissions requested.",
|
"noPermissions": "No host permissions requested.",
|
||||||
"configManaged": "Managed by configuration.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Node.js runtime",
|
|
||||||
"reason": "Run third-party JavaScript or TypeScript in a Node.js process."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "Revoke trust",
|
"revokeTrust": "Revoke trust",
|
||||||
"trust": "Trust",
|
"trust": "Trust",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|||||||
@ -1253,34 +1253,23 @@
|
|||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "Volver al chat",
|
"backToChat": "Volver al chat",
|
||||||
"title": "Extensiones",
|
"title": "Extensiones",
|
||||||
"tabs": {
|
|
||||||
"label": "Vistas de extensiones",
|
|
||||||
"installed": "Instaladas",
|
|
||||||
"discover": "Descubrir",
|
|
||||||
"builtin": "Integradas"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "Todas",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "Buscar paquetes de extensiones",
|
|
||||||
"searchInstalled": "Buscar extensiones",
|
"searchInstalled": "Buscar extensiones",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "No hay extensiones externas instaladas.",
|
"installed": "No hay extensiones instaladas."
|
||||||
"discover": "No hay paquetes de extensiones coincidentes.",
|
|
||||||
"builtin": "No se encontraron capacidades integradas."
|
|
||||||
},
|
},
|
||||||
"installed": "Instalada",
|
"installKind": "Origen de instalación",
|
||||||
"install": "Instalar {{name}}",
|
"source": {
|
||||||
|
"git": "Repositorio Git",
|
||||||
|
"local": "Carpeta local"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/ruta/absoluta/a/la/extensión",
|
||||||
|
"installAction": "Instalar",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "Identidad",
|
"identity": "Identidad",
|
||||||
"source": "Origen",
|
"source": "Origen",
|
||||||
"scope": "Ámbito",
|
|
||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"homepage": "Página principal",
|
"homepage": "Página principal",
|
||||||
"contributions": "Contribuciones",
|
|
||||||
"dependencies": "Dependencias",
|
"dependencies": "Dependencias",
|
||||||
"permissions": "Permisos",
|
"permissions": "Permisos",
|
||||||
"diagnostics": "Diagnóstico"
|
"diagnostics": "Diagnóstico"
|
||||||
@ -1289,14 +1278,7 @@
|
|||||||
"permissionPending": "No concedido",
|
"permissionPending": "No concedido",
|
||||||
"revokePermissions": "Revocar permisos",
|
"revokePermissions": "Revocar permisos",
|
||||||
"grantPermissions": "Conceder permisos",
|
"grantPermissions": "Conceder permisos",
|
||||||
"noPermissions": "No solicita permisos del host.",
|
"noPermissions": "No solicita permisos del sistema.",
|
||||||
"configManaged": "Gestionada por la configuración.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Entorno de ejecución Node.js",
|
|
||||||
"reason": "Ejecuta JavaScript o TypeScript de terceros en un proceso de Node.js."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "Revocar confianza",
|
"revokeTrust": "Revocar confianza",
|
||||||
"trust": "Confiar",
|
"trust": "Confiar",
|
||||||
"disable": "Desactivar",
|
"disable": "Desactivar",
|
||||||
@ -1311,7 +1293,7 @@
|
|||||||
"untrusted": "No confiable",
|
"untrusted": "No confiable",
|
||||||
"inactive": "Inactiva"
|
"inactive": "Inactiva"
|
||||||
},
|
},
|
||||||
"none": "Ninguna",
|
"none": "Ninguno",
|
||||||
"loading": "Cargando extensiones…"
|
"loading": "Cargando extensiones…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1252,57 +1252,39 @@
|
|||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "Retour au chat",
|
"backToChat": "Retour au chat",
|
||||||
"title": "Extensions",
|
"title": "Extensions",
|
||||||
"tabs": {
|
|
||||||
"label": "Vues des extensions",
|
|
||||||
"installed": "Installées",
|
|
||||||
"discover": "Découvrir",
|
|
||||||
"builtin": "Intégrées"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "Toutes",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "Rechercher des paquets d’extension",
|
|
||||||
"searchInstalled": "Rechercher des extensions",
|
"searchInstalled": "Rechercher des extensions",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "Aucune extension externe installée.",
|
"installed": "Aucune extension installée."
|
||||||
"discover": "Aucun paquet d’extension correspondant.",
|
|
||||||
"builtin": "Aucune capacité intégrée trouvée."
|
|
||||||
},
|
},
|
||||||
"installed": "Installée",
|
"installKind": "Source d’installation",
|
||||||
"install": "Installer {{name}}",
|
"source": {
|
||||||
|
"git": "Dépôt Git",
|
||||||
|
"local": "Dossier local"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/chemin/absolu/vers/extension",
|
||||||
|
"installAction": "Installer",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "Identité",
|
"identity": "Identité",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"scope": "Portée",
|
|
||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"homepage": "Site web",
|
"homepage": "Page d’accueil",
|
||||||
"contributions": "Contributions",
|
|
||||||
"dependencies": "Dépendances",
|
"dependencies": "Dépendances",
|
||||||
"permissions": "Autorisations",
|
"permissions": "Autorisations",
|
||||||
"diagnostics": "Diagnostic"
|
"diagnostics": "Diagnostics"
|
||||||
},
|
},
|
||||||
"permissionGranted": "Accordée",
|
"permissionGranted": "Accordée",
|
||||||
"permissionPending": "Non accordée",
|
"permissionPending": "Non accordée",
|
||||||
"revokePermissions": "Révoquer les autorisations",
|
"revokePermissions": "Révoquer les autorisations",
|
||||||
"grantPermissions": "Accorder les autorisations",
|
"grantPermissions": "Accorder les autorisations",
|
||||||
"noPermissions": "Aucune autorisation hôte demandée.",
|
"noPermissions": "Aucune autorisation hôte demandée.",
|
||||||
"configManaged": "Gérée par la configuration.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Environnement Node.js",
|
|
||||||
"reason": "Exécute du JavaScript ou TypeScript tiers dans un processus Node.js."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "Révoquer la confiance",
|
"revokeTrust": "Révoquer la confiance",
|
||||||
"trust": "Faire confiance",
|
"trust": "Faire confiance",
|
||||||
"disable": "Désactiver",
|
"disable": "Désactiver",
|
||||||
"enable": "Activer",
|
"enable": "Activer",
|
||||||
"uninstall": "Désinstaller",
|
"uninstall": "Désinstaller",
|
||||||
"uninstallTitle": "Désinstaller l’extension ?",
|
"uninstallTitle": "Désinstaller l’extension ?",
|
||||||
"uninstallDescription": "Cette action supprime {{name}} et ses fichiers installés de nanobot.",
|
"uninstallDescription": "Cela supprime {{name}} et ses fichiers installés de nanobot.",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
@ -1310,7 +1292,7 @@
|
|||||||
"untrusted": "Non approuvée",
|
"untrusted": "Non approuvée",
|
||||||
"inactive": "Inactive"
|
"inactive": "Inactive"
|
||||||
},
|
},
|
||||||
"none": "Aucune",
|
"none": "Aucun",
|
||||||
"loading": "Chargement des extensions…"
|
"loading": "Chargement des extensions…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1250,36 +1250,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "Kembali ke obrolan",
|
"backToChat": "Kembali ke chat",
|
||||||
"title": "Ekstensi",
|
"title": "Ekstensi",
|
||||||
"tabs": {
|
|
||||||
"label": "Tampilan ekstensi",
|
|
||||||
"installed": "Terpasang",
|
|
||||||
"discover": "Temukan",
|
|
||||||
"builtin": "Bawaan"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "Semua",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "Cari paket ekstensi",
|
|
||||||
"searchInstalled": "Cari ekstensi",
|
"searchInstalled": "Cari ekstensi",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "Belum ada ekstensi eksternal terpasang.",
|
"installed": "Belum ada ekstensi terpasang."
|
||||||
"discover": "Tidak ada paket ekstensi yang cocok.",
|
|
||||||
"builtin": "Kemampuan bawaan tidak ditemukan."
|
|
||||||
},
|
},
|
||||||
"installed": "Terpasang",
|
"installKind": "Sumber instalasi",
|
||||||
"install": "Pasang {{name}}",
|
"source": {
|
||||||
|
"git": "Repositori Git",
|
||||||
|
"local": "Folder lokal"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/jalur/absolut/ke/ekstensi",
|
||||||
|
"installAction": "Pasang",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "Identitas",
|
"identity": "Identitas",
|
||||||
"source": "Sumber",
|
"source": "Sumber",
|
||||||
"scope": "Cakupan",
|
|
||||||
"license": "Lisensi",
|
"license": "Lisensi",
|
||||||
"homepage": "Beranda",
|
"homepage": "Beranda",
|
||||||
"contributions": "Kontribusi",
|
|
||||||
"dependencies": "Dependensi",
|
"dependencies": "Dependensi",
|
||||||
"permissions": "Izin",
|
"permissions": "Izin",
|
||||||
"diagnostics": "Diagnostik"
|
"diagnostics": "Diagnostik"
|
||||||
@ -1288,14 +1277,7 @@
|
|||||||
"permissionPending": "Belum diberikan",
|
"permissionPending": "Belum diberikan",
|
||||||
"revokePermissions": "Cabut izin",
|
"revokePermissions": "Cabut izin",
|
||||||
"grantPermissions": "Berikan izin",
|
"grantPermissions": "Berikan izin",
|
||||||
"noPermissions": "Tidak meminta izin host.",
|
"noPermissions": "Tidak meminta izin host.",
|
||||||
"configManaged": "Dikelola melalui konfigurasi.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Runtime Node.js",
|
|
||||||
"reason": "Jalankan JavaScript atau TypeScript pihak ketiga dalam proses Node.js."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "Cabut kepercayaan",
|
"revokeTrust": "Cabut kepercayaan",
|
||||||
"trust": "Percayai",
|
"trust": "Percayai",
|
||||||
"disable": "Nonaktifkan",
|
"disable": "Nonaktifkan",
|
||||||
@ -1306,7 +1288,7 @@
|
|||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Aktif",
|
"active": "Aktif",
|
||||||
"disabled": "Dinonaktifkan",
|
"disabled": "Nonaktif",
|
||||||
"untrusted": "Belum dipercaya",
|
"untrusted": "Belum dipercaya",
|
||||||
"inactive": "Tidak aktif"
|
"inactive": "Tidak aktif"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1252,34 +1252,23 @@
|
|||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "チャットに戻る",
|
"backToChat": "チャットに戻る",
|
||||||
"title": "拡張機能",
|
"title": "拡張機能",
|
||||||
"tabs": {
|
|
||||||
"label": "拡張機能ビュー",
|
|
||||||
"installed": "インストール済み",
|
|
||||||
"discover": "探す",
|
|
||||||
"builtin": "組み込み"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "すべて",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "拡張パッケージを検索",
|
|
||||||
"searchInstalled": "拡張機能を検索",
|
"searchInstalled": "拡張機能を検索",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "外部拡張機能はありません。",
|
"installed": "拡張機能はインストールされていません。"
|
||||||
"discover": "一致する拡張パッケージはありません。",
|
|
||||||
"builtin": "組み込み機能が見つかりません。"
|
|
||||||
},
|
},
|
||||||
"installed": "インストール済み",
|
"installKind": "インストール元",
|
||||||
"install": "{{name}} をインストール",
|
"source": {
|
||||||
|
"git": "Git リポジトリ",
|
||||||
|
"local": "ローカルフォルダー"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/拡張機能への絶対パス",
|
||||||
|
"installAction": "インストール",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "識別情報",
|
"identity": "識別情報",
|
||||||
"source": "ソース",
|
"source": "ソース",
|
||||||
"scope": "スコープ",
|
|
||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"homepage": "ホームページ",
|
"homepage": "ホームページ",
|
||||||
"contributions": "提供機能",
|
|
||||||
"dependencies": "依存関係",
|
"dependencies": "依存関係",
|
||||||
"permissions": "権限",
|
"permissions": "権限",
|
||||||
"diagnostics": "診断"
|
"diagnostics": "診断"
|
||||||
@ -1288,14 +1277,7 @@
|
|||||||
"permissionPending": "未許可",
|
"permissionPending": "未許可",
|
||||||
"revokePermissions": "権限を取り消す",
|
"revokePermissions": "権限を取り消す",
|
||||||
"grantPermissions": "権限を許可",
|
"grantPermissions": "権限を許可",
|
||||||
"noPermissions": "ホスト権限の要求はありません。",
|
"noPermissions": "ホスト権限は要求されていません。",
|
||||||
"configManaged": "設定で管理されています。",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Node.js ランタイム",
|
|
||||||
"reason": "サードパーティーの JavaScript または TypeScript を Node.js プロセスで実行します。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "信頼を取り消す",
|
"revokeTrust": "信頼を取り消す",
|
||||||
"trust": "信頼する",
|
"trust": "信頼する",
|
||||||
"disable": "無効化",
|
"disable": "無効化",
|
||||||
@ -1305,7 +1287,7 @@
|
|||||||
"uninstallDescription": "{{name}} とインストール済みファイルを nanobot から削除します。",
|
"uninstallDescription": "{{name}} とインストール済みファイルを nanobot から削除します。",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"status": {
|
"status": {
|
||||||
"active": "実行中",
|
"active": "有効",
|
||||||
"disabled": "無効",
|
"disabled": "無効",
|
||||||
"untrusted": "未信頼",
|
"untrusted": "未信頼",
|
||||||
"inactive": "停止中"
|
"inactive": "停止中"
|
||||||
|
|||||||
@ -1251,35 +1251,24 @@
|
|||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "채팅으로 돌아가기",
|
"backToChat": "채팅으로 돌아가기",
|
||||||
"title": "확장 기능",
|
"title": "확장",
|
||||||
"tabs": {
|
"searchInstalled": "확장 검색",
|
||||||
"label": "확장 기능 보기",
|
|
||||||
"installed": "설치됨",
|
|
||||||
"discover": "찾기",
|
|
||||||
"builtin": "내장"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "전체",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "확장 패키지 검색",
|
|
||||||
"searchInstalled": "확장 기능 검색",
|
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "설치된 외부 확장 기능이 없습니다.",
|
"installed": "설치된 확장이 없습니다."
|
||||||
"discover": "일치하는 확장 패키지가 없습니다.",
|
|
||||||
"builtin": "내장 기능을 찾지 못했습니다."
|
|
||||||
},
|
},
|
||||||
"installed": "설치됨",
|
"installKind": "설치 소스",
|
||||||
"install": "{{name}} 설치",
|
"source": {
|
||||||
|
"git": "Git 저장소",
|
||||||
|
"local": "로컬 폴더"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/확장/절대/경로",
|
||||||
|
"installAction": "설치",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "식별 정보",
|
"identity": "식별 정보",
|
||||||
"source": "소스",
|
"source": "소스",
|
||||||
"scope": "범위",
|
|
||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"homepage": "홈페이지",
|
"homepage": "홈페이지",
|
||||||
"contributions": "제공 기능",
|
|
||||||
"dependencies": "종속성",
|
"dependencies": "종속성",
|
||||||
"permissions": "권한",
|
"permissions": "권한",
|
||||||
"diagnostics": "진단"
|
"diagnostics": "진단"
|
||||||
@ -1288,29 +1277,22 @@
|
|||||||
"permissionPending": "허용되지 않음",
|
"permissionPending": "허용되지 않음",
|
||||||
"revokePermissions": "권한 취소",
|
"revokePermissions": "권한 취소",
|
||||||
"grantPermissions": "권한 허용",
|
"grantPermissions": "권한 허용",
|
||||||
"noPermissions": "요청한 호스트 권한이 없습니다.",
|
"noPermissions": "호스트 권한을 요청하지 않습니다.",
|
||||||
"configManaged": "설정에서 관리됩니다.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Node.js 런타임",
|
|
||||||
"reason": "타사 JavaScript 또는 TypeScript를 Node.js 프로세스에서 실행합니다."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "신뢰 취소",
|
"revokeTrust": "신뢰 취소",
|
||||||
"trust": "신뢰",
|
"trust": "신뢰",
|
||||||
"disable": "비활성화",
|
"disable": "비활성화",
|
||||||
"enable": "활성화",
|
"enable": "활성화",
|
||||||
"uninstall": "제거",
|
"uninstall": "제거",
|
||||||
"uninstallTitle": "확장 기능을 제거할까요?",
|
"uninstallTitle": "확장을 제거할까요?",
|
||||||
"uninstallDescription": "nanobot에서 {{name}} 및 설치된 파일을 제거합니다.",
|
"uninstallDescription": "nanobot에서 {{name}} 및 설치된 파일을 제거합니다.",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"status": {
|
"status": {
|
||||||
"active": "실행 중",
|
"active": "활성",
|
||||||
"disabled": "비활성",
|
"disabled": "비활성",
|
||||||
"untrusted": "신뢰 안 함",
|
"untrusted": "신뢰 안 함",
|
||||||
"inactive": "중지됨"
|
"inactive": "중지됨"
|
||||||
},
|
},
|
||||||
"none": "없음",
|
"none": "없음",
|
||||||
"loading": "확장 기능을 불러오는 중…"
|
"loading": "확장 로드 중…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1266,56 +1266,38 @@
|
|||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "Voltar ao chat",
|
"backToChat": "Voltar ao chat",
|
||||||
"title": "Extensões",
|
"title": "Extensões",
|
||||||
"tabs": {
|
|
||||||
"label": "Visualizações de extensões",
|
|
||||||
"installed": "Instaladas",
|
|
||||||
"discover": "Descobrir",
|
|
||||||
"builtin": "Integradas"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "Todas",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "Buscar pacotes de extensão",
|
|
||||||
"searchInstalled": "Buscar extensões",
|
"searchInstalled": "Buscar extensões",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "Nenhuma extensão externa instalada.",
|
"installed": "Nenhuma extensão instalada."
|
||||||
"discover": "Nenhum pacote de extensão correspondente.",
|
|
||||||
"builtin": "Nenhum recurso integrado encontrado."
|
|
||||||
},
|
},
|
||||||
"installed": "Instalada",
|
"installKind": "Origem da instalação",
|
||||||
"install": "Instalar {{name}}",
|
"source": {
|
||||||
|
"git": "Repositório Git",
|
||||||
|
"local": "Pasta local"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/caminho/absoluto/para/extensão",
|
||||||
|
"installAction": "Instalar",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "Identidade",
|
"identity": "Identidade",
|
||||||
"source": "Origem",
|
"source": "Origem",
|
||||||
"scope": "Escopo",
|
|
||||||
"license": "Licença",
|
"license": "Licença",
|
||||||
"homepage": "Página inicial",
|
"homepage": "Página inicial",
|
||||||
"contributions": "Contribuições",
|
|
||||||
"dependencies": "Dependências",
|
"dependencies": "Dependências",
|
||||||
"permissions": "Permissões",
|
"permissions": "Permissões",
|
||||||
"diagnostics": "Diagnóstico"
|
"diagnostics": "Diagnósticos"
|
||||||
},
|
},
|
||||||
"permissionGranted": "Concedida",
|
"permissionGranted": "Concedida",
|
||||||
"permissionPending": "Não concedida",
|
"permissionPending": "Não concedida",
|
||||||
"revokePermissions": "Revogar permissões",
|
"revokePermissions": "Revogar permissões",
|
||||||
"grantPermissions": "Conceder permissões",
|
"grantPermissions": "Conceder permissões",
|
||||||
"noPermissions": "Nenhuma permissão do host solicitada.",
|
"noPermissions": "Nenhuma permissão do host solicitada.",
|
||||||
"configManaged": "Gerenciada pela configuração.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Runtime Node.js",
|
|
||||||
"reason": "Executa JavaScript ou TypeScript de terceiros em um processo Node.js."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "Revogar confiança",
|
"revokeTrust": "Revogar confiança",
|
||||||
"trust": "Confiar",
|
"trust": "Confiar",
|
||||||
"disable": "Desativar",
|
"disable": "Desativar",
|
||||||
"enable": "Ativar",
|
"enable": "Ativar",
|
||||||
"uninstall": "Desinstalar",
|
"uninstall": "Desinstalar",
|
||||||
"uninstallTitle": "Desinstalar a extensão?",
|
"uninstallTitle": "Desinstalar extensão?",
|
||||||
"uninstallDescription": "Isso remove {{name}} e seus arquivos instalados do nanobot.",
|
"uninstallDescription": "Isso remove {{name}} e seus arquivos instalados do nanobot.",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"status": {
|
"status": {
|
||||||
@ -1324,7 +1306,7 @@
|
|||||||
"untrusted": "Não confiável",
|
"untrusted": "Não confiável",
|
||||||
"inactive": "Inativa"
|
"inactive": "Inativa"
|
||||||
},
|
},
|
||||||
"none": "Nenhuma",
|
"none": "Nenhum",
|
||||||
"loading": "Carregando extensões…"
|
"loading": "Carregando extensões…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1251,35 +1251,24 @@
|
|||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "Quay lại trò chuyện",
|
"backToChat": "Quay lại trò chuyện",
|
||||||
"title": "Tiện ích mở rộng",
|
"title": "Tiện ích",
|
||||||
"tabs": {
|
|
||||||
"label": "Chế độ xem tiện ích",
|
|
||||||
"installed": "Đã cài",
|
|
||||||
"discover": "Khám phá",
|
|
||||||
"builtin": "Tích hợp"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "Tất cả",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "Tìm gói tiện ích",
|
|
||||||
"searchInstalled": "Tìm tiện ích",
|
"searchInstalled": "Tìm tiện ích",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "Chưa cài tiện ích bên ngoài.",
|
"installed": "Chưa cài tiện ích nào."
|
||||||
"discover": "Không có gói tiện ích phù hợp.",
|
|
||||||
"builtin": "Không tìm thấy khả năng tích hợp."
|
|
||||||
},
|
},
|
||||||
"installed": "Đã cài",
|
"installKind": "Nguồn cài đặt",
|
||||||
"install": "Cài {{name}}",
|
"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": {
|
"details": {
|
||||||
"identity": "Định danh",
|
"identity": "Danh tính",
|
||||||
"source": "Nguồn",
|
"source": "Nguồn",
|
||||||
"scope": "Phạm vi",
|
|
||||||
"license": "Giấy phép",
|
"license": "Giấy phép",
|
||||||
"homepage": "Trang chủ",
|
"homepage": "Trang chủ",
|
||||||
"contributions": "Khả năng cung cấp",
|
|
||||||
"dependencies": "Phụ thuộc",
|
"dependencies": "Phụ thuộc",
|
||||||
"permissions": "Quyền",
|
"permissions": "Quyền",
|
||||||
"diagnostics": "Chẩn đoán"
|
"diagnostics": "Chẩn đoán"
|
||||||
@ -1288,14 +1277,7 @@
|
|||||||
"permissionPending": "Chưa cấp",
|
"permissionPending": "Chưa cấp",
|
||||||
"revokePermissions": "Thu hồi quyền",
|
"revokePermissions": "Thu hồi quyền",
|
||||||
"grantPermissions": "Cấp quyền",
|
"grantPermissions": "Cấp quyền",
|
||||||
"noPermissions": "Không yêu cầu quyền máy chủ.",
|
"noPermissions": "Không yêu cầu quyền máy chủ.",
|
||||||
"configManaged": "Được quản lý bằng cấu hình.",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Môi trường chạy Node.js",
|
|
||||||
"reason": "Chạy JavaScript hoặc TypeScript của bên thứ ba trong tiến trình Node.js."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "Thu hồi tin cậy",
|
"revokeTrust": "Thu hồi tin cậy",
|
||||||
"trust": "Tin cậy",
|
"trust": "Tin cậy",
|
||||||
"disable": "Tắt",
|
"disable": "Tắt",
|
||||||
@ -1305,7 +1287,7 @@
|
|||||||
"uninstallDescription": "Thao tác này xóa {{name}} và các tệp đã cài khỏi nanobot.",
|
"uninstallDescription": "Thao tác này xóa {{name}} và các tệp đã cài khỏi nanobot.",
|
||||||
"cancel": "Hủy",
|
"cancel": "Hủy",
|
||||||
"status": {
|
"status": {
|
||||||
"active": "Đang chạy",
|
"active": "Đang hoạt động",
|
||||||
"disabled": "Đã tắt",
|
"disabled": "Đã tắt",
|
||||||
"untrusted": "Chưa tin cậy",
|
"untrusted": "Chưa tin cậy",
|
||||||
"inactive": "Không hoạt động"
|
"inactive": "Không hoạt động"
|
||||||
|
|||||||
@ -1266,34 +1266,23 @@
|
|||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "返回聊天",
|
"backToChat": "返回聊天",
|
||||||
"title": "扩展",
|
"title": "扩展",
|
||||||
"tabs": {
|
|
||||||
"label": "扩展视图",
|
|
||||||
"installed": "已安装",
|
|
||||||
"discover": "发现",
|
|
||||||
"builtin": "内置"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "全部",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "搜索扩展包",
|
|
||||||
"searchInstalled": "搜索扩展",
|
"searchInstalled": "搜索扩展",
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "尚未安装外部扩展。",
|
"installed": "尚未安装扩展。"
|
||||||
"discover": "没有匹配的扩展包。",
|
|
||||||
"builtin": "未发现内置能力。"
|
|
||||||
},
|
},
|
||||||
"installed": "已安装",
|
"installKind": "安装来源",
|
||||||
"install": "安装 {{name}}",
|
"source": {
|
||||||
|
"git": "Git 仓库",
|
||||||
|
"local": "本地文件夹"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/扩展的绝对路径",
|
||||||
|
"installAction": "安装",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "标识",
|
"identity": "标识",
|
||||||
"source": "来源",
|
"source": "来源",
|
||||||
"scope": "范围",
|
|
||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"homepage": "主页",
|
"homepage": "主页",
|
||||||
"contributions": "提供的能力",
|
|
||||||
"dependencies": "依赖",
|
"dependencies": "依赖",
|
||||||
"permissions": "权限",
|
"permissions": "权限",
|
||||||
"diagnostics": "诊断"
|
"diagnostics": "诊断"
|
||||||
@ -1302,14 +1291,7 @@
|
|||||||
"permissionPending": "未授予",
|
"permissionPending": "未授予",
|
||||||
"revokePermissions": "撤销权限",
|
"revokePermissions": "撤销权限",
|
||||||
"grantPermissions": "授予权限",
|
"grantPermissions": "授予权限",
|
||||||
"noPermissions": "未请求宿主权限。",
|
"noPermissions": "未请求宿主权限。",
|
||||||
"configManaged": "由配置管理。",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Node.js 运行时",
|
|
||||||
"reason": "允许扩展在 Node.js 进程中运行第三方 JavaScript 或 TypeScript。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "撤销信任",
|
"revokeTrust": "撤销信任",
|
||||||
"trust": "信任",
|
"trust": "信任",
|
||||||
"disable": "停用",
|
"disable": "停用",
|
||||||
|
|||||||
@ -1251,35 +1251,24 @@
|
|||||||
},
|
},
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"backToChat": "返回聊天",
|
"backToChat": "返回聊天",
|
||||||
"title": "擴充套件",
|
"title": "擴充功能",
|
||||||
"tabs": {
|
"searchInstalled": "搜尋擴充功能",
|
||||||
"label": "擴充套件檢視",
|
|
||||||
"installed": "已安裝",
|
|
||||||
"discover": "探索",
|
|
||||||
"builtin": "內建"
|
|
||||||
},
|
|
||||||
"ecosystem": {
|
|
||||||
"all": "全部",
|
|
||||||
"nanobot": "nanobot",
|
|
||||||
"pi": "Pi",
|
|
||||||
"openclaw": "OpenClaw"
|
|
||||||
},
|
|
||||||
"searchMarket": "搜尋擴充套件",
|
|
||||||
"searchInstalled": "搜尋擴充套件",
|
|
||||||
"empty": {
|
"empty": {
|
||||||
"installed": "尚未安裝外部擴充套件。",
|
"installed": "尚未安裝擴充功能。"
|
||||||
"discover": "沒有相符的擴充套件。",
|
|
||||||
"builtin": "找不到內建能力。"
|
|
||||||
},
|
},
|
||||||
"installed": "已安裝",
|
"installKind": "安裝來源",
|
||||||
"install": "安裝 {{name}}",
|
"source": {
|
||||||
|
"git": "Git 儲存庫",
|
||||||
|
"local": "本機資料夾"
|
||||||
|
},
|
||||||
|
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
||||||
|
"installLocalPlaceholder": "/擴充功能的絕對路徑",
|
||||||
|
"installAction": "安裝",
|
||||||
"details": {
|
"details": {
|
||||||
"identity": "識別資訊",
|
"identity": "識別資訊",
|
||||||
"source": "來源",
|
"source": "來源",
|
||||||
"scope": "範圍",
|
|
||||||
"license": "授權條款",
|
"license": "授權條款",
|
||||||
"homepage": "首頁",
|
"homepage": "首頁",
|
||||||
"contributions": "提供的能力",
|
|
||||||
"dependencies": "相依項目",
|
"dependencies": "相依項目",
|
||||||
"permissions": "權限",
|
"permissions": "權限",
|
||||||
"diagnostics": "診斷"
|
"diagnostics": "診斷"
|
||||||
@ -1288,29 +1277,22 @@
|
|||||||
"permissionPending": "未授予",
|
"permissionPending": "未授予",
|
||||||
"revokePermissions": "撤銷權限",
|
"revokePermissions": "撤銷權限",
|
||||||
"grantPermissions": "授予權限",
|
"grantPermissions": "授予權限",
|
||||||
"noPermissions": "未要求主機權限。",
|
"noPermissions": "未要求主機權限。",
|
||||||
"configManaged": "由設定管理。",
|
|
||||||
"knownPermissions": {
|
|
||||||
"runtimeNode": {
|
|
||||||
"label": "Node.js 執行環境",
|
|
||||||
"reason": "允許擴充套件在 Node.js 行程中執行第三方 JavaScript 或 TypeScript。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"revokeTrust": "撤銷信任",
|
"revokeTrust": "撤銷信任",
|
||||||
"trust": "信任",
|
"trust": "信任",
|
||||||
"disable": "停用",
|
"disable": "停用",
|
||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
"uninstall": "解除安裝",
|
"uninstall": "解除安裝",
|
||||||
"uninstallTitle": "解除安裝擴充套件?",
|
"uninstallTitle": "解除安裝擴充功能?",
|
||||||
"uninstallDescription": "這會從 nanobot 移除 {{name}} 及其已安裝檔案。",
|
"uninstallDescription": "這會從 nanobot 移除 {{name}} 及其已安裝檔案。",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"status": {
|
"status": {
|
||||||
"active": "執行中",
|
"active": "運作中",
|
||||||
"disabled": "已停用",
|
"disabled": "已停用",
|
||||||
"untrusted": "未信任",
|
"untrusted": "未信任",
|
||||||
"inactive": "未執行"
|
"inactive": "未運作"
|
||||||
},
|
},
|
||||||
"none": "無",
|
"none": "無",
|
||||||
"loading": "正在載入擴充套件…"
|
"loading": "正在載入擴充功能…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type {
|
|||||||
ChannelValidationPayload,
|
ChannelValidationPayload,
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
CliAppsPayload,
|
CliAppsPayload,
|
||||||
ExtensionMarketPayload,
|
|
||||||
ExtensionsPayload,
|
ExtensionsPayload,
|
||||||
FilePreviewPayload,
|
FilePreviewPayload,
|
||||||
ImageGenerationSettingsUpdate,
|
ImageGenerationSettingsUpdate,
|
||||||
@ -311,21 +310,6 @@ export async function fetchExtensions(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchExtensions(
|
|
||||||
token: string,
|
|
||||||
query: string,
|
|
||||||
ecosystem: string,
|
|
||||||
base: string = "",
|
|
||||||
): Promise<ExtensionMarketPayload> {
|
|
||||||
const params = new URLSearchParams({ q: query, ecosystem });
|
|
||||||
return request<ExtensionMarketPayload>(
|
|
||||||
`${base}/api/extensions/market?${params}`,
|
|
||||||
token,
|
|
||||||
undefined,
|
|
||||||
API_READ_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExtensionAction =
|
export type ExtensionAction =
|
||||||
| "install"
|
| "install"
|
||||||
| "enable"
|
| "enable"
|
||||||
|
|||||||
@ -734,12 +734,6 @@ export interface CliAppsPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionContributionInfo {
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtensionDependencyInfo {
|
export interface ExtensionDependencyInfo {
|
||||||
kind: string;
|
kind: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -756,11 +750,9 @@ export interface ExtensionInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
runtime: "python" | "pi" | "openclaw" | "declarative" | string;
|
|
||||||
description: string;
|
description: string;
|
||||||
homepage: string;
|
homepage: string;
|
||||||
license: string;
|
license: string;
|
||||||
scope: "builtin" | "user" | "workspace" | string;
|
|
||||||
location: string | null;
|
location: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
trusted: boolean;
|
trusted: boolean;
|
||||||
@ -771,8 +763,6 @@ export interface ExtensionInfo {
|
|||||||
source_ref: string;
|
source_ref: string;
|
||||||
integrity: string;
|
integrity: string;
|
||||||
installed_at: string;
|
installed_at: string;
|
||||||
managed_by_store: boolean;
|
|
||||||
contributions: ExtensionContributionInfo[];
|
|
||||||
dependencies: ExtensionDependencyInfo[];
|
dependencies: ExtensionDependencyInfo[];
|
||||||
permissions: ExtensionPermissionInfo[];
|
permissions: ExtensionPermissionInfo[];
|
||||||
}
|
}
|
||||||
@ -789,22 +779,6 @@ export interface ExtensionsPayload {
|
|||||||
diagnostics: ExtensionDiagnosticInfo[];
|
diagnostics: ExtensionDiagnosticInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionMarketPackage {
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
description: string;
|
|
||||||
ecosystem: "nanobot" | "pi" | "openclaw" | string;
|
|
||||||
publisher: string;
|
|
||||||
license: string;
|
|
||||||
homepage: string;
|
|
||||||
repository: string;
|
|
||||||
published_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtensionMarketPayload {
|
|
||||||
packages: ExtensionMarketPackage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NanobotFeatureInfo {
|
export interface NanobotFeatureInfo {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|||||||
@ -87,7 +87,7 @@ describe("webui API helpers", () => {
|
|||||||
await fetchExtensions("tok");
|
await fetchExtensions("tok");
|
||||||
await runExtensionAction("tok", "install", {
|
await runExtensionAction("tok", "install", {
|
||||||
source: "本地扩展",
|
source: "本地扩展",
|
||||||
kind: "npm",
|
kind: "local",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenNthCalledWith(
|
expect(fetch).toHaveBeenNthCalledWith(
|
||||||
@ -105,7 +105,7 @@ describe("webui API helpers", () => {
|
|||||||
JSON.parse(
|
JSON.parse(
|
||||||
decodeURIComponent(headers.get("X-Nanobot-Extension-Values") ?? ""),
|
decodeURIComponent(headers.get("X-Nanobot-Extension-Values") ?? ""),
|
||||||
),
|
),
|
||||||
).toEqual({ source: "本地扩展", kind: "npm" });
|
).toEqual({ source: "本地扩展", kind: "local" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes pagination params when fetching a WebUI thread page", async () => {
|
it("passes pagination params when fetching a WebUI thread page", async () => {
|
||||||
|
|||||||
@ -361,11 +361,9 @@ describe("App layout", () => {
|
|||||||
id: "nanobot.shell",
|
id: "nanobot.shell",
|
||||||
name: "Shell",
|
name: "Shell",
|
||||||
version: "1",
|
version: "1",
|
||||||
runtime: "python",
|
|
||||||
description: "Run shell commands.",
|
description: "Run shell commands.",
|
||||||
homepage: "",
|
homepage: "",
|
||||||
license: "",
|
license: "",
|
||||||
scope: "builtin",
|
|
||||||
location: null,
|
location: null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trusted: true,
|
trusted: true,
|
||||||
@ -376,7 +374,6 @@ describe("App layout", () => {
|
|||||||
source_ref: "",
|
source_ref: "",
|
||||||
integrity: "",
|
integrity: "",
|
||||||
installed_at: "",
|
installed_at: "",
|
||||||
contributions: [{ kind: "tool", name: "shell", description: "" }],
|
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
}],
|
}],
|
||||||
@ -391,7 +388,6 @@ describe("App layout", () => {
|
|||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Extensions" }));
|
fireEvent.click(within(sidebar).getByRole("button", { name: "Extensions" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Extensions" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Extensions" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Built in/ }));
|
|
||||||
expect(await screen.findByText("Shell")).toBeInTheDocument();
|
expect(await screen.findByText("Shell")).toBeInTheDocument();
|
||||||
expect(within(sidebar).getByRole("button", { name: "Extensions" })).toHaveAttribute(
|
expect(within(sidebar).getByRole("button", { name: "Extensions" })).toHaveAttribute(
|
||||||
"aria-current",
|
"aria-current",
|
||||||
|
|||||||
@ -18,28 +18,24 @@ function response(body: unknown): Response {
|
|||||||
|
|
||||||
function extension(overrides: Partial<ExtensionInfo> = {}): ExtensionInfo {
|
function extension(overrides: Partial<ExtensionInfo> = {}): ExtensionInfo {
|
||||||
return {
|
return {
|
||||||
id: "sample.pi",
|
id: "sample.tools",
|
||||||
name: "Sample Pi",
|
name: "Sample Tools",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
runtime: "pi",
|
description: "Adds a small set of native tools.",
|
||||||
description: "A compatible Pi extension.",
|
|
||||||
homepage: "",
|
homepage: "",
|
||||||
license: "MIT",
|
license: "MIT",
|
||||||
scope: "user",
|
location: "/tmp/extensions/sample.tools",
|
||||||
location: "/tmp/extensions/sample.pi",
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
trusted: false,
|
trusted: false,
|
||||||
active: false,
|
active: false,
|
||||||
requested_permissions: ["process.spawn"],
|
requested_permissions: ["network"],
|
||||||
granted_permissions: [],
|
granted_permissions: [],
|
||||||
source: "npm",
|
source: "git",
|
||||||
source_ref: "@sample/pi-extension",
|
source_ref: "https://example.com/sample-tools.git",
|
||||||
integrity: "sha512-example",
|
integrity: "sha256:example",
|
||||||
installed_at: "2026-07-26T00:00:00Z",
|
installed_at: "2026-07-26T00:00:00Z",
|
||||||
managed_by_store: true,
|
|
||||||
contributions: [{ kind: "tool", name: "sample", description: "" }],
|
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
permissions: [{ name: "process.spawn", reason: "Runs the extension host." }],
|
permissions: [{ name: "network", reason: "Fetch selected URLs." }],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -57,18 +53,18 @@ describe("ExtensionsView", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires permission grants before an extension can be trusted", async () => {
|
it("requires permission grants before trust", async () => {
|
||||||
let current = extension();
|
let current = extension();
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
requests.push({ url, init });
|
requests.push({ url, init });
|
||||||
if (url === "/api/extensions/permissions") {
|
if (url === "/api/extensions/permissions") {
|
||||||
current = extension({ granted_permissions: ["process.spawn"] });
|
current = extension({ granted_permissions: ["network"] });
|
||||||
}
|
}
|
||||||
if (url === "/api/extensions/trust") {
|
if (url === "/api/extensions/trust") {
|
||||||
current = extension({
|
current = extension({
|
||||||
granted_permissions: ["process.spawn"],
|
granted_permissions: ["network"],
|
||||||
trusted: true,
|
trusted: true,
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
@ -79,110 +75,45 @@ describe("ExtensionsView", () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
renderView();
|
renderView();
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /Sample Pi/ }));
|
fireEvent.click(await screen.findByRole("button", { name: /Sample Tools/ }));
|
||||||
|
|
||||||
const trust = screen.getByRole("button", { name: "Trust" });
|
const trust = screen.getByRole("button", { name: "Trust" });
|
||||||
expect(trust).toBeDisabled();
|
expect(trust).toBeDisabled();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Grant permissions" }));
|
fireEvent.click(screen.getByRole("button", { name: "Grant permissions" }));
|
||||||
|
|
||||||
await waitFor(() => expect(trust).toBeEnabled());
|
await waitFor(() => expect(trust).toBeEnabled());
|
||||||
fireEvent.click(trust);
|
fireEvent.click(trust);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() =>
|
||||||
expect(requests.some(({ url }) => url === "/api/extensions/trust")).toBe(true);
|
expect(requests.some(({ url }) => url === "/api/extensions/trust")).toBe(true),
|
||||||
});
|
|
||||||
const permissionRequest = requests.find(
|
|
||||||
({ url }) => url === "/api/extensions/permissions",
|
|
||||||
);
|
);
|
||||||
const encoded = new Headers(permissionRequest?.init?.headers).get(
|
|
||||||
"X-Nanobot-Extension-Values",
|
|
||||||
);
|
|
||||||
expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({
|
|
||||||
id: "sample.pi",
|
|
||||||
permissions: ["process.spawn"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("discovers and installs a package without granting trust", async () => {
|
it("installs a Git package without granting trust", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
requests.push({ url, init });
|
requests.push({ url, init });
|
||||||
if (url.startsWith("/api/extensions/market?")) {
|
|
||||||
return response({
|
|
||||||
packages: [{
|
|
||||||
name: "@sample/pi-extension",
|
|
||||||
version: "1.0.0",
|
|
||||||
description: "A compatible Pi extension.",
|
|
||||||
ecosystem: "pi",
|
|
||||||
publisher: "sample",
|
|
||||||
license: "MIT",
|
|
||||||
homepage: "",
|
|
||||||
repository: "",
|
|
||||||
published_at: "",
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return url === "/api/extensions"
|
return url === "/api/extensions"
|
||||||
? response({ extensions: [], diagnostics: [] })
|
? response({ extensions: [], diagnostics: [] })
|
||||||
: response({});
|
: response({});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
renderView();
|
renderView();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Discover" }));
|
fireEvent.change(screen.getByPlaceholderText("https://github.com/acme/extension.git"), {
|
||||||
fireEvent.click(await screen.findByRole("button", {
|
target: { value: "https://example.com/sample-tools.git" },
|
||||||
name: "Install @sample/pi-extension",
|
|
||||||
}));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(requests.some(({ url }) => url === "/api/extensions/install")).toBe(true);
|
|
||||||
});
|
});
|
||||||
const installRequest = requests.find(({ url }) => url === "/api/extensions/install");
|
fireEvent.click(screen.getByRole("button", { name: "Install" }));
|
||||||
const encoded = new Headers(installRequest?.init?.headers).get(
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(requests.some(({ url }) => url === "/api/extensions/install")).toBe(true),
|
||||||
|
);
|
||||||
|
const install = requests.find(({ url }) => url === "/api/extensions/install");
|
||||||
|
const encoded = new Headers(install?.init?.headers).get(
|
||||||
"X-Nanobot-Extension-Values",
|
"X-Nanobot-Extension-Values",
|
||||||
);
|
);
|
||||||
expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({
|
expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({
|
||||||
source: "@sample/pi-extension",
|
source: "https://example.com/sample-tools.git",
|
||||||
kind: "npm",
|
kind: "git",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("localizes permissions generated by a compatibility adapter", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => response({
|
|
||||||
extensions: [extension({
|
|
||||||
requested_permissions: ["runtime.node"],
|
|
||||||
permissions: [{
|
|
||||||
name: "runtime.node",
|
|
||||||
reason: "Raw adapter copy.",
|
|
||||||
}],
|
|
||||||
})],
|
|
||||||
diagnostics: [],
|
|
||||||
})));
|
|
||||||
|
|
||||||
renderView();
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /Sample Pi/ }));
|
|
||||||
|
|
||||||
expect(screen.getByText("Node.js runtime")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(
|
|
||||||
"Run third-party JavaScript or TypeScript in a Node.js process.",
|
|
||||||
)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps config-discovered extensions read-only", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => response({
|
|
||||||
extensions: [extension({
|
|
||||||
scope: "workspace",
|
|
||||||
source: "path",
|
|
||||||
managed_by_store: false,
|
|
||||||
})],
|
|
||||||
diagnostics: [],
|
|
||||||
})));
|
|
||||||
|
|
||||||
renderView();
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /Sample Pi/ }));
|
|
||||||
|
|
||||||
expect(screen.getByText("Managed by configuration.")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Trust" })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Uninstall" })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user