refactor(extensions): simplify the native extension platform

This commit is contained in:
Xubin Ren 2026-07-27 14:37:05 +08:00
parent d68857bb2d
commit bed0db4922
71 changed files with 1079 additions and 6805 deletions

View File

@ -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) |
| Enable web search | [Configure Web Search](./guides/configure-web-search.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) |
| Schedule work or create a local trigger | [Automations](./automations.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) |
| Add a channel package | [Channel Package Guide](./channel-package-guide.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) |
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.

View File

@ -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 |
| 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` |
| 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 |
| 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 |
@ -257,10 +257,7 @@ operations:
| Command | Description |
|---|---|
| `nanobot extensions list` | Show installed packages and activation policy |
| `nanobot extensions inspect <id>` | Show contributions, 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 inspect <id>` | Show identity, dependencies, requested permissions, and diagnostics |
| `nanobot extensions install <url> --kind git [--ref <ref>]` | Install from a Git branch, tag, or commit |
| `nanobot extensions install <path> --kind local` | Install from a local package directory |
| `nanobot extensions permissions <id> [permissions...]` | Replace the exact granted permission set; omit values to revoke all |
@ -274,16 +271,16 @@ operations:
Example:
```bash
nanobot extensions install @acme/pi-review
nanobot extensions inspect pi.acme.pi-review
nanobot extensions permissions pi.acme.pi-review workspace.read
nanobot extensions trust pi.acme.pi-review
nanobot extensions enable pi.acme.pi-review
nanobot extensions install https://github.com/acme/nanobot-review.git
nanobot extensions inspect acme.review
nanobot extensions permissions acme.review workspace.read
nanobot extensions trust acme.review
nanobot extensions enable acme.review
```
Installed packages live under `~/.nanobot/extensions/`. They do not execute
until trusted. See [Extensions](./extensions.md) for the safety model and
[Extension Authoring](./extension-authoring.md) for package compatibility.
[Extension Authoring](./extension-authoring.md) for the native package contract.
## Optional Features

View File

@ -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) |
| Enable image generation | [Image Generation](#image-generation) |
| 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) |
| 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) |
@ -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 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) |
| 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) |
| 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) |
@ -2239,27 +2239,12 @@ When enabled, all incoming messages — regardless of which channel they arrive
## Extensions
Use the WebUI **Extensions** page or `nanobot extensions` commands for normal
installation and trust decisions. The top-level `extensions` object is for
advanced discovery and policy:
installation and trust decisions. Extension support can be disabled globally:
```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"
}
}
}
"enabled": true
}
}
```
@ -2267,18 +2252,10 @@ advanced discovery and policy:
| Option | Default | Description |
|---|---|---|
| `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
`~/.nanobot/extensions/`. Config policy is merged at discovery time; nanobot
does not silently rewrite `config.json` when you install a package.
Installed packages and their trust, permission, and activation state live
under `~/.nanobot/extensions/`. Managing an extension does not rewrite
`config.json`.
See [Extensions](./extensions.md) for the safe install flow and
[Extension Authoring](./extension-authoring.md) for the package contract.

View File

@ -1,56 +1,33 @@
# Extension Authoring
nanobot extensions are packages with a strict `nanobot.extension.json`
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:
A native nanobot extension is a directory containing:
```text
my-extension/
|-- nanobot.extension.json
`-- my_extension/
`-- __init__.py
nanobot-review/
├── nanobot.extension.json
└── extension.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
{
"id": "acme.review",
"name": "Acme Review",
"version": "1.0.0",
"entry": "extension:register",
"description": "Adds repository review tools.",
"apiVersion": 1,
"runtime": "python",
"entry": "my_extension:register",
"description": "Adds a review tool and command.",
"homepage": "https://example.com/acme-review",
"license": "MIT",
"contributions": [
{
"kind": "tool",
"name": "review_code",
"description": "Review a local change."
},
{
"kind": "command",
"name": "review",
"description": "Start a review from chat."
}
],
"homepage": "https://github.com/acme/nanobot-review",
"dependencies": [
{
"kind": "python",
"name": "acme-review-core",
"specifier": ">=1,<2",
"optional": false
"kind": "executable",
"name": "git"
}
],
"permissions": [
@ -62,245 +39,109 @@ my-extension/
}
```
Unknown fields are rejected. This is deliberate: a misspelled permission,
dependency, or contribution must not silently change package behavior.
Required fields are `id`, `name`, and `version`. `entry` defaults to
`"extension:register"` and `apiVersion` defaults to `1`.
## Manifest Reference
### 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.
IDs use lowercase letters, digits, dots, underscores, and hyphens. Entry points
use `module:function` syntax and must resolve inside the package.
### Dependencies
| Kind | `name` identifies | Version behavior |
|---|---|---|
| `python` | Installed Python distribution | PEP 440 specifier |
| `npm` | Package under the extension's `node_modules` | npm installation constraint |
| `executable` | Command on `PATH` | No version probe |
| `environment` | Environment variable | Must be non-empty |
| `extension` | Another extension ID | Active extension version |
| Kind | Meaning |
|---|---|
| `python` | Installed Python distribution; `specifier` accepts a version constraint |
| `executable` | Command available on `PATH` |
| `environment` | Non-empty environment variable |
Set `optional: true` when the extension can activate without the dependency.
Do not put API keys in the manifest.
Set `"optional": true` when a missing dependency should not block activation.
### Permissions
Permission names are lowercase namespaced identifiers such as
`workspace.read`, `workspace.write`, or `network.http`. Include a concrete
reason the user can evaluate. Activation requires every requested permission to
be granted.
Permissions are lowercase namespaced identifiers chosen by the package, such
as `workspace.read` or `network`. Give each permission a concrete reason.
Activation waits until every requested permission is granted.
Permissions are review and activation gates; they do not constrain direct
Python or Node process access and are not an OS sandbox. Keep the request set
minimal and use native host operations when one exists.
The host currently uses permissions as explicit user consent. They do not
sandbox Python code, so do not describe a permission as stronger isolation
than it provides.
## Native Python Runtime
## Registration API
The Python entry is `module[:attribute]`; the default attribute is `register`.
The function is synchronous and must return `None`:
The entry point receives `PythonExtensionApi` and must return `None`:
```python
from typing import Any
from nanobot.agent.tools.base import Tool
class ReviewTool(Tool):
@property
def name(self) -> str:
return "review_code"
return "review_repository"
@property
def description(self) -> str:
return "Review a local code change."
return "Review the current repository."
@property
def parameters(self) -> dict:
return {
"type": "object",
"properties": {
"path": {"type": "string"},
},
"required": ["path"],
}
def parameters(self) -> dict[str, Any]:
return {"type": "object", "properties": {}}
async def execute(self, path: str) -> str:
return f"Review requested for {path}"
async def execute(self, **kwargs: Any) -> str:
return "No findings."
def register(api) -> None:
api.register_tool(ReviewTool())
```
The v1 Python API exposes:
The API has three stable methods:
- `register_tool(tool)`
- `register_command(command, handler, prefix=False)`
- `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"
]
}
```python
api.register_tool(tool)
api.register_command("review", handler)
api.register_hook_factory(factory)
```
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`
- OpenClaw: `openclaw-plugin` plus `openclaw.extensions` or
`openclaw.runtimeExtensions`
Do not modify `AgentLoop` or global registries directly. The API tags every
registration with the extension ID so reload, failure rollback, and uninstall
can remove exactly what the package owns.
The adapter adds the `runtime.node` permission to both generated manifests.
Users must explicitly grant it before any third-party JavaScript or TypeScript
entry runs.
## Collision and failure behavior
Native Python packages can be installed from a local directory or Git source.
The market is an index; installation always passes through local validation,
dependency checks, permission review, trust, and activation.
Tool and command names are unique across core and active extensions. If an
extension registers a duplicate name, activation fails for that extension and
all of its partial registrations are rolled back.
## 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
upstream API.
## Develop locally
| Upstream capability | Pi | OpenClaw | nanobot behavior |
|---|---|---|---|
| Tool registration and calls | Executable | Executable | Projected as native `Tool`; implementation stays in Node sidecar |
| Slash commands | Executable | Executable | Registered in native command router |
| Supported lifecycle events | Observation-only | Observation-only | Receives serialized run/tool events; cannot mutate native context |
| 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:
1. Create the manifest and entry module.
2. Install the directory with `--kind local`.
3. Inspect and grant its permissions.
4. Trust it.
5. Reinstall after editing so nanobot records a new integrity digest.
```bash
nanobot extensions install ./my-extension --kind local
nanobot extensions install "$PWD" --kind local
nanobot extensions inspect acme.review
nanobot extensions permissions acme.review workspace.read
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;
- 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.
## Distribution
For nanobot itself, extension tests live under `tests/extensions/`. Keep
compatibility fixtures small and assert diagnostics for unsupported APIs.
## Design Rules
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.
Publish the directory in a Git repository. Users can pin a release tag or
commit with `--ref`. The repository root must contain
`nanobot.extension.json`; install scripts and generated compatibility manifests
are not part of the native contract.

View File

@ -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.

View File

@ -1,205 +1,89 @@
# Extensions
Extensions add capabilities to nanobot without modifying the agent loop. A
package can declare tools, commands, hooks, skills, channels, providers, MCP
servers, or WebUI surfaces in one governable manifest. Extension API v1
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.
Extensions add native tools, slash commands, or lifecycle hooks without
changing nanobot core. An extension is a Python package with one manifest and
one registration entry point.
Use this page to install and manage extensions. To publish one, read
[Extension Authoring](./extension-authoring.md). For the internal design, read
[Extension System](./extension-system.md).
Use an extension when a capability needs executable integration with nanobot.
Use a [skill](./skills.md) when instructions alone are enough, an App when the
agent should call an external CLI, and MCP when a service already exposes an
MCP server.
## Install Safely
## Install
The WebUI **Extensions** page and the `nanobot extensions` commands use the same
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
Install from a Git repository:
```bash
# Search all supported npm ecosystems
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
nanobot extensions install https://github.com/acme/nanobot-review.git
```
Install from Git or a local directory when the package is not published:
Install a local package while developing it:
```bash
nanobot extensions install https://github.com/acme/nanobot-tool.git --kind git
nanobot extensions install ./my-extension --kind local
nanobot extensions install /absolute/path/to/nanobot-review --kind local
```
Local-directory installation is available only to local WebUI requests. Remote
browser clients cannot ask the gateway to read an arbitrary server path.
Git installs may select a branch, tag, or commit:
## Sources and Scopes
Extensions can come from three scopes:
| 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"
}
}
}
}
}
```bash
nanobot extensions install https://github.com/acme/nanobot-review.git \
--ref v1.2.0
```
Config entries do not rewrite the installation registry. See
[Configuration](./configuration.md#extensions) for exact fields. Actions from
the Extensions WebUI or CLI reload the extension host. Direct edits to advanced
`extensions` config fields are applied on the next process start.
The WebUI **Extensions** page exposes the same Git and local installation
flows. Local paths are accepted only from a browser running on the nanobot
host.
## 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
nanobot extensions list
nanobot extensions inspect <extension-id>
```
Common causes:
| 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
Disable, untrust, or remove a package at any time:
```bash
nanobot extensions disable <extension-id>
nanobot extensions uninstall <extension-id>
nanobot extensions disable acme.review
nanobot extensions untrust acme.review
nanobot extensions uninstall acme.review
```
Uninstall removes the user-scope package and its local policy record. It does
not remove a built-in capability or a separately configured workspace copy.
Changes made in the WebUI reload its gateway extension host immediately.
Changes made by the standalone CLI take effect the next time the gateway or
agent process starts. Failed registrations are rolled back and reported as
diagnostics.
## Safety model
Extensions are executable Python code. nanobot provides these controls:
- packages are copied into `~/.nanobot/extensions/` with an integrity digest;
- package symlinks and special files are rejected;
- installation, permission grants, trust, and activation are separate steps;
- changed package contents invalidate trust;
- registration is transactional, so a failed extension does not leave tools,
commands, or hooks behind;
- remote WebUI clients cannot grant trust or permissions.
Permission declarations are consent gates, not an operating-system sandbox.
Only install code you are willing to run with the same account as nanobot.
## Package compatibility
The core runtime intentionally executes only the native nanobot Python
contract. Pi and OpenClaw packages are not loaded directly. Compatibility
adapters can be distributed as separate nanobot extensions later without
adding JavaScript runtimes or package-market policy to the agent core.
See [Extension Authoring](./extension-authoring.md) to build a package.

View File

@ -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.
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
private, trusted network.

View File

@ -86,21 +86,9 @@ class ToolLoader:
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
registered: list[str] = []
builtin_names: set[str] = set()
sources = [
(
(("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,
),
]
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
for source, is_plugin_source in sources:
for owner, tool_cls in source:
for tool_cls in source:
cls_label = tool_cls.__name__
try:
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, cls_label,
)
registry.register(tool, owner=owner)
registry.register(tool)
registered.append(tool.name)
if not is_plugin_source:
builtin_names.add(tool.name)

View File

@ -1349,7 +1349,7 @@ def serve(
from nanobot.api.server import create_app
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.session.manager import SessionManager
@ -1652,7 +1652,8 @@ def _run_gateway(
from nanobot.cron.service import CronJobSkippedError, CronService
from nanobot.cron.session_turns import is_bound_cron_job
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 (
build_provider_snapshot,
build_unconfigured_provider_snapshot,
@ -2164,7 +2165,6 @@ def _run_gateway(
console.print,
)
try:
await agent._connect_mcp()
await extension_host.reload()
await cron.start()
# Re-read once on first admission to close the watcher subscription window.
@ -2285,7 +2285,7 @@ def agent(
"""Interact with the agent directly."""
from nanobot.bus.queue import MessageBus
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
config = _load_runtime_config(config, workspace)
@ -2356,7 +2356,6 @@ def agent(
# Single message mode — direct call, no bus needed
async def run_once():
try:
await agent_loop._connect_mcp()
await extension_host.reload()
renderer = StreamRenderer(
render_markdown=markdown,
@ -2418,7 +2417,6 @@ def agent(
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive():
await agent_loop._connect_mcp()
await extension_host.reload()
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()

View File

@ -10,7 +10,7 @@ import typer
from rich.console import Console
from rich.table import Table
from nanobot.extensions import ExtensionService
from nanobot.extensions.service import ExtensionService
ServiceFactory = Callable[[], ExtensionService]
@ -21,7 +21,7 @@ def create_extensions_app(
service_factory: ServiceFactory = ExtensionService,
) -> typer.Typer:
"""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:
return service_factory()
@ -39,7 +39,6 @@ def create_extensions_app(
payload = run(service().status())
table = Table(show_header=True, header_style="bold")
table.add_column("Extension")
table.add_column("Runtime")
table.add_column("State")
table.add_column("Trust")
table.add_column("Version")
@ -47,7 +46,6 @@ def create_extensions_app(
state = "active" if item["active"] else ("enabled" if item["enabled"] else "disabled")
table.add_row(
item["name"],
item["runtime"],
state,
"trusted" if item["trusted"] else "untrusted",
item["version"],
@ -71,12 +69,10 @@ def create_extensions_app(
raise typer.Exit(1)
console.print(f"[bold]{item['name']}[/bold] [dim]{item['version']}[/dim]")
console.print(item["description"] or "[dim]No description.[/dim]")
console.print(f"Runtime: {item['runtime']} Scope: {item['scope']}")
console.print(
f"State: {'active' if item['active'] else 'inactive'} "
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_permissions(console, item["permissions"], set(item["granted_permissions"]))
diagnostics = [
@ -91,39 +87,10 @@ def create_extensions_app(
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")
def install_extension(
source: str = typer.Argument(..., help="npm spec, Git URL, or local path"),
kind: str = typer.Option("npm", "--kind", help="npm, git, or local"),
source: str = typer.Argument(..., help="Git URL or local package path"),
kind: str = typer.Option("git", "--kind", help="git or local"),
ref: str = typer.Option("", "--ref", help="Git branch, tag, or commit"),
) -> None:
"""Install an extension without granting trust or permissions."""

View File

@ -97,13 +97,6 @@ class CommandRouter:
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
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:
"""Return the extension that owns one command registration."""
return self._owners.get((tier, command))

View File

@ -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)
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):
"""Discovery and trust policy for first-class extensions."""
"""Global switch for external extension activation."""
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):

View File

@ -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 (
EXTENSION_API_VERSION,
ContributionKind,
DependencyKind,
ExtensionContribution,
ExtensionDependency,
ExtensionManifest,
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__ = [
"EXTENSION_API_VERSION",
"ContributionKind",
"DependencyKind",
"ExtensionCandidate",
"ExtensionCatalog",
"ExtensionHost",
"ExtensionHostSnapshot",
"ExtensionMarketplace",
"ExtensionContribution",
"ExtensionDependency",
"ExtensionDiagnostic",
"ExtensionManifest",
"ExtensionPermission",
"ExtensionPolicy",
"ExtensionRegistry",
"ExtensionRuntime",
"ExtensionRuntimeManager",
"ExtensionScope",
"ExtensionSnapshot",
"ExtensionSourceKind",
"ExtensionStore",
"ExtensionService",
"MANIFEST_FILENAME",
"ManifestFormatError",
"NODE_PROTOCOL_VERSION",
"NodeLoadResult",
"NodeProtocolError",
"NodeRegistration",
"NodeSidecar",
"ActivatedExtension",
"AdaptedPackage",
"ActivationResult",
"PythonExtensionApi",
"InstalledExtension",
"InstallResult",
"MarketplacePackage",
"ResolvedContribution",
"build_extension_catalog",
"adapt_package",
"dump_manifest",
"discover_native_extensions",
"load_manifest",
"manifest_from_mapping",
"manifest_to_mapping",
]

View File

@ -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 dataclasses import dataclass, replace
from dataclasses import dataclass
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.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
ExtensionPolicy,
ExtensionRegistry,
ExtensionScope,
ExtensionSnapshot,
)
from nanobot.extensions.store import ExtensionStore
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
@ -41,43 +31,17 @@ class ExtensionCatalog:
def build_extension_catalog(
config: Config,
*,
skills: SkillsLoader | None = None,
tools: ToolRegistry | None = None,
commands: CommandRouter | None = None,
user_root: Path | None = None,
) -> ExtensionCatalog:
"""Build the authoritative extension view without executing plugin code."""
native = discover_native_extensions(
config,
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)
)
"""Build the authoritative extension view without executing package code."""
if not config.extensions.enabled:
return ExtensionCatalog((), ExtensionSnapshot((), ()), ())
candidates = tuple(
_apply_entry_config(config, candidate)
for result in discoveries
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),
)
discovery = ExtensionStore(user_root).discover()
candidates, dependency_diagnostics = evaluate_dependencies(
discovery.candidates
)
registry = ExtensionRegistry()
registry_diagnostics: list[ExtensionDiagnostic] = []
for candidate in candidates:
try:
@ -92,47 +56,9 @@ def build_extension_catalog(
)
snapshot = registry.snapshot()
diagnostics = (
discovery_diagnostics
discovery.diagnostics
+ dependency_diagnostics
+ tuple(registry_diagnostics)
+ 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)
),
)

View File

@ -1,46 +1,17 @@
"""Strict JSON codec for portable extension manifests."""
"""JSON persistence for extension manifests."""
from __future__ import annotations
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from nanobot.extensions.manifest import (
EXTENSION_API_VERSION,
ContributionKind,
DependencyKind,
ExtensionContribution,
ExtensionDependency,
ExtensionManifest,
ExtensionPermission,
ExtensionRuntime,
)
from pydantic import ValidationError
from nanobot.extensions.manifest import ExtensionManifest
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):
"""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:
"""Write one canonical JSON manifest."""
payload = json.dumps(
manifest_to_mapping(manifest),
ensure_ascii=False,
indent=2,
path.write_text(
json.dumps(manifest_to_mapping(manifest), ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
path.write_text(payload + "\n", encoding="utf-8")
def manifest_from_mapping(data: object) -> ExtensionManifest:
"""Decode a mapping while rejecting misspelled or ambiguous fields."""
mapping = _mapping(data, "extension manifest")
_reject_unknown(mapping, _MANIFEST_KEYS, "extension manifest")
"""Decode a manifest and reject unknown or invalid fields."""
try:
contributions = tuple(
_contribution_from_mapping(item)
for item in _sequence(mapping.get("contributions", ()), "contributions")
return ExtensionManifest.model_validate(data)
except ValidationError as exc:
unknown = sorted(
".".join(str(part) for part in error["loc"])
for error in exc.errors()
if error["type"] == "extra_forbidden"
)
dependencies = tuple(
_dependency_from_mapping(item)
for item in _sequence(mapping.get("dependencies", ()), "dependencies")
)
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:
if unknown:
raise ManifestFormatError(
f"extension manifest has unknown fields: {', '.join(unknown)}"
) from exc
raise ManifestFormatError(f"invalid extension manifest: {exc}") from exc
def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
"""Return the stable wire representation used by sidecars and catalogs."""
return {
"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)}")
"""Return the canonical JSON representation."""
return manifest.model_dump(mode="json", by_alias=True)

View File

@ -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)

View File

@ -9,7 +9,6 @@ from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
ExtensionScope,
)
@ -21,10 +20,6 @@ class ExtensionDiscoveryResult:
def discover_manifest_root(
root: Path,
*,
scope: ExtensionScope,
trusted: bool = False,
enabled_ids: frozenset[str] = frozenset(),
) -> ExtensionDiscoveryResult:
"""Discover direct children containing ``nanobot.extension.json``."""
if not root.exists():
@ -62,10 +57,7 @@ def discover_manifest_root(
candidates.append(
ExtensionCandidate(
manifest=manifest,
scope=scope,
location=path.parent.resolve(),
enabled=not enabled_ids or manifest.id in enabled_ids,
trusted=trusted,
)
)
except Exception as exc:

View File

@ -59,19 +59,11 @@ class ExtensionHost:
config = self._config_loader()
catalog = build_extension_catalog(
config,
skills=getattr(
getattr(self._agent, "context", None),
"skills",
None,
),
tools=self._agent.tools,
commands=self._agent.commands,
user_root=self._user_root,
)
manager = ExtensionRuntimeManager(
tools=self._agent.tools,
commands=self._agent.commands,
config=config,
hook_factories=self._agent._hook_factories,
)
activation = await manager.activate(catalog.snapshot)

View File

@ -1,11 +1,13 @@
"""Dependency-free metadata shared by every nanobot extension format."""
"""Strict schema for native nanobot extension packages."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Literal, Self
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
EXTENSION_API_VERSION = 1
@ -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]+)*")
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):
"""Kinds of prerequisites resolved before activation."""
PYTHON = "python"
NPM = "npm"
EXECUTABLE = "executable"
ENVIRONMENT = "environment"
EXTENSION = "extension"
@dataclass(frozen=True, slots=True)
class ExtensionDependency:
class _ManifestModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
class ExtensionDependency(_ManifestModel):
"""One activation prerequisite declared by an extension."""
kind: DependencyKind
@ -57,162 +35,92 @@ class ExtensionDependency:
specifier: str = ""
optional: bool = False
def __post_init__(self) -> None:
if not isinstance(self.kind, DependencyKind):
raise TypeError("extension dependency kind must be a DependencyKind")
_require_text(self.name, "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")
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
return _require_text(value, "extension dependency name")
@dataclass(frozen=True, slots=True)
class ExtensionPermission:
class ExtensionPermission(_ManifestModel):
"""A privileged host capability requested by an extension."""
name: str
reason: str = ""
def __post_init__(self) -> None:
if not isinstance(self.name, str) or _PERMISSION.fullmatch(self.name) is None:
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if _PERMISSION.fullmatch(value) is None:
raise ValueError(
"extension permission must be a lowercase namespaced identifier"
)
if not isinstance(self.reason, str):
raise TypeError("extension permission reason must be a string")
return value
@dataclass(frozen=True, slots=True)
class ExtensionContribution:
"""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."""
class ExtensionManifest(_ManifestModel):
"""Identity, prerequisites, and consent declarations for one extension."""
id: str
name: str
version: str
runtime: ExtensionRuntime
entry: str = ""
entries: tuple[str, ...] = ()
contributions: tuple[ExtensionContribution, ...] = ()
entry: str = "extension:register"
description: str = ""
dependencies: tuple[ExtensionDependency, ...] = ()
permissions: tuple[ExtensionPermission, ...] = ()
api_version: int = EXTENSION_API_VERSION
api_version: Literal[EXTENSION_API_VERSION] = Field(
default=EXTENSION_API_VERSION,
alias="apiVersion",
)
homepage: str = ""
license: str = ""
def __post_init__(self) -> None:
_require_identifier(self.id, "extension id")
_require_text(self.name, "extension name")
_require_text(self.version, "extension version")
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")
@field_validator("id")
@classmethod
def validate_id(cls, value: str) -> str:
return _require_identifier(value, "extension id")
contribution_keys = [
(contribution.kind, contribution.name)
for contribution in self.contributions
]
if len(set(contribution_keys)) != len(contribution_keys):
raise ValueError("extension manifest contains duplicate contributions")
dependency_keys = [
(dependency.kind, dependency.name) for dependency in self.dependencies
]
if len(set(dependency_keys)) != len(dependency_keys):
@field_validator("name", "version")
@classmethod
def validate_required_text(cls, value: str, info) -> str:
return _require_text(value, f"extension {info.field_name}")
@field_validator("entry")
@classmethod
def validate_entry(cls, value: str) -> str:
value = _require_text(value, "extension entry")
module_name = value.partition(":")[0]
if Path(module_name).is_absolute() or ".." in Path(module_name).parts:
raise ValueError("extension entry cannot escape the package root")
return value
@model_validator(mode="after")
def reject_duplicates(self) -> Self:
dependencies = [(item.kind, item.name) for item in self.dependencies]
if len(set(dependencies)) != len(dependencies):
raise ValueError("extension manifest contains duplicate dependencies")
permission_names = [permission.name for permission in self.permissions]
if len(set(permission_names)) != len(permission_names):
permissions = [item.name for item in self.permissions]
if len(set(permissions)) != len(permissions):
raise ValueError("extension manifest contains duplicate permissions")
@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 ())
return self
def _require_text(value: object, label: str) -> str:
if not isinstance(value, str) or not value.strip():
def _require_text(value: str, label: str) -> str:
if not value.strip():
raise ValueError(f"{label} must be a non-empty string")
return value
def _require_identifier(value: object, label: str) -> str:
text = _require_text(value, label)
if _IDENTIFIER.fullmatch(text) is None:
def _require_identifier(value: str, label: str) -> str:
value = _require_text(value, label)
if _IDENTIFIER.fullmatch(value) is None:
raise ValueError(
f"{label} must use lowercase letters, digits, dots, underscores, or hyphens"
)
return text
return value
def validate_extension_id(value: object) -> str:
"""Validate and return one portable extension identifier."""
if not isinstance(value, str):
raise ValueError("extension id must be a string")
return _require_identifier(value, "extension id")
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__}")

View File

@ -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 ""),
)

View File

@ -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"

View File

@ -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()

View File

@ -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`,
);
}
});
});

View File

@ -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)

View File

@ -1,13 +1,11 @@
"""Activation preflight for extension dependencies."""
"""Activation preflight for extension runtime prerequisites."""
from __future__ import annotations
import importlib.metadata
import json
import os
import shutil
from dataclasses import replace
from pathlib import Path
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
@ -17,7 +15,7 @@ from nanobot.extensions.versioning import dependency_version_failure
def evaluate_dependencies(
candidates: tuple[ExtensionCandidate, ...],
) -> 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] = []
diagnostics: list[ExtensionDiagnostic] = []
for candidate in candidates:
@ -25,12 +23,7 @@ def evaluate_dependencies(
message
for dependency in candidate.manifest.dependencies
if not dependency.optional
if (
message := _dependency_failure(
dependency,
location=candidate.location,
)
)
if (message := _dependency_failure(dependency))
]
if failures:
candidate = replace(candidate, enabled=False)
@ -48,8 +41,6 @@ def evaluate_dependencies(
def _dependency_failure(
dependency: ExtensionDependency,
*,
location: Path | None,
) -> str:
if dependency.kind is DependencyKind.EXECUTABLE:
if shutil.which(dependency.name) is None:
@ -65,28 +56,4 @@ def _dependency_failure(
except importlib.metadata.PackageNotFoundError:
return f"Required Python package is not installed: {dependency.name}"
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}"
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

View File

@ -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),
)

View File

@ -1,102 +1,28 @@
"""Deterministic extension selection and contribution ownership."""
"""Deterministic extension activation planning."""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
from pathlib import Path
from nanobot.extensions.manifest import (
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
from nanobot.extensions.manifest import ExtensionManifest
@dataclass(frozen=True, slots=True)
class ExtensionCandidate:
"""One discovered installation of an extension manifest."""
"""One discovered extension package and its activation state."""
manifest: ExtensionManifest
scope: ExtensionScope
location: Path | None = None
enabled: bool = True
trusted: bool = False
integrity_valid: bool = True
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)
class ExtensionDiagnostic:
"""A non-fatal discovery or ownership problem."""
"""A non-fatal discovery or activation problem."""
code: str
extension_id: str
@ -106,243 +32,51 @@ class ExtensionDiagnostic:
@dataclass(frozen=True, slots=True)
class ExtensionSnapshot:
"""Immutable result consumed by runtime adapters and management surfaces."""
"""Immutable activation plan consumed by the runtime."""
extensions: tuple[ExtensionCandidate, ...]
contributions: tuple[ResolvedContribution, ...]
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:
"""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:
self._policy = policy or ExtensionPolicy()
self._candidates: dict[
tuple[str, ExtensionScope],
ExtensionCandidate,
] = {}
def __init__(self) -> None:
self._candidates: dict[str, ExtensionCandidate] = {}
def register(self, candidate: ExtensionCandidate) -> None:
key = (candidate.manifest.id, candidate.scope)
existing = self._candidates.get(key)
if existing is not None:
raise ValueError(
f"extension '{candidate.manifest.id}' is already registered in "
f"{candidate.scope.name.lower()} scope"
)
self._candidates[key] = candidate
extension_id = candidate.manifest.id
if extension_id in self._candidates:
raise ValueError(f"extension '{extension_id}' is already installed")
self._candidates[extension_id] = candidate
def snapshot(self) -> ExtensionSnapshot:
active, selection_diagnostics = self._select_active_extensions()
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] = {}
active: list[ExtensionCandidate] = []
diagnostics: list[ExtensionDiagnostic] = []
for candidate in sorted(
self._candidates.values(),
key=lambda item: (item.scope, item.manifest.id),
key=lambda item: item.manifest.id,
):
if self._policy.permits(candidate):
active[candidate.manifest.id] = candidate
continue
requested = {
permission.name for permission in candidate.manifest.permissions
}
missing = sorted(requested - candidate.granted_permissions)
if (
candidate.enabled
and candidate.integrity_valid
and candidate.trusted
and candidate.scope is not ExtensionScope.BUILTIN
and not missing
):
requested = {
permission.name
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)
active.append(candidate)
elif candidate.enabled and candidate.trusted and missing:
diagnostics.append(
ExtensionDiagnostic(
code="dependency_cycle",
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",
code="permission_required",
extension_id=candidate.manifest.id,
message=(
f"{contribution.kind.value} '{contribution.name}' is already "
f"owned by extension '{existing_id}'; disable one owner "
"before activating the other"
"Grant required extension permissions: "
+ ", ".join(missing)
),
)
)
return resolved, diagnostics
return ExtensionSnapshot(tuple(active), tuple(diagnostics))

View File

@ -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
@ -6,17 +6,15 @@ import importlib
import shutil
import sys
from dataclasses import dataclass
from importlib.machinery import ModuleSpec
from pathlib import Path
from types import ModuleType
from typing import Any
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.command.router import CommandRouter, Handler
from nanobot.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 (
ExtensionCandidate,
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)
class ActivationResult:
"""Immutable activation outcome consumed by the agent assembly layer."""
extensions: tuple[ActivatedExtension, ...]
extensions: tuple[ExtensionCandidate, ...]
hook_factories: tuple[AgentTurnHookFactory, ...]
diagnostics: tuple[ExtensionDiagnostic, ...]
@ -95,52 +84,21 @@ class ExtensionRuntimeManager:
*,
tools: ToolRegistry,
commands: CommandRouter,
config: Config,
hook_factories: list[AgentTurnHookFactory] | None = None,
) -> None:
self._tools = tools
self._commands = commands
self._config = config
self._active: list[ActivatedExtension] = []
self._active: list[ExtensionCandidate] = []
self._hook_factories = hook_factories if hook_factories is not None else []
async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult:
diagnostics: list[ExtensionDiagnostic] = []
activated_ids = {
candidate.manifest.id
for candidate in snapshot.extensions
if candidate.location is None
}
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:
active = await self._activate_candidate(candidate)
if active is not None:
self._active.append(active)
activated_ids.add(candidate.manifest.id)
diagnostics.extend(active.diagnostics)
active = self._activate_candidate(candidate)
self._active.append(active)
except Exception as exc:
await self._rollback_owner(candidate.manifest.id)
self._rollback_owner(candidate.manifest.id)
diagnostics.append(
ExtensionDiagnostic(
code="activation_failed",
@ -156,83 +114,34 @@ class ExtensionRuntimeManager:
async def close(self) -> None:
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()
async def _activate_candidate(
def _activate_candidate(
self,
candidate: ExtensionCandidate,
) -> ActivatedExtension | None:
runtime = candidate.manifest.runtime.value
if runtime == "declarative":
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
) -> ExtensionCandidate:
self._activate_python(candidate)
return candidate
def _activate_python(self, candidate: ExtensionCandidate) -> None:
if len(candidate.manifest.activation_entries) != 1:
raise ValueError("Python extensions must declare exactly one entry")
raw_entry = candidate.manifest.activation_entries[0]
raw_entry = candidate.manifest.entry
module_name, separator, attribute = raw_entry.partition(":")
if not separator:
module_name = raw_entry
attribute = "register"
assert candidate.location is not None
importlib.invalidate_caches()
_unload_modules_under(candidate.location)
_reject_module_collision(module_name, candidate.location)
sys.path.insert(0, str(candidate.location))
module_prefix = _module_prefix(candidate.manifest.id)
_unload_extension_modules(candidate)
package = ModuleType(module_prefix)
package.__package__ = module_prefix
package.__path__ = [str(candidate.location)]
package.__spec__ = ModuleSpec(module_prefix, loader=None, is_package=True)
sys.modules[module_prefix] = package
try:
module = importlib.import_module(module_name)
module = importlib.import_module(f"{module_prefix}.{module_name}")
module_path = getattr(module, "__file__", None)
if not module_path or not Path(module_path).resolve().is_relative_to(
candidate.location.resolve()
@ -251,31 +160,12 @@ class ExtensionRuntimeManager:
if result is not None:
raise TypeError("Python extension register function must return None")
except Exception:
_unload_modules_under(candidate.location)
_unload_extension_modules(candidate)
raise
finally:
sys.path.remove(str(candidate.location))
def _register_compatible(
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(
def _rollback_owner(
self,
owner: str,
active: ActivatedExtension | None = None,
) -> None:
self._tools.unregister_owner(owner)
self._commands.unregister_owner(owner)
@ -284,43 +174,6 @@ class ExtensionRuntimeManager:
for factory in self._hook_factories
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(
@ -352,17 +205,14 @@ def _unload_modules_under(root: Path) -> None:
shutil.rmtree(cache, ignore_errors=True)
def _reject_module_collision(module_name: str, root: Path) -> None:
package_root = root.resolve()
parts = module_name.split(".")
for index in range(1, len(parts) + 1):
loaded = sys.modules.get(".".join(parts[:index]))
loaded_path = getattr(loaded, "__file__", None)
if loaded is not None and (
not loaded_path
or not Path(loaded_path).resolve().is_relative_to(package_root)
):
raise ValueError(
f"Python extension entry conflicts with loaded module: "
f"{'.'.join(parts[:index])}"
)
def _module_prefix(extension_id: str) -> str:
return "_nanobot_extension_" + extension_id.encode().hex()
def _unload_extension_modules(candidate: ExtensionCandidate) -> None:
assert candidate.location is not None
prefix = _module_prefix(candidate.manifest.id)
for name in tuple(sys.modules):
if name == prefix or name.startswith(f"{prefix}."):
sys.modules.pop(name, None)
_unload_modules_under(candidate.location)

View File

@ -8,9 +8,9 @@ from pathlib import Path
from typing import Any
from nanobot.extensions.host import ExtensionHost
from nanobot.extensions.market import ExtensionMarketplace
from nanobot.extensions.registry import ExtensionCandidate, ExtensionScope
from nanobot.extensions.store import ExtensionStore
from nanobot.extensions.manifest import ExtensionManifest
from nanobot.extensions.registry import ExtensionCandidate
from nanobot.extensions.store import ExtensionStore, InstalledExtension
class ExtensionService:
@ -21,15 +21,14 @@ class ExtensionService:
*,
host: ExtensionHost | None = None,
store: ExtensionStore | None = None,
marketplace: ExtensionMarketplace | None = None,
) -> None:
self.host = host
self.store = store or ExtensionStore()
self.marketplace = marketplace or ExtensionMarketplace()
self._mutation_lock = asyncio.Lock()
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:
discovery = self.store.discover()
candidates = discovery.candidates
@ -37,61 +36,33 @@ class ExtensionService:
active_ids: set[str] = set()
else:
candidates = catalog.candidates
diagnostics = catalog.diagnostics
active_ids = {
active.candidate.manifest.id
for active in self.host.snapshot.activation.extensions
active.manifest.id
for active in snapshot.activation.extensions
}
active_ids.update(
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
diagnostics = catalog.diagnostics + snapshot.activation.diagnostics
records = self.store.records()
return {
"extensions": [
_candidate_payload(candidate, active_ids, records.get(candidate.manifest.id))
for candidate in sorted(
candidates,
key=lambda item: (item.scope, item.manifest.name.lower()),
key=lambda item: item.manifest.name.lower(),
)
],
"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(
self,
source: str,
*,
kind: str = "npm",
kind: str = "git",
ref: str = "",
trusted: bool = False,
) -> dict[str, Any]:
async with self._mutation_lock:
if kind == "npm":
result = await asyncio.to_thread(
self.store.install_npm,
source,
trusted=trusted,
)
elif kind == "git":
if kind == "git":
result = await asyncio.to_thread(
self.store.install_git,
source,
@ -109,8 +80,7 @@ class ExtensionService:
await self._reload()
return {
"record": _record_payload(result.record),
"manifest": _manifest_payload(result.package.manifest),
"diagnostics": list(result.package.diagnostics),
"manifest": _manifest_payload(result.manifest),
}
async def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]:
@ -150,46 +120,33 @@ class ExtensionService:
def _candidate_payload(
candidate: ExtensionCandidate,
active_ids: set[str],
record: Any | None,
record: InstalledExtension | None,
) -> dict[str, Any]:
manifest = candidate.manifest
requested = [permission.name for permission in manifest.permissions]
return {
**_manifest_payload(manifest),
"scope": candidate.scope.name.lower(),
"location": str(candidate.location) if candidate.location else None,
"enabled": candidate.enabled,
"trusted": candidate.trusted,
"active": manifest.id in active_ids,
"requested_permissions": requested,
"granted_permissions": sorted(candidate.granted_permissions),
"source": record.source.value if record else (
"builtin" if candidate.scope is ExtensionScope.BUILTIN else "path"
),
"source": record.source.value if record else "path",
"source_ref": record.source_ref if record else "",
"integrity": record.integrity if record else "",
"installed_at": record.installed_at if record else "",
"managed_by_store": record is not None,
}
def _manifest_payload(manifest: Any) -> dict[str, Any]:
def _manifest_payload(manifest: ExtensionManifest) -> dict[str, Any]:
return {
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"runtime": manifest.runtime.value,
"description": manifest.description,
"homepage": manifest.homepage,
"license": manifest.license,
"contributions": [
{
"kind": contribution.kind.value,
"name": contribution.name,
"description": contribution.description,
}
for contribution in manifest.contributions
],
"dependencies": [
{
"kind": dependency.kind.value,
@ -206,8 +163,5 @@ def _manifest_payload(manifest: Any) -> dict[str, Any]:
}
def _record_payload(record: Any) -> dict[str, Any]:
return {
**asdict(record),
"source": record.source.value,
}
def _record_payload(record: InstalledExtension) -> dict[str, Any]:
return record.model_dump(mode="json")

View File

@ -8,9 +8,8 @@ import os
import re
import shutil
import subprocess
import tarfile
import tempfile
from dataclasses import asdict, dataclass, replace
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from enum import Enum
from pathlib import Path
@ -19,49 +18,34 @@ from urllib.parse import urlparse
from uuid import uuid4
from filelock import FileLock
from pydantic import BaseModel, ConfigDict, field_validator
from nanobot.extensions.codec import MANIFEST_FILENAME, dump_manifest, load_manifest
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
from nanobot.extensions.discovery import (
ExtensionDiscoveryResult,
discover_manifest_root,
)
from nanobot.extensions.manifest import (
DependencyKind,
ExtensionManifest,
validate_extension_id,
)
from nanobot.extensions.package_adapter import AdaptedPackage, adapt_package
from nanobot.extensions.registry import ExtensionDiagnostic, ExtensionScope
from nanobot.extensions.manifest import ExtensionManifest, validate_extension_id
from nanobot.extensions.registry import ExtensionDiagnostic
_REGISTRY_FILENAME = ".registry.json"
_GIT_SCHEMES = frozenset({"git", "http", "https", "ssh"})
_SCP_GIT_URL = re.compile(
r"(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?:\S+"
)
_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}")
_MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
_MAX_EXTRACTED_BYTES = 512 * 1024 * 1024
_MAX_ARCHIVE_MEMBERS = 50_000
class ExtensionSourceKind(str, Enum):
LOCAL = "local"
GIT = "git"
NPM = "npm"
@dataclass(frozen=True, slots=True)
class InstalledExtension:
class InstalledExtension(BaseModel):
"""Persistent installation and policy record."""
model_config = ConfigDict(extra="forbid", frozen=True)
id: str
version: str
source: ExtensionSourceKind
@ -72,65 +56,39 @@ class InstalledExtension:
trusted: bool = False
granted_permissions: tuple[str, ...] = ()
@field_validator("id")
@classmethod
def from_mapping(cls, value: object) -> InstalledExtension:
if not isinstance(value, dict):
raise ValueError("extension registry record must be an object")
required = {
"id",
"version",
"source",
"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()):
def validate_id(cls, value: str) -> str:
return validate_extension_id(value)
@field_validator("version", "source_ref", "installed_at")
@classmethod
def validate_metadata(cls, value: str) -> str:
if not value:
raise ValueError("extension registry metadata must use non-empty strings")
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")
source = value["source"]
if not isinstance(source, str):
raise ValueError("extension registry source must be a string")
enabled = value.get("enabled", True)
trusted = value.get("trusted", False)
if not isinstance(enabled, bool) or not isinstance(trusted, bool):
raise ValueError("extension enabled and trusted values must be booleans")
return cls(
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),
)
return value
@field_validator("granted_permissions")
@classmethod
def reject_duplicate_permissions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value):
raise ValueError("extension granted permissions cannot contain duplicates")
return value
@dataclass(frozen=True, slots=True)
class InstallResult:
"""Installed package plus metadata-adapter notices."""
"""Installed package metadata."""
record: InstalledExtension
package: AdaptedPackage
manifest: ExtensionManifest
class ExtensionStore:
@ -154,7 +112,7 @@ class ExtensionStore:
raise ValueError("extension registry extensions must be an array")
records: dict[str, InstalledExtension] = {}
for item in rows:
record = InstalledExtension.from_mapping(item)
record = InstalledExtension.model_validate(item)
if record.id in records:
raise ValueError(
f"extension registry contains duplicate id: {record.id}"
@ -176,7 +134,7 @@ class ExtensionStore:
def discover(self) -> ExtensionDiscoveryResult:
"""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)
try:
records = self.records(strict=True)
@ -191,15 +149,12 @@ class ExtensionStore:
)
candidates = []
for candidate in result.candidates:
record = records.get(candidate.manifest.id, _DEFAULT_RECORD)
trusted = record.trusted
record = records.get(candidate.manifest.id)
trusted = record.trusted if record else False
integrity_valid = True
if candidate.location is not None and record is not _DEFAULT_RECORD:
if candidate.location is not None and record is not None:
try:
_reject_unsafe_files(
candidate.location,
allow_installed_node_links=True,
)
_reject_unsafe_files(candidate.location)
actual_integrity = _tree_hash(candidate.location)
except (OSError, ValueError) as exc:
actual_integrity = ""
@ -226,10 +181,12 @@ class ExtensionStore:
candidates.append(
replace(
candidate,
enabled=record.enabled,
enabled=record.enabled if record else True,
trusted=trusted,
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))
@ -301,48 +258,6 @@ class ExtensionStore:
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:
return self._update_record(extension_id, enabled=enabled)
@ -420,9 +335,11 @@ class ExtensionStore:
) -> InstallResult:
if not source.is_dir():
raise ValueError(f"extension source is not a directory: {source}")
if self.root.resolve().is_relative_to(source.resolve()):
raise ValueError("extension source cannot contain the extension store")
_reject_unsafe_files(source)
package = adapt_package(source)
extension_id = package.manifest.id
manifest = load_manifest(source / MANIFEST_FILENAME)
extension_id = manifest.id
self.root.mkdir(parents=True, exist_ok=True)
staging = self.root / f".install-{uuid4().hex}"
target = self.root / extension_id
@ -437,10 +354,7 @@ class ExtensionStore:
staging,
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
)
if package.generated:
dump_manifest(package.manifest, staging / MANIFEST_FILENAME)
_install_node_dependencies(staging, package.manifest)
_reject_unsafe_files(staging, allow_installed_node_links=True)
_reject_unsafe_files(staging)
integrity = _tree_hash(staging)
if target.exists():
target.rename(backup)
@ -448,12 +362,12 @@ class ExtensionStore:
staging.rename(target)
target_installed = True
requested_permissions = {
permission.name for permission in package.manifest.permissions
permission.name for permission in manifest.permissions
}
unchanged = bool(previous and previous.integrity == integrity)
record = InstalledExtension(
id=extension_id,
version=package.manifest.version,
version=manifest.version,
source=source_kind,
source_ref=source_ref,
integrity=integrity,
@ -473,7 +387,7 @@ class ExtensionStore:
records[extension_id] = record
self._write_records(records)
shutil.rmtree(backup, ignore_errors=True)
return InstallResult(record, package)
return InstallResult(record, manifest)
except Exception:
shutil.rmtree(staging, ignore_errors=True)
if target_installed:
@ -498,7 +412,7 @@ class ExtensionStore:
**changes: Any,
) -> InstalledExtension:
try:
record = replace(records[extension_id], **changes)
record = records[extension_id].model_copy(update=changes)
except KeyError as exc:
raise KeyError(
f"extension '{extension_id}' is not installed"
@ -512,10 +426,7 @@ class ExtensionStore:
payload = {
"version": 1,
"extensions": [
{
**asdict(record),
"source": record.source.value,
}
record.model_dump(mode="json")
for record in sorted(records.values(), key=lambda item: item.id)
],
}
@ -524,71 +435,6 @@ class ExtensionStore:
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:
try:
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
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:
if not isinstance(url, str) or not url.strip() or any(
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")
def _validate_npm_package_spec(spec: str) -> 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()
def _reject_unsafe_files(root: Path) -> None:
for path in root.rglob("*"):
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}")
if not path.is_file() and not path.is_dir():
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:
digest = hashlib.sha256()
for path in sorted(
@ -715,25 +509,3 @@ def _tree_hash(root: Path) -> str:
while chunk := handle.read(1024 * 1024):
digest.update(chunk)
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)

View File

@ -11,7 +11,7 @@ from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
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.sdk.clients import MemoryClient, RuntimeClient, SessionClient
from nanobot.sdk.runtime import (
@ -334,7 +334,6 @@ class Nanobot:
return
async with self._extensions_lock:
if not self._extensions_started:
await self._loop._connect_mcp()
await self._extensions.reload()
self._extensions_started = True

View File

@ -34,7 +34,6 @@ class WebUIExtensionsRouter:
*,
service: ExtensionService | None,
check_api_token: Callable[[WsRequest], bool],
parse_query: Callable[[str], dict[str, list[str]]],
json_response: Callable[[dict[str, Any]], Response],
error_response: Callable[[int, str | None], Response],
allow_remote_package_install: bool = False,
@ -42,7 +41,6 @@ class WebUIExtensionsRouter:
) -> None:
self._service = service
self._check_api_token = check_api_token
self._parse_query = parse_query
self._json_response = json_response
self._error_response = error_response
self._allow_remote_package_install = allow_remote_package_install
@ -65,17 +63,6 @@ class WebUIExtensionsRouter:
if _method(request) != "GET":
return self._error_response(405, "Method not allowed")
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)
if action is None:
return None
@ -89,7 +76,7 @@ class WebUIExtensionsRouter:
values = self._values(request)
if (
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)
):
return self._error_response(
@ -116,7 +103,7 @@ class WebUIExtensionsRouter:
raise ValueError("Missing extension source")
return await self._service.install(
source,
kind=str(values.get("kind") or "npm"),
kind=str(values.get("kind") or "git"),
ref=str(values.get("ref") or ""),
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:
return str(getattr(request, "method", "GET")).upper()

View File

@ -212,7 +212,6 @@ class GatewayHTTPHandler:
self.extensions_routes = WebUIExtensionsRouter(
service=extension_service,
check_api_token=self.check_api_token,
parse_query=_parse_query,
json_response=_http_json_response,
error_response=_http_error,
allow_remote_package_install=allow_remote_package_install,

View File

@ -122,7 +122,6 @@ allow-direct-references = true
[tool.hatch.build]
include = [
"nanobot/**/*.py",
"nanobot/extensions/**/*.mjs",
"nanobot/templates/**/*.md",
"nanobot/skills/**/*.md",
"nanobot/skills/**/*.sh",

View File

@ -1461,11 +1461,10 @@ def mock_agent_runtime(tmp_path):
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
patch("nanobot.bus.queue.MessageBus"), \
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:
agent_loop = MagicMock()
agent_loop.channels_config = None
agent_loop._connect_mcp = AsyncMock(return_value=None)
agent_loop.process_direct = AsyncMock(
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):
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
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)
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):
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
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)
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):
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
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)
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):
return OutboundMessage(channel="cli", chat_id="direct", content="ok")
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
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
)
@ -1901,7 +1888,7 @@ def _patch_cli_command_runtime(
) -> None:
provider_factory = make_provider or (lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.extensions.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
monkeypatch.setattr(
"nanobot.config.loader.set_config_path",
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):
return SimpleNamespace(content="")
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
@ -2487,10 +2471,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
seen["workspace"] = kwargs["workspace"]
async def _connect_mcp(self) -> None:
return None
seen["mcp_connected"] = True
async def close_mcp(self) -> None:
return None
seen["mcp_closed"] = True
def _fake_create_app(
agent_loop,
@ -2661,9 +2645,6 @@ def test_gateway_unbound_agent_cron_is_skipped(
async def submit_cron_turn(self, _msg: InboundMessage):
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:
return None
@ -2782,9 +2763,6 @@ def test_gateway_bound_cron_runs_as_session_turn(
content="Checked the repo.",
)
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
@ -3005,9 +2983,6 @@ def test_gateway_local_trigger_queue_submits_agent_turns(
self.runtime_resolver.invalidate.assert_called_once_with()
await asyncio.Event().wait()
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
@ -3252,9 +3227,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
async def run(self) -> None:
await asyncio.Event().wait()
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
return None
@ -3452,9 +3424,6 @@ def test_gateway_shutdown_lets_agent_task_own_mcp_cleanup(
finally:
seen["agent_task_cleaned_up"] = True
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
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:
seen["agent_task_cleaned_up"] = True
async def _connect_mcp(self) -> None:
return None
async def close_mcp(self) -> None:
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"
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(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,

View File

@ -20,13 +20,10 @@ class _Service:
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"runtime": "pi",
"scope": "user",
"description": "Example extension",
"enabled": True,
"trusted": False,
"active": False,
"contributions": [],
"dependencies": [],
"permissions": [{"name": "network", "reason": "Fetch data"}],
"granted_permissions": [],
@ -35,10 +32,6 @@ class _Service:
"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):
self.calls.append(("install", (source, kind, ref, trusted)))
return {
@ -80,27 +73,21 @@ def _runner(service: _Service):
return CliRunner(), app, output
def test_extension_cli_inspects_and_searches() -> None:
def test_extension_cli_inspects() -> None:
service = _Service()
runner, app, output = _runner(service)
inspected = runner.invoke(app, ["inspect", "sample"])
searched = runner.invoke(app, ["search", "web", "--ecosystem", "pi", "--limit", "7"])
assert inspected.exit_code == 0
assert searched.exit_code == 0
assert "Sample" in output.getvalue()
assert service.calls == [
("status", None),
("search", ("web", "pi", 7)),
]
assert service.calls == [("status", None)]
def test_extension_cli_install_and_policy_commands() -> None:
service = _Service()
runner, app, _output = _runner(service)
assert runner.invoke(app, ["install", "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, ["disable", "sample"]).exit_code == 0
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 service.calls == [
("install", ("pi-example", "npm", "", False)),
("install", ("https://example.com/acme.git", "git", "", False)),
("trusted", ("sample", True)),
("enabled", ("sample", False)),
("permissions", ("sample", {"network", "filesystem.read"})),

View File

@ -1,29 +1,7 @@
import pytest
from pydantic import ValidationError
from nanobot.config.schema import Config, ExtensionsConfig
from nanobot.config.schema import Config
def test_extensions_config_accepts_camel_case_workspace_trust() -> None:
config = Config.model_validate(
{
"extensions": {
"workspaceTrust": "allow",
"entries": {
"acme": {
"enabled": True,
"trusted": True,
"config": {"endpoint": "https://example.com"},
}
},
}
}
)
def test_extensions_can_be_disabled_globally() -> None:
config = Config.model_validate({"extensions": {"enabled": False}})
assert config.extensions.workspace_trust == "allow"
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"])
assert config.extensions.enabled is False

View File

@ -1,8 +1,8 @@
import json
from unittest.mock import patch
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:
@ -14,60 +14,39 @@ def _write_extension(root, extension_id: str) -> None:
"id": extension_id,
"name": extension_id,
"version": "1.0.0",
"runtime": "python",
"contributions": [{"kind": "tool", "name": f"{extension_id}_tool"}],
"entry": "extension:register",
}
)
)
def _catalog(config: Config, user_root):
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:
def test_catalog_requires_trust_before_activation(tmp_path) -> None:
_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 catalog.snapshot.extensions == ()
def test_entry_config_trust_activates_installed_extension(tmp_path) -> None:
_write_extension(tmp_path, "acme")
config = Config.model_validate(
{
"extensions": {
"entries": {
"acme": {
"enabled": True,
"trusted": True,
}
}
}
}
)
def test_store_policy_can_trust_an_extension(tmp_path) -> None:
source = tmp_path / "source"
_write_extension(source, "acme")
store = ExtensionStore(tmp_path / "installed")
store.install_local(source / "acme")
store.set_trusted("acme", True)
catalog = _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 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")
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 == ()

View File

@ -2,11 +2,8 @@ import json
import pytest
from nanobot.extensions import (
ContributionKind,
ExtensionContribution,
ExtensionManifest,
ExtensionRuntime,
from nanobot.extensions import ExtensionManifest
from nanobot.extensions.codec import (
ManifestFormatError,
dump_manifest,
load_manifest,
@ -20,14 +17,7 @@ def test_manifest_json_round_trip(tmp_path) -> None:
id="acme.tools",
name="Acme Tools",
version="2.0.0",
runtime=ExtensionRuntime.PI,
contributions=(
ExtensionContribution(
kind=ContributionKind.TOOL,
name="acme_search",
target="./index.ts#search",
),
),
entry="acme_extension:register",
)
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
def test_manifest_rejects_unknown_fields() -> None:
def test_manifest_codec_rejects_unknown_fields() -> None:
with pytest.raises(ManifestFormatError, match="unknown fields: typo"):
manifest_from_mapping(
{
"id": "bad",
"name": "Bad",
"version": "1.0.0",
"runtime": "python",
"typo": True,
}
{"id": "bad", "name": "Bad", "version": "1.0.0", "typo": True}
)
def test_manifest_rejects_unknown_contribution_kind() -> None:
with pytest.raises(ManifestFormatError, match="invalid extension contribution"):
with pytest.raises(ManifestFormatError, match="unknown fields: contributions"):
manifest_from_mapping(
{
"id": "bad",
"name": "Bad",
"version": "1.0.0",
"runtime": "python",
"contributions": [{"kind": "mystery", "name": "unknown"}],
}
)

View File

@ -1,6 +1,5 @@
import json
from nanobot.extensions import ExtensionScope
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,
"name": extension_id,
"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, "two", "two")
result = discover_manifest_root(
tmp_path,
scope=ExtensionScope.WORKSPACE,
trusted=True,
)
result = discover_manifest_root(tmp_path)
assert [candidate.manifest.id for candidate in result.candidates] == ["one", "two"]
assert all(candidate.trusted for candidate in result.candidates)
assert result.diagnostics == ()
@ -40,23 +34,7 @@ def test_discovery_reports_bad_manifest_without_hiding_good_packages(tmp_path) -
bad.mkdir()
(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 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),
]

View File

@ -11,7 +11,6 @@ class _Agent:
def __init__(self) -> None:
self.tools = ToolRegistry()
self.commands = CommandRouter()
self.context = type("Context", (), {"skills": None})()
self._hook_factories = []
@ -44,7 +43,7 @@ async def test_host_reloads_and_closes_runtime(
await host.close()
assert host.snapshot is None
assert first.catalog.snapshot.extensions
assert second.catalog.snapshot.extensions
assert first.catalog.snapshot.extensions == ()
assert second.catalog.snapshot.extensions == ()
assert len(activated) == 2
assert len(closed) == 2

View File

@ -1,88 +1,53 @@
import pytest
from nanobot.extensions import (
ContributionKind,
DependencyKind,
ExtensionContribution,
ExtensionDependency,
ExtensionManifest,
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(
id="acme.research",
name="Acme Research",
version="1.2.0",
runtime=ExtensionRuntime.PYTHON,
contributions=(
ExtensionContribution(
kind=ContributionKind.TOOL,
name="research",
target="acme_nanobot:ResearchTool",
),
),
permissions=(
ExtensionPermission(
name="network",
reason="Fetch sources selected by the user.",
reason="Fetch user-selected sources.",
),
),
)
assert manifest.api_version == 1
assert manifest.contributions[0].name == "research"
assert manifest.entry == "extension:register"
@pytest.mark.parametrize("extension_id", ["Uppercase", "../escape", "two words", ""])
def test_manifest_rejects_invalid_ids(extension_id: str) -> None:
with pytest.raises(ValueError):
ExtensionManifest(
id=extension_id,
name="Invalid",
version="1.0.0",
runtime=ExtensionRuntime.PYTHON,
)
ExtensionManifest(id=extension_id, name="Invalid", version="1.0.0")
def test_manifest_rejects_duplicate_contributions() -> 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:
def test_manifest_rejects_duplicate_contract_rows() -> None:
dependency = ExtensionDependency(
kind=DependencyKind.EXTENSION,
name="acme.base",
kind=DependencyKind.PYTHON,
name="httpx",
)
permission = ExtensionPermission(name="network")
with pytest.raises(ValueError, match="duplicate dependencies"):
ExtensionManifest(
id="duplicate",
name="Duplicate",
version="1.0.0",
runtime=ExtensionRuntime.DECLARATIVE,
dependencies=(dependency, dependency),
)
def test_dependency_optional_must_be_boolean() -> None:
with pytest.raises(TypeError, match="optional must be a boolean"):
ExtensionDependency(
kind=DependencyKind.EXTENSION,
name="acme.base",
optional="false", # type: ignore[arg-type]
with pytest.raises(ValueError, match="duplicate permissions"):
ExtensionManifest(
id="duplicate",
name="Duplicate",
version="1.0.0",
permissions=(permission, permission),
)

View File

@ -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")

View File

@ -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"),
]

View File

@ -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()

View File

@ -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"

View File

@ -1,86 +1,53 @@
from pathlib import Path
from nanobot.extensions import (
DependencyKind,
ExtensionCandidate,
ExtensionDependency,
ExtensionManifest,
ExtensionRuntime,
ExtensionScope,
)
from nanobot.extensions import ExtensionDependency, ExtensionManifest
from nanobot.extensions.manifest import DependencyKind
from nanobot.extensions.preflight import evaluate_dependencies
from nanobot.extensions.registry import ExtensionCandidate
def _candidate(
dependency: ExtensionDependency,
*,
location: Path | None = None,
) -> ExtensionCandidate:
def _candidate(dependency: ExtensionDependency) -> ExtensionCandidate:
return ExtensionCandidate(
manifest=ExtensionManifest(
ExtensionManifest(
id="preflight.test",
name="Preflight test",
version="1.0.0",
runtime=ExtensionRuntime.DECLARATIVE,
dependencies=(dependency,),
),
scope=ExtensionScope.USER,
location=location,
trusted=True,
)
def test_missing_environment_dependency_disables_extension(
monkeypatch,
) -> None:
def test_missing_environment_dependency_disables_extension(monkeypatch) -> None:
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
candidate = _candidate(
ExtensionDependency(
kind=DependencyKind.ENVIRONMENT,
name="NANOBOT_EXTENSION_TEST_KEY",
candidates, diagnostics = evaluate_dependencies(
(
_candidate(
ExtensionDependency(
kind=DependencyKind.ENVIRONMENT,
name="NANOBOT_EXTENSION_TEST_KEY",
)
),
)
)
candidates, diagnostics = evaluate_dependencies((candidate,))
assert not candidates[0].enabled
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:
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=">=2026.7.0",
),
location=tmp_path,
def test_optional_dependency_does_not_disable_extension(monkeypatch) -> None:
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
candidates, diagnostics = evaluate_dependencies(
(
_candidate(
ExtensionDependency(
kind=DependencyKind.ENVIRONMENT,
name="NANOBOT_EXTENSION_TEST_KEY",
optional=True,
)
),
)
)
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 diagnostics == ()

View File

@ -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"},
}
)

View File

@ -1,145 +1,48 @@
from nanobot.extensions import (
ContributionKind,
DependencyKind,
ExtensionCandidate,
ExtensionContribution,
ExtensionDependency,
ExtensionManifest,
ExtensionPermission,
ExtensionPolicy,
ExtensionRegistry,
ExtensionRuntime,
ExtensionScope,
)
import pytest
from nanobot.extensions import ExtensionManifest, ExtensionPermission
from nanobot.extensions.registry import ExtensionCandidate, ExtensionRegistry
def _candidate(
extension_id: str,
*,
scope: ExtensionScope,
contribution_name: str = "",
trusted: bool = True,
dependencies: tuple[ExtensionDependency, ...] = (),
permissions: tuple[ExtensionPermission, ...] = (),
granted: frozenset[str] = frozenset(),
) -> ExtensionCandidate:
contributions = (
ExtensionContribution(
kind=ContributionKind.TOOL,
name=contribution_name,
),
) if contribution_name else ()
return ExtensionCandidate(
manifest=ExtensionManifest(
ExtensionManifest(
id=extension_id,
name=extension_id,
version="1.0.0",
runtime=ExtensionRuntime.PYTHON,
contributions=contributions,
dependencies=dependencies,
permissions=permissions,
),
scope=scope,
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.register(_candidate("acme", scope=ExtensionScope.BUILTIN))
registry.register(_candidate("acme", scope=ExtensionScope.USER))
registry.register(_candidate("acme", scope=ExtensionScope.WORKSPACE))
registry.register(_candidate("trusted"))
registry.register(_candidate("untrusted", trusted=False))
snapshot = registry.snapshot()
assert len(snapshot.extensions) == 1
assert snapshot.extensions[0].scope is ExtensionScope.WORKSPACE
assert [item.manifest.id for item in registry.snapshot().extensions] == ["trusted"]
def test_policy_filters_extensions_before_contribution_resolution() -> 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:
def test_every_requested_permission_must_be_granted() -> None:
registry = ExtensionRegistry()
registry.register(
_candidate(
"untrusted",
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,
"permission.test",
permissions=(
ExtensionPermission(name="network", reason="Fetch data."),
ExtensionPermission(
name="filesystem.read",
reason="Read input.",
),
ExtensionPermission(name="network"),
ExtensionPermission(name="filesystem.read"),
),
),
scope=ExtensionScope.USER,
trusted=True,
granted_permissions=frozenset({"network"}),
granted=frozenset({"network"}),
)
)
registry = ExtensionRegistry()
registry.register(candidate)
snapshot = registry.snapshot()
@ -148,174 +51,9 @@ def test_external_extension_requires_every_requested_permission() -> None:
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.register(
_candidate(
"core",
scope=ExtensionScope.BUILTIN,
contribution_name="shell",
)
)
registry.register(
_candidate(
"third-party",
scope=ExtensionScope.WORKSPACE,
contribution_name="shell",
)
)
registry.register(_candidate("duplicate"))
snapshot = registry.snapshot()
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
)
with pytest.raises(ValueError, match="already installed"):
registry.register(_candidate("duplicate"))

View File

@ -1,157 +1,46 @@
from pathlib import Path
from types import SimpleNamespace
import pytest
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.command.router import CommandRouter
from nanobot.config.schema import Config
from nanobot.extensions import (
DependencyKind,
from nanobot.extensions import ExtensionManifest
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDependency,
ExtensionManifest,
ExtensionRuntime,
ExtensionRuntimeManager,
ExtensionScope,
ExtensionSnapshot,
)
from nanobot.extensions.runtime import ExtensionRuntimeManager
def _snapshot(candidate: ExtensionCandidate) -> ExtensionSnapshot:
return ExtensionSnapshot((candidate,), (), ())
@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(
def _candidate(root: Path, extension_id: str = "test.python") -> ExtensionCandidate:
return ExtensionCandidate(
ExtensionManifest(
id="test.remote",
name="Remote",
id=extension_id,
name="Python extension",
version="1.0.0",
runtime=ExtensionRuntime.PI,
entry="index.mjs",
entry="extension:register",
),
ExtensionScope.USER,
location=tmp_path,
location=root,
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
async def test_runtime_rolls_back_partial_registration(tmp_path: Path) -> None:
(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"
def _snapshot(*candidates: ExtensionCandidate) -> ExtensionSnapshot:
return ExtensionSnapshot(candidates, ())
@pytest.mark.asyncio
async def test_python_runtime_reloads_updated_source(tmp_path: Path) -> None:
source = tmp_path / "plugin.py"
candidate = ExtensionCandidate(
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"""
def _write_extension(root: Path, result: str = "ok") -> None:
(root / "extension.py").write_text(
f"""
from nanobot.agent.hook import AgentHook
from nanobot.agent.tools.base import Tool
class ReloadTool(Tool):
class ExtensionTool(Tool):
@property
def name(self):
return "reload_test"
return "extension_echo"
@property
def description(self):
return "Reload test"
return "Echo from an extension"
@property
def parameters(self):
@ -160,143 +49,50 @@ class ReloadTool(Tool):
async def execute(self):
return "{result}"
def register(api):
api.register_tool(ReloadTool())
"""
)
async def command(_context):
return None
write_plugin("first")
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):
def hook_factory(_context):
return AgentHook()
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(
tools=ToolRegistry(),
commands=CommandRouter(),
config=Config(),
tools=tools,
commands=commands,
hook_factories=hooks,
)
result = await manager.activate(_snapshot(candidate))
result = await manager.activate(_snapshot(_candidate(tmp_path)))
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()
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_python_runtime_cannot_overwrite_core_tool(tmp_path: Path) -> None:
(tmp_path / "plugin.py").write_text(
async def test_failed_registration_rolls_back_partial_state(tmp_path: Path) -> None:
(tmp_path / "extension.py").write_text(
"""
from nanobot.agent.tools.base import Tool
@ -304,88 +100,81 @@ class DuplicateTool(Tool):
name = "duplicate"
description = "Duplicate"
parameters = {"type": "object", "properties": {}}
async def execute(self):
return "extension"
return "ok"
async def command(_context):
return None
def register(api):
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.register(core_tool)
commands = CommandRouter()
async def core_handler(_context):
return None
commands.exact("/duplicate", core_handler)
manager = ExtensionRuntimeManager(
tools=tools,
commands=CommandRouter(),
config=Config(),
commands=commands,
)
result = await manager.activate(_snapshot(candidate))
result = await manager.activate(_snapshot(_candidate(tmp_path)))
assert result.extensions == ()
assert result.diagnostics[0].code == "activation_failed"
assert tools.get("duplicate") is core_tool
assert tools.owner("duplicate") == "nanobot.core"
assert tools.get("duplicate") is None
assert commands.owner("exact", "/duplicate") == "nanobot.core"
@pytest.mark.asyncio
async def test_failed_extension_prevents_dependent_activation(tmp_path: Path) -> None:
base_root = tmp_path / "base"
dependent_root = tmp_path / "dependent"
base_root.mkdir()
dependent_root.mkdir()
base = ExtensionCandidate(
ExtensionManifest(
id="base",
name="Base",
version="1.0.0",
runtime=ExtensionRuntime.PYTHON,
entry="missing:register",
),
ExtensionScope.USER,
location=base_root,
trusted=True,
)
dependent = ExtensionCandidate(
ExtensionManifest(
id="dependent",
name="Dependent",
version="1.0.0",
runtime=ExtensionRuntime.DECLARATIVE,
dependencies=(
ExtensionDependency(
kind=DependencyKind.EXTENSION,
name="base",
),
),
),
ExtensionScope.USER,
location=dependent_root,
trusted=True,
)
manager = ExtensionRuntimeManager(
tools=ToolRegistry(),
commands=CommandRouter(),
config=Config(),
async def test_python_extension_reloads_updated_source(tmp_path: Path) -> None:
tools = ToolRegistry()
candidate = _candidate(tmp_path)
_write_extension(tmp_path, "first")
first = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
await first.activate(_snapshot(candidate))
assert await tools.get("extension_echo").execute() == "first"
await first.close()
_write_extension(tmp_path, "later")
second = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
await second.activate(_snapshot(candidate))
assert await tools.get("extension_echo").execute() == "later"
await second.close()
async def test_extensions_with_the_same_entry_module_are_isolated(
tmp_path: Path,
) -> None:
tools = ToolRegistry()
manager = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
first_root = tmp_path / "first"
second_root = tmp_path / "second"
first_root.mkdir()
second_root.mkdir()
_write_extension(first_root, "first")
_write_extension(second_root, "second")
second_source = (second_root / "extension.py").read_text()
(second_root / "extension.py").write_text(
second_source
.replace("extension_echo", "second_echo")
.replace('register_command("extension"', 'register_command("second"')
)
result = await manager.activate(ExtensionSnapshot((base, dependent), (), ()))
result = await manager.activate(
_snapshot(
_candidate(first_root, "test.first"),
_candidate(second_root, "test.second"),
)
)
assert result.extensions == ()
assert [item.code for item in result.diagnostics] == [
"activation_failed",
"dependency_activation_failed",
]
assert result.diagnostics == ()
assert await tools.get("extension_echo").execute() == "first"
assert await tools.get("second_echo").execute() == "second"
await manager.close()

View File

@ -1,103 +1,69 @@
import json
from pathlib import Path
from types import SimpleNamespace
from nanobot.extensions import (
from nanobot.extensions import ExtensionManifest
from nanobot.extensions.registry import (
ExtensionCandidate,
ExtensionDiagnostic,
ExtensionManifest,
ExtensionRuntime,
ExtensionScope,
ExtensionSnapshot,
)
from nanobot.extensions.service import ExtensionService
from nanobot.extensions.store import ExtensionStore
async def test_service_installs_untrusted_extension_and_reports_status(
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",
def _package(root: Path) -> Path:
root.mkdir()
(root / "extension.py").write_text("def register(api):\n pass\n")
(root / "nanobot.extension.json").write_text(
json.dumps(
{
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"entry": "extension:register",
}
)
)
return root
async def test_service_installs_untrusted_and_updates_policy(tmp_path: Path) -> None:
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
installed = await service.install(str(source), kind="local")
status = await service.status()
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",
installed = await service.install(
str(_package(tmp_path / "source")),
kind="local",
)
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
await service.install(str(source), kind="local")
trusted = await service.set_trusted("sample", True)
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 removed == {"removed": "sample"}
assert (await service.status())["extensions"] == []
assert (await service.status())["extensions"][0]["enabled"] is False
async def test_service_reports_active_only_after_runtime_activation(
tmp_path: Path,
) -> None:
async def test_service_reports_activation_failure(tmp_path: Path) -> None:
candidate = ExtensionCandidate(
manifest=ExtensionManifest(
id="broken",
name="Broken",
version="1.0.0",
runtime=ExtensionRuntime.PYTHON,
entry="missing:register",
),
scope=ExtensionScope.USER,
ExtensionManifest(id="broken", name="Broken", version="1.0.0"),
location=tmp_path,
trusted=True,
)
snapshot = ExtensionSnapshot((candidate,), (), ())
host = SimpleNamespace(
snapshot=SimpleNamespace(
catalog=SimpleNamespace(
candidates=(candidate,),
diagnostics=(),
snapshot=snapshot,
snapshot=ExtensionSnapshot((candidate,), ()),
),
activation=SimpleNamespace(
extensions=(),
diagnostics=(
ExtensionDiagnostic(
code="activation_failed",
extension_id="broken",
message="missing module",
"activation_failed",
"broken",
"missing module",
),
),
),
@ -110,41 +76,13 @@ async def test_service_reports_active_only_after_runtime_activation(
status = await service.status()
assert status["extensions"][0]["active"] is False
assert status["extensions"][0]["managed_by_store"] is False
assert not status["extensions"][0]["active"]
assert status["diagnostics"][0]["code"] == "activation_failed"
async def test_service_reports_projected_native_capability_as_active(
tmp_path: Path,
) -> None:
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"),
)
async def test_service_uninstalls_package(tmp_path: Path) -> None:
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
await service.install(str(_package(tmp_path / "source")), kind="local")
status = await service.status()
assert status["extensions"][0]["active"] is True
assert status["extensions"][0]["managed_by_store"] is False
assert await service.uninstall("sample") == {"removed": "sample"}
assert (await service.status())["extensions"] == []

View File

@ -1,603 +1,144 @@
import json
import tarfile
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.extensions import (
DependencyKind,
ExtensionDependency,
ExtensionManifest,
ExtensionPermission,
ExtensionRuntime,
from nanobot.extensions.store import (
ExtensionSourceKind,
ExtensionStore,
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 / "index.mjs").write_text("export default function () {}")
(root / "package.json").write_text(
(root / "extension.py").write_text("def register(api):\n pass\n")
(root / "nanobot.extension.json").write_text(
json.dumps(
{
"name": "store-test",
"id": "store.test",
"name": "Store test",
"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
def test_store_installs_and_applies_trust_state(tmp_path: Path) -> None:
source = _pi_package(tmp_path / "source")
store = ExtensionStore(tmp_path / "extensions")
def test_local_install_is_atomic_and_untrusted_by_default(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
installed = store.install_local(source)
result = store.install_local(_package(tmp_path / "source"))
assert installed.record.source is ExtensionSourceKind.LOCAL
assert installed.record.integrity.startswith("sha256:")
assert result.record.source is ExtensionSourceKind.LOCAL
assert result.record.integrity.startswith("sha256:")
assert result.manifest.id == "store.test"
assert not store.discover().candidates[0].trusted
store.set_trusted(installed.record.id, True)
store.set_enabled(installed.record.id, False)
store.set_permissions(installed.record.id, {"network", "filesystem.read"})
def test_policy_updates_survive_discovery(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
store.set_trusted("store.test", True)
store.set_enabled("store.test", False)
store.set_permissions("store.test", {"network"})
candidate = store.discover().candidates[0]
assert candidate.trusted
assert not candidate.enabled
assert candidate.granted_permissions == frozenset(
{"network", "filesystem.read"}
)
assert candidate.granted_permissions == frozenset({"network"})
def test_registry_permissions_must_be_a_string_array() -> None:
with pytest.raises(ValueError, match="array of strings"):
InstalledExtension.from_mapping(
{
"id": "sample",
"version": "1.0.0",
"source": "npm",
"source_ref": "sample",
"integrity": "sha256:test",
"installed_at": "2026-01-01T00:00:00Z",
"granted_permissions": "network",
}
)
def test_unknown_permission_cannot_be_granted(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
with pytest.raises(ValueError, match="not requested"):
store.set_permissions("store.test", {"filesystem.write"})
@pytest.mark.parametrize(
("field", "value", "message"),
[
("source", 3, "source must be a string"),
("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() {}")
def test_modified_install_loses_effective_trust(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"), trusted=True)
(store.root / "store.test" / "extension.py").write_text("changed = True\n")
discovery = store.discover()
assert not discovery.candidates[0].trusted
assert not discovery.candidates[0].integrity_valid
assert any(
item.code == "extension_integrity_mismatch"
for item in discovery.diagnostics
)
assert discovery.diagnostics[0].code == "extension_integrity_mismatch"
def test_store_ignores_interrupted_transaction_directories(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "extensions")
hidden = _pi_package(store.root / ".install-interrupted")
def test_changed_reinstall_revokes_trust_but_identical_reinstall_preserves_it(
tmp_path: Path,
) -> None:
source = _package(tmp_path / "source")
store = ExtensionStore(tmp_path / "installed")
store.install_local(source, trusted=True)
identical = store.install_local(source)
assert identical.record.trusted
(source / "extension.py").write_text("changed = True\n")
changed = store.install_local(source)
assert not changed.record.trusted
def test_install_rejects_missing_manifest_and_symlinks(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(ValueError, match="cannot read extension manifest"):
store.install_local(empty)
source = _package(tmp_path / "source")
(source / "outside").symlink_to(tmp_path)
with pytest.raises(ValueError, match="symlink"):
store.install_local(source)
def test_install_rejects_source_containing_store(tmp_path: Path) -> None:
source = _package(tmp_path / "source")
store = ExtensionStore(source / "installed")
with pytest.raises(ValueError, match="cannot contain the extension store"):
store.install_local(source)
def test_registry_validation_and_uninstall(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="sha256 digest"):
InstalledExtension.model_validate(
{
"id": "sample",
"version": "1.0.0",
"source": "local",
"source_ref": "/tmp/sample",
"integrity": "invalid",
"installed_at": "2026-01-01T00:00:00Z",
}
)
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
store.uninstall("store.test")
assert store.records() == {}
assert not (store.root / "store.test").exists()
def test_corrupt_registry_is_diagnostic_and_not_overwritten(tmp_path: Path) -> None:
store = ExtensionStore(tmp_path / "installed")
store.install_local(_package(tmp_path / "source"))
store.registry_path.write_text("{broken")
discovery = store.discover()
assert hidden.is_dir()
assert discovery.candidates == ()
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)
assert discovery.diagnostics[0].code == "invalid_extension_registry"
with pytest.raises(ValueError, match="invalid extension registry"):
store.set_trusted("store.test", True)
assert store.registry_path.read_text() == "{broken"

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import json
from types import SimpleNamespace
from urllib.parse import parse_qs, quote, urlsplit
from urllib.parse import quote
import pytest
from websockets.datastructures import Headers
@ -19,10 +19,6 @@ class _Service:
self.calls.append(("status", None))
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):
self.calls.append(("install", (source, kind, ref, trusted)))
return {"record": {"id": "sample"}}
@ -31,6 +27,10 @@ class _Service:
self.calls.append(("trust", (extension_id, trusted)))
return {"record": {"id": extension_id}}
async def set_permissions(self, extension_id, permissions):
self.calls.append(("permissions", (extension_id, permissions)))
return {"record": {"id": extension_id}}
def _router(
service: _Service,
@ -41,7 +41,6 @@ def _router(
return WebUIExtensionsRouter(
service=service,
check_api_token=lambda _request: authorized,
parse_query=lambda path: parse_qs(urlsplit(path).query),
json_response=http_json_response,
error_response=lambda status, message: http_json_response(
{"error": message},
@ -58,12 +57,10 @@ def _request(
method: str = "GET",
values: dict[str, object] | None = None,
host: str = "127.0.0.1:8765",
encode_values: bool = False,
):
headers = Headers([("Host", host)])
if values is not None:
payload = json.dumps(values)
headers["X-Nanobot-Extension-Values"] = quote(payload) if encode_values else payload
headers["X-Nanobot-Extension-Values"] = quote(json.dumps(values))
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
async def test_extension_market_parses_query() -> 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:
async def test_local_install_is_untrusted() -> None:
service = _Service()
response = await _router(service).dispatch(
_LOCAL,
_request(
"/api/extensions/install",
method="POST",
values={"source": "中文扩展", "kind": "npm"},
encode_values=True,
values={"source": "https://example.com/acme.git", "kind": "git"},
),
"/api/extensions/install",
)
assert response is not None and response.status_code == 200
assert service.calls == [
("install", ("中文扩展", "npm", "", False)),
("install", ("https://example.com/acme.git", "git", "", False)),
]
@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()
denied = await _router(service).dispatch(
_REMOTE,
_request(
"/api/extensions/install",
method="POST",
values={"source": "pi-example", "kind": "npm"},
values={"source": "https://example.com/acme.git", "kind": "git"},
),
"/api/extensions/install",
)
npm_allowed = await _router(service, allow_remote=True).dispatch(
allowed = await _router(service, allow_remote=True).dispatch(
_REMOTE,
_request(
"/api/extensions/install",
method="POST",
values={"source": "pi-example", "kind": "npm"},
values={"source": "https://example.com/acme.git", "kind": "git"},
),
"/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 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 service.calls == [
("install", ("pi-example", "npm", "", False)),
("install", ("https://example.com/acme.git", "git", "", False)),
]
@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()
response = await _router(service, allow_remote=True).dispatch(
_REMOTE,
_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 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

View File

@ -24,7 +24,6 @@ import {
ExtensionMark,
MetaItem,
NamedItems,
RuntimeBadge,
StatusBadge,
} from "./extension-ui";
@ -52,8 +51,6 @@ export function ExtensionDetailSheet({
const [uninstallOpen, setUninstallOpen] = useState(false);
if (!extension) return null;
const configManaged =
extension.scope !== "builtin" && !extension.managed_by_store;
const requested = new Set(extension.requested_permissions);
const granted = new Set(extension.granted_permissions);
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="flex items-start gap-3 pr-8">
<ExtensionMark runtime={extension.runtime} large />
<ExtensionMark large />
<div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold">
{extension.name}
@ -76,7 +73,6 @@ export function ExtensionDetailSheet({
{extension.description || extension.id}
</SheetDescription>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<RuntimeBadge runtime={extension.runtime} />
<DetailPill>{extension.version}</DetailPill>
<StatusBadge extension={extension} />
</div>
@ -91,10 +87,6 @@ export function ExtensionDetailSheet({
label={t("extensions.details.source")}
value={extension.source}
/>
<MetaItem
label={t("extensions.details.scope")}
value={extension.scope}
/>
<MetaItem
label={t("extensions.details.license")}
value={extension.license || "—"}
@ -113,13 +105,6 @@ export function ExtensionDetailSheet({
) : null}
</DetailSection>
<NamedItems
title={t("extensions.details.contributions")}
rows={extension.contributions.map((item) => ({
name: item.name,
meta: item.kind,
}))}
/>
<NamedItems
title={t("extensions.details.dependencies")}
rows={extension.dependencies.map((item) => ({
@ -138,15 +123,11 @@ export function ExtensionDetailSheet({
>
<div className="min-w-0">
<div className="text-[13px] font-medium text-foreground">
{permission.name === "runtime.node"
? t("extensions.knownPermissions.runtimeNode.label")
: permission.name}
{permission.name}
</div>
{permission.reason ? (
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
{permission.name === "runtime.node"
? t("extensions.knownPermissions.runtimeNode.reason")
: permission.reason}
{permission.reason}
</p>
) : null}
</div>
@ -164,24 +145,22 @@ export function ExtensionDetailSheet({
</span>
</div>
))}
{extension.managed_by_store ? (
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() =>
void onAction("permissions", {
id: extension.id,
permissions: allGranted ? [] : [...requested],
})
}
className="rounded-full"
>
{allGranted
? t("extensions.revokePermissions")
: t("extensions.grantPermissions")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() =>
void onAction("permissions", {
id: extension.id,
permissions: allGranted ? [] : [...requested],
})
}
className="rounded-full"
>
{allGranted
? t("extensions.revokePermissions")
: t("extensions.grantPermissions")}
</Button>
</div>
) : (
<p className="text-[13px] text-muted-foreground">
@ -212,56 +191,50 @@ export function ExtensionDetailSheet({
</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">
<Button
size="sm"
variant={extension.trusted ? "outline" : "default"}
disabled={busy !== null || (!extension.trusted && !allGranted)}
onClick={() =>
void onAction(extension.trusted ? "untrust" : "trust", {
id: extension.id,
})
}
className="rounded-full"
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{extension.trusted
? t("extensions.revokeTrust")
: t("extensions.trust")}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy !== null || !extension.trusted}
onClick={() =>
void onAction(extension.enabled ? "disable" : "enable", {
id: extension.id,
})
}
className="rounded-full"
>
{extension.enabled
? t("extensions.disable")
: t("extensions.enable")}
</Button>
<Button
size="icon"
variant="ghost"
disabled={busy !== null}
aria-label={t("extensions.uninstall")}
title={t("extensions.uninstall")}
onClick={() => setUninstallOpen(true)}
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
) : 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}
<div className="flex flex-wrap items-center gap-2 border-t border-border/45 bg-background/95 px-5 py-4">
<Button
size="sm"
variant={extension.trusted ? "outline" : "default"}
disabled={busy !== null || (!extension.trusted && !allGranted)}
onClick={() =>
void onAction(extension.trusted ? "untrust" : "trust", {
id: extension.id,
})
}
className="rounded-full"
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{extension.trusted
? t("extensions.revokeTrust")
: t("extensions.trust")}
</Button>
<Button
size="sm"
variant="outline"
disabled={busy !== null || !extension.trusted}
onClick={() =>
void onAction(extension.enabled ? "disable" : "enable", {
id: extension.id,
})
}
className="rounded-full"
>
{extension.enabled
? t("extensions.disable")
: t("extensions.enable")}
</Button>
<Button
size="icon"
variant="ghost"
disabled={busy !== null}
aria-label={t("extensions.uninstall")}
title={t("extensions.uninstall")}
onClick={() => setUninstallOpen(true)}
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" aria-hidden />
</Button>
</div>
</SheetContent>
</Sheet>

View File

@ -1,5 +1,5 @@
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 { Button } from "@/components/ui/button";
@ -7,42 +7,33 @@ import { Input } from "@/components/ui/input";
import {
fetchExtensions,
runExtensionAction,
searchExtensions,
type ExtensionAction,
} from "@/lib/api";
import type {
ExtensionDiagnosticInfo,
ExtensionInfo,
ExtensionMarketPackage,
} from "@/lib/types";
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import { ExtensionDetailSheet } from "./ExtensionDetailSheet";
import {
EmptyState,
type ExtensionEcosystem,
ExtensionMark,
type ExtensionTab,
filterExtensions,
filterPackages,
LoadingState,
RuntimeBadge,
StatusBadge,
} from "./extension-ui";
type InstallKind = "git" | "local";
export function ExtensionsCatalog() {
const { t } = useTranslation();
const { token } = useClient();
const [tab, setTab] = useState<ExtensionTab>("installed");
const [ecosystem, setEcosystem] = useState<ExtensionEcosystem>("all");
const [query, setQuery] = useState("");
const [source, setSource] = useState("");
const [kind, setKind] = useState<InstallKind>("git");
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [diagnostics, setDiagnostics] = useState<ExtensionDiagnosticInfo[]>([]);
const [packages, setPackages] = useState<ExtensionMarketPackage[]>([]);
const [selected, setSelected] = useState<ExtensionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [searching, setSearching] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@ -69,49 +60,6 @@ export function ExtensionsCatalog() {
void 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(
async (
action: ExtensionAction,
@ -132,41 +80,50 @@ export function ExtensionsCatalog() {
[refresh, token],
);
const tabs: Array<{ key: ExtensionTab; count?: number }> = [
{ key: "installed", count: installed.length },
{ key: "discover" },
{ key: "builtin", count: builtin.length },
];
const visible = useMemo(
() => filterExtensions(extensions, query),
[extensions, query],
);
return (
<div className="space-y-5">
<section className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="inline-flex w-fit rounded-[12px] bg-muted/55 p-1"
aria-label={t("extensions.tabs.label")}
<form
className="flex flex-col gap-2 sm:flex-row"
onSubmit={(event) => {
event.preventDefault();
const value = source.trim();
if (!value) return;
void mutate("install", { source: value, kind }, "install");
}}
>
<select
value={kind}
onChange={(event) => setKind(event.target.value as InstallKind)}
aria-label={t("extensions.installKind")}
className="h-11 rounded-[14px] border border-border/55 bg-settings-surface px-3 text-[13px] text-foreground outline-none"
>
{tabs.map((item) => (
<button
key={item.key}
type="button"
aria-pressed={tab === item.key}
onClick={() => setTab(item.key)}
className={cn(
"h-8 rounded-[9px] px-3 text-[12px] font-medium text-muted-foreground transition-colors",
tab === item.key && "bg-background text-foreground shadow-sm",
)}
>
{t(`extensions.tabs.${item.key}`)}
{item.count === undefined ? null : (
<span className="ml-1.5 text-muted-foreground">{item.count}</span>
)}
</button>
))}
</div>
{tab === "discover" ? (
<EcosystemFilter value={ecosystem} onChange={setEcosystem} />
) : null}
</section>
<option value="git">{t("extensions.source.git")}</option>
<option value="local">{t("extensions.source.local")}</option>
</select>
<Input
value={source}
onChange={(event) => setSource(event.target.value)}
placeholder={t(
kind === "git"
? "extensions.installGitPlaceholder"
: "extensions.installLocalPlaceholder",
)}
className="h-11 flex-1 rounded-[14px] border-border/55 bg-settings-surface text-[13px] shadow-none"
/>
<Button
type="submit"
disabled={!source.trim() || busy === "install"}
className="h-11 rounded-[14px] px-4"
>
<Download className="mr-2 h-4 w-4" aria-hidden />
{t("extensions.installAction")}
</Button>
</form>
<label className="relative block">
<Search
@ -176,11 +133,7 @@ export function ExtensionsCatalog() {
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t(
tab === "discover"
? "extensions.searchMarket"
: "extensions.searchInstalled",
)}
placeholder={t("extensions.searchInstalled")}
className="h-11 rounded-[14px] border-border/55 bg-settings-surface pl-10 text-[13px] shadow-none"
/>
</label>
@ -191,29 +144,12 @@ export function ExtensionsCatalog() {
</div>
) : null}
{tab === "discover" ? (
<MarketList
packages={filterPackages(packages, query)}
installedIds={installedIds}
loading={searching}
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}
/>
)}
<ExtensionList
extensions={visible}
diagnostics={diagnostics}
loading={loading}
onSelect={setSelected}
/>
<ExtensionDetailSheet
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({
extensions,
diagnostics,
loading,
emptyKey,
onSelect,
}: {
extensions: ExtensionInfo[];
diagnostics: ExtensionDiagnosticInfo[];
loading: boolean;
emptyKey: "installed" | "builtin";
onSelect: (extension: ExtensionInfo) => void;
}) {
const { t } = useTranslation();
if (loading) return <LoadingState />;
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));
return (
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
{extensions.map((extension, index) => (
<button
key={`${extension.scope}:${extension.id}`}
key={extension.id}
type="button"
onClick={() => onSelect(extension)}
className={cn(
@ -293,20 +198,22 @@ function ExtensionList({
index > 0 && "border-t border-border/40",
)}
>
<ExtensionMark runtime={extension.runtime} />
<ExtensionMark />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-[14px] font-medium text-foreground">
{extension.name}
</h3>
<RuntimeBadge runtime={extension.runtime} />
</div>
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
{extension.description || extension.id}
</p>
</div>
{diagnosticIds.has(extension.id) ? (
<CircleAlert className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
<CircleAlert
className="h-4 w-4 shrink-0 text-amber-500"
aria-hidden
/>
) : null}
<StatusBadge extension={extension} />
</button>
@ -314,80 +221,3 @@ function ExtensionList({
</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>
);
}

View File

@ -1,21 +1,11 @@
import type { ReactNode } from "react";
import { Box, Loader2, PackageOpen } from "lucide-react";
import { Loader2, PackageOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ExtensionInfo, ExtensionMarketPackage } from "@/lib/types";
import type { ExtensionInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type ExtensionTab = "installed" | "discover" | "builtin";
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;
export function ExtensionMark({ large = false }: { large?: boolean }) {
return (
<div
className={cn(
@ -23,19 +13,15 @@ export function ExtensionMark({
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>
);
}
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 }) {
const { t } = useTranslation();
const [key, tone] = extension.active
@ -137,20 +123,7 @@ export function filterExtensions(
const term = query.trim().toLowerCase();
if (!term) return items;
return items.filter((item) =>
[item.name, item.id, item.description, item.runtime].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) =>
[item.name, item.id, item.description].some((value) =>
value.toLowerCase().includes(term),
),
);

View File

@ -1266,34 +1266,23 @@
"extensions": {
"backToChat": "Back to chat",
"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",
"empty": {
"installed": "No external extensions installed.",
"discover": "No matching extension packages.",
"builtin": "No built-in capabilities found."
"installed": "No extensions installed."
},
"installed": "Installed",
"install": "Install {{name}}",
"installKind": "Installation source",
"source": {
"git": "Git repository",
"local": "Local folder"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/absolute/path/to/extension",
"installAction": "Install",
"details": {
"identity": "Identity",
"source": "Source",
"scope": "Scope",
"license": "License",
"homepage": "Homepage",
"contributions": "Contributions",
"dependencies": "Dependencies",
"permissions": "Permissions",
"diagnostics": "Diagnostics"
@ -1302,14 +1291,7 @@
"permissionPending": "Not granted",
"revokePermissions": "Revoke permissions",
"grantPermissions": "Grant permissions",
"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."
}
},
"noPermissions": "No host permissions requested.",
"revokeTrust": "Revoke trust",
"trust": "Trust",
"disable": "Disable",

View File

@ -1253,34 +1253,23 @@
"extensions": {
"backToChat": "Volver al chat",
"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",
"empty": {
"installed": "No hay extensiones externas instaladas.",
"discover": "No hay paquetes de extensiones coincidentes.",
"builtin": "No se encontraron capacidades integradas."
"installed": "No hay extensiones instaladas."
},
"installed": "Instalada",
"install": "Instalar {{name}}",
"installKind": "Origen de instalación",
"source": {
"git": "Repositorio Git",
"local": "Carpeta local"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/ruta/absoluta/a/la/extensión",
"installAction": "Instalar",
"details": {
"identity": "Identidad",
"source": "Origen",
"scope": "Ámbito",
"license": "Licencia",
"homepage": "Página principal",
"contributions": "Contribuciones",
"dependencies": "Dependencias",
"permissions": "Permisos",
"diagnostics": "Diagnóstico"
@ -1289,14 +1278,7 @@
"permissionPending": "No concedido",
"revokePermissions": "Revocar permisos",
"grantPermissions": "Conceder permisos",
"noPermissions": "No solicita permisos del host.",
"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."
}
},
"noPermissions": "No solicita permisos del sistema.",
"revokeTrust": "Revocar confianza",
"trust": "Confiar",
"disable": "Desactivar",
@ -1311,7 +1293,7 @@
"untrusted": "No confiable",
"inactive": "Inactiva"
},
"none": "Ninguna",
"none": "Ninguno",
"loading": "Cargando extensiones…"
}
}

View File

@ -1252,57 +1252,39 @@
"extensions": {
"backToChat": "Retour au chat",
"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 dextension",
"searchInstalled": "Rechercher des extensions",
"empty": {
"installed": "Aucune extension externe installée.",
"discover": "Aucun paquet dextension correspondant.",
"builtin": "Aucune capacité intégrée trouvée."
"installed": "Aucune extension installée."
},
"installed": "Installée",
"install": "Installer {{name}}",
"installKind": "Source dinstallation",
"source": {
"git": "Dépôt Git",
"local": "Dossier local"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/chemin/absolu/vers/extension",
"installAction": "Installer",
"details": {
"identity": "Identité",
"source": "Source",
"scope": "Portée",
"license": "Licence",
"homepage": "Site web",
"contributions": "Contributions",
"homepage": "Page daccueil",
"dependencies": "Dépendances",
"permissions": "Autorisations",
"diagnostics": "Diagnostic"
"diagnostics": "Diagnostics"
},
"permissionGranted": "Accordée",
"permissionPending": "Non accordée",
"revokePermissions": "Révoquer les autorisations",
"grantPermissions": "Accorder les autorisations",
"noPermissions": "Aucune autorisation hôte demandée.",
"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."
}
},
"noPermissions": "Aucune autorisation hôte demandée.",
"revokeTrust": "Révoquer la confiance",
"trust": "Faire confiance",
"disable": "Désactiver",
"enable": "Activer",
"uninstall": "Désinstaller",
"uninstallTitle": "Désinstaller lextension ?",
"uninstallDescription": "Cette action supprime {{name}} et ses fichiers installés de nanobot.",
"uninstallTitle": "Désinstaller lextension ?",
"uninstallDescription": "Cela supprime {{name}} et ses fichiers installés de nanobot.",
"cancel": "Annuler",
"status": {
"active": "Active",
@ -1310,7 +1292,7 @@
"untrusted": "Non approuvée",
"inactive": "Inactive"
},
"none": "Aucune",
"none": "Aucun",
"loading": "Chargement des extensions…"
}
}

View File

@ -1250,36 +1250,25 @@
}
},
"extensions": {
"backToChat": "Kembali ke obrolan",
"backToChat": "Kembali ke chat",
"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",
"empty": {
"installed": "Belum ada ekstensi eksternal terpasang.",
"discover": "Tidak ada paket ekstensi yang cocok.",
"builtin": "Kemampuan bawaan tidak ditemukan."
"installed": "Belum ada ekstensi terpasang."
},
"installed": "Terpasang",
"install": "Pasang {{name}}",
"installKind": "Sumber instalasi",
"source": {
"git": "Repositori Git",
"local": "Folder lokal"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/jalur/absolut/ke/ekstensi",
"installAction": "Pasang",
"details": {
"identity": "Identitas",
"source": "Sumber",
"scope": "Cakupan",
"license": "Lisensi",
"homepage": "Beranda",
"contributions": "Kontribusi",
"dependencies": "Dependensi",
"permissions": "Izin",
"diagnostics": "Diagnostik"
@ -1288,14 +1277,7 @@
"permissionPending": "Belum diberikan",
"revokePermissions": "Cabut izin",
"grantPermissions": "Berikan izin",
"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."
}
},
"noPermissions": "Tidak meminta izin host.",
"revokeTrust": "Cabut kepercayaan",
"trust": "Percayai",
"disable": "Nonaktifkan",
@ -1306,7 +1288,7 @@
"cancel": "Batal",
"status": {
"active": "Aktif",
"disabled": "Dinonaktifkan",
"disabled": "Nonaktif",
"untrusted": "Belum dipercaya",
"inactive": "Tidak aktif"
},

View File

@ -1252,34 +1252,23 @@
"extensions": {
"backToChat": "チャットに戻る",
"title": "拡張機能",
"tabs": {
"label": "拡張機能ビュー",
"installed": "インストール済み",
"discover": "探す",
"builtin": "組み込み"
},
"ecosystem": {
"all": "すべて",
"nanobot": "nanobot",
"pi": "Pi",
"openclaw": "OpenClaw"
},
"searchMarket": "拡張パッケージを検索",
"searchInstalled": "拡張機能を検索",
"empty": {
"installed": "外部拡張機能はありません。",
"discover": "一致する拡張パッケージはありません。",
"builtin": "組み込み機能が見つかりません。"
"installed": "拡張機能はインストールされていません。"
},
"installed": "インストール済み",
"install": "{{name}} をインストール",
"installKind": "インストール元",
"source": {
"git": "Git リポジトリ",
"local": "ローカルフォルダー"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/拡張機能への絶対パス",
"installAction": "インストール",
"details": {
"identity": "識別情報",
"source": "ソース",
"scope": "スコープ",
"license": "ライセンス",
"homepage": "ホームページ",
"contributions": "提供機能",
"dependencies": "依存関係",
"permissions": "権限",
"diagnostics": "診断"
@ -1288,14 +1277,7 @@
"permissionPending": "未許可",
"revokePermissions": "権限を取り消す",
"grantPermissions": "権限を許可",
"noPermissions": "ホスト権限の要求はありません。",
"configManaged": "設定で管理されています。",
"knownPermissions": {
"runtimeNode": {
"label": "Node.js ランタイム",
"reason": "サードパーティーの JavaScript または TypeScript を Node.js プロセスで実行します。"
}
},
"noPermissions": "ホスト権限は要求されていません。",
"revokeTrust": "信頼を取り消す",
"trust": "信頼する",
"disable": "無効化",
@ -1305,7 +1287,7 @@
"uninstallDescription": "{{name}} とインストール済みファイルを nanobot から削除します。",
"cancel": "キャンセル",
"status": {
"active": "実行中",
"active": "有効",
"disabled": "無効",
"untrusted": "未信頼",
"inactive": "停止中"

View File

@ -1251,35 +1251,24 @@
},
"extensions": {
"backToChat": "채팅으로 돌아가기",
"title": "확장 기능",
"tabs": {
"label": "확장 기능 보기",
"installed": "설치됨",
"discover": "찾기",
"builtin": "내장"
},
"ecosystem": {
"all": "전체",
"nanobot": "nanobot",
"pi": "Pi",
"openclaw": "OpenClaw"
},
"searchMarket": "확장 패키지 검색",
"searchInstalled": "확장 기능 검색",
"title": "확장",
"searchInstalled": "확장 검색",
"empty": {
"installed": "설치된 외부 확장 기능이 없습니다.",
"discover": "일치하는 확장 패키지가 없습니다.",
"builtin": "내장 기능을 찾지 못했습니다."
"installed": "설치된 확장이 없습니다."
},
"installed": "설치됨",
"install": "{{name}} 설치",
"installKind": "설치 소스",
"source": {
"git": "Git 저장소",
"local": "로컬 폴더"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/확장/절대/경로",
"installAction": "설치",
"details": {
"identity": "식별 정보",
"source": "소스",
"scope": "범위",
"license": "라이선스",
"homepage": "홈페이지",
"contributions": "제공 기능",
"dependencies": "종속성",
"permissions": "권한",
"diagnostics": "진단"
@ -1288,29 +1277,22 @@
"permissionPending": "허용되지 않음",
"revokePermissions": "권한 취소",
"grantPermissions": "권한 허용",
"noPermissions": "요청한 호스트 권한이 없습니다.",
"configManaged": "설정에서 관리됩니다.",
"knownPermissions": {
"runtimeNode": {
"label": "Node.js 런타임",
"reason": "타사 JavaScript 또는 TypeScript를 Node.js 프로세스에서 실행합니다."
}
},
"noPermissions": "호스트 권한을 요청하지 않습니다.",
"revokeTrust": "신뢰 취소",
"trust": "신뢰",
"disable": "비활성화",
"enable": "활성화",
"uninstall": "제거",
"uninstallTitle": "확장 기능을 제거할까요?",
"uninstallTitle": "확장을 제거할까요?",
"uninstallDescription": "nanobot에서 {{name}} 및 설치된 파일을 제거합니다.",
"cancel": "취소",
"status": {
"active": "실행 중",
"active": "활성",
"disabled": "비활성",
"untrusted": "신뢰 안 함",
"inactive": "중지됨"
},
"none": "없음",
"loading": "확장 기능을 불러오는 중…"
"loading": "확장 로드 중…"
}
}

View File

@ -1266,56 +1266,38 @@
"extensions": {
"backToChat": "Voltar ao chat",
"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",
"empty": {
"installed": "Nenhuma extensão externa instalada.",
"discover": "Nenhum pacote de extensão correspondente.",
"builtin": "Nenhum recurso integrado encontrado."
"installed": "Nenhuma extensão instalada."
},
"installed": "Instalada",
"install": "Instalar {{name}}",
"installKind": "Origem da instalação",
"source": {
"git": "Repositório Git",
"local": "Pasta local"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/caminho/absoluto/para/extensão",
"installAction": "Instalar",
"details": {
"identity": "Identidade",
"source": "Origem",
"scope": "Escopo",
"license": "Licença",
"homepage": "Página inicial",
"contributions": "Contribuições",
"dependencies": "Dependências",
"permissions": "Permissões",
"diagnostics": "Diagnóstico"
"diagnostics": "Diagnósticos"
},
"permissionGranted": "Concedida",
"permissionPending": "Não concedida",
"revokePermissions": "Revogar permissões",
"grantPermissions": "Conceder permissões",
"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."
}
},
"noPermissions": "Nenhuma permissão do host solicitada.",
"revokeTrust": "Revogar confiança",
"trust": "Confiar",
"disable": "Desativar",
"enable": "Ativar",
"uninstall": "Desinstalar",
"uninstallTitle": "Desinstalar a extensão?",
"uninstallTitle": "Desinstalar extensão?",
"uninstallDescription": "Isso remove {{name}} e seus arquivos instalados do nanobot.",
"cancel": "Cancelar",
"status": {
@ -1324,7 +1306,7 @@
"untrusted": "Não confiável",
"inactive": "Inativa"
},
"none": "Nenhuma",
"none": "Nenhum",
"loading": "Carregando extensões…"
}
}

View File

@ -1251,35 +1251,24 @@
},
"extensions": {
"backToChat": "Quay lại trò chuyện",
"title": "Tiện ích mở rộng",
"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",
"title": "Tiện ích",
"searchInstalled": "Tìm tiện ích",
"empty": {
"installed": "Chưa cài tiện ích bên ngoài.",
"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": "Chưa cài tiện ích nào."
},
"installed": "Đã cài",
"install": "Cài {{name}}",
"installKind": "Nguồn cài đặt",
"source": {
"git": "Kho Git",
"local": "Thư mục cục bộ"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/đường/dẫn/tuyệt/đối/đến/tiện-ích",
"installAction": "Cài đặt",
"details": {
"identity": "Định danh",
"identity": "Danh tính",
"source": "Nguồn",
"scope": "Phạm vi",
"license": "Giấy phép",
"homepage": "Trang chủ",
"contributions": "Khả năng cung cấp",
"dependencies": "Phụ thuộc",
"permissions": "Quyền",
"diagnostics": "Chẩn đoán"
@ -1288,14 +1277,7 @@
"permissionPending": "Chưa cấp",
"revokePermissions": "Thu hồi quyền",
"grantPermissions": "Cấp quyền",
"noPermissions": "Không yêu cầu quyền máy chủ.",
"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."
}
},
"noPermissions": "Không yêu cầu quyền máy chủ.",
"revokeTrust": "Thu hồi tin cậy",
"trust": "Tin cậy",
"disable": "Tắt",
@ -1305,7 +1287,7 @@
"uninstallDescription": "Thao tác này xóa {{name}} và các tệp đã cài khỏi nanobot.",
"cancel": "Hủy",
"status": {
"active": "Đang chạy",
"active": "Đang hoạt động",
"disabled": "Đã tắt",
"untrusted": "Chưa tin cậy",
"inactive": "Không hoạt động"

View File

@ -1266,34 +1266,23 @@
"extensions": {
"backToChat": "返回聊天",
"title": "扩展",
"tabs": {
"label": "扩展视图",
"installed": "已安装",
"discover": "发现",
"builtin": "内置"
},
"ecosystem": {
"all": "全部",
"nanobot": "nanobot",
"pi": "Pi",
"openclaw": "OpenClaw"
},
"searchMarket": "搜索扩展包",
"searchInstalled": "搜索扩展",
"empty": {
"installed": "尚未安装外部扩展。",
"discover": "没有匹配的扩展包。",
"builtin": "未发现内置能力。"
"installed": "尚未安装扩展。"
},
"installed": "已安装",
"install": "安装 {{name}}",
"installKind": "安装来源",
"source": {
"git": "Git 仓库",
"local": "本地文件夹"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/扩展的绝对路径",
"installAction": "安装",
"details": {
"identity": "标识",
"source": "来源",
"scope": "范围",
"license": "许可证",
"homepage": "主页",
"contributions": "提供的能力",
"dependencies": "依赖",
"permissions": "权限",
"diagnostics": "诊断"
@ -1302,14 +1291,7 @@
"permissionPending": "未授予",
"revokePermissions": "撤销权限",
"grantPermissions": "授予权限",
"noPermissions": "未请求宿主权限。",
"configManaged": "由配置管理。",
"knownPermissions": {
"runtimeNode": {
"label": "Node.js 运行时",
"reason": "允许扩展在 Node.js 进程中运行第三方 JavaScript 或 TypeScript。"
}
},
"noPermissions": "未请求宿主权限。",
"revokeTrust": "撤销信任",
"trust": "信任",
"disable": "停用",

View File

@ -1251,35 +1251,24 @@
},
"extensions": {
"backToChat": "返回聊天",
"title": "擴充套件",
"tabs": {
"label": "擴充套件檢視",
"installed": "已安裝",
"discover": "探索",
"builtin": "內建"
},
"ecosystem": {
"all": "全部",
"nanobot": "nanobot",
"pi": "Pi",
"openclaw": "OpenClaw"
},
"searchMarket": "搜尋擴充套件",
"searchInstalled": "搜尋擴充套件",
"title": "擴充功能",
"searchInstalled": "搜尋擴充功能",
"empty": {
"installed": "尚未安裝外部擴充套件。",
"discover": "沒有相符的擴充套件。",
"builtin": "找不到內建能力。"
"installed": "尚未安裝擴充功能。"
},
"installed": "已安裝",
"install": "安裝 {{name}}",
"installKind": "安裝來源",
"source": {
"git": "Git 儲存庫",
"local": "本機資料夾"
},
"installGitPlaceholder": "https://github.com/acme/extension.git",
"installLocalPlaceholder": "/擴充功能的絕對路徑",
"installAction": "安裝",
"details": {
"identity": "識別資訊",
"source": "來源",
"scope": "範圍",
"license": "授權條款",
"homepage": "首頁",
"contributions": "提供的能力",
"dependencies": "相依項目",
"permissions": "權限",
"diagnostics": "診斷"
@ -1288,29 +1277,22 @@
"permissionPending": "未授予",
"revokePermissions": "撤銷權限",
"grantPermissions": "授予權限",
"noPermissions": "未要求主機權限。",
"configManaged": "由設定管理。",
"knownPermissions": {
"runtimeNode": {
"label": "Node.js 執行環境",
"reason": "允許擴充套件在 Node.js 行程中執行第三方 JavaScript 或 TypeScript。"
}
},
"noPermissions": "未要求主機權限。",
"revokeTrust": "撤銷信任",
"trust": "信任",
"disable": "停用",
"enable": "啟用",
"uninstall": "解除安裝",
"uninstallTitle": "解除安裝擴充套件",
"uninstallTitle": "解除安裝擴充功能?",
"uninstallDescription": "這會從 nanobot 移除 {{name}} 及其已安裝檔案。",
"cancel": "取消",
"status": {
"active": "執行中",
"active": "運作中",
"disabled": "已停用",
"untrusted": "未信任",
"inactive": "未執行"
"inactive": "未運作"
},
"none": "無",
"loading": "正在載入擴充套件…"
"loading": "正在載入擴充功能…"
}
}

View File

@ -7,7 +7,6 @@ import type {
ChannelValidationPayload,
ChatSummary,
CliAppsPayload,
ExtensionMarketPayload,
ExtensionsPayload,
FilePreviewPayload,
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 =
| "install"
| "enable"

View File

@ -734,12 +734,6 @@ export interface CliAppsPayload {
};
}
export interface ExtensionContributionInfo {
kind: string;
name: string;
description: string;
}
export interface ExtensionDependencyInfo {
kind: string;
name: string;
@ -756,11 +750,9 @@ export interface ExtensionInfo {
id: string;
name: string;
version: string;
runtime: "python" | "pi" | "openclaw" | "declarative" | string;
description: string;
homepage: string;
license: string;
scope: "builtin" | "user" | "workspace" | string;
location: string | null;
enabled: boolean;
trusted: boolean;
@ -771,8 +763,6 @@ export interface ExtensionInfo {
source_ref: string;
integrity: string;
installed_at: string;
managed_by_store: boolean;
contributions: ExtensionContributionInfo[];
dependencies: ExtensionDependencyInfo[];
permissions: ExtensionPermissionInfo[];
}
@ -789,22 +779,6 @@ export interface ExtensionsPayload {
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 {
name: string;
display_name: string;

View File

@ -87,7 +87,7 @@ describe("webui API helpers", () => {
await fetchExtensions("tok");
await runExtensionAction("tok", "install", {
source: "本地扩展",
kind: "npm",
kind: "local",
});
expect(fetch).toHaveBeenNthCalledWith(
@ -105,7 +105,7 @@ describe("webui API helpers", () => {
JSON.parse(
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 () => {

View File

@ -361,11 +361,9 @@ describe("App layout", () => {
id: "nanobot.shell",
name: "Shell",
version: "1",
runtime: "python",
description: "Run shell commands.",
homepage: "",
license: "",
scope: "builtin",
location: null,
enabled: true,
trusted: true,
@ -376,7 +374,6 @@ describe("App layout", () => {
source_ref: "",
integrity: "",
installed_at: "",
contributions: [{ kind: "tool", name: "shell", description: "" }],
dependencies: [],
permissions: [],
}],
@ -391,7 +388,6 @@ describe("App layout", () => {
fireEvent.click(within(sidebar).getByRole("button", { name: "Extensions" }));
expect(await screen.findByRole("heading", { name: "Extensions" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Built in/ }));
expect(await screen.findByText("Shell")).toBeInTheDocument();
expect(within(sidebar).getByRole("button", { name: "Extensions" })).toHaveAttribute(
"aria-current",

View File

@ -18,28 +18,24 @@ function response(body: unknown): Response {
function extension(overrides: Partial<ExtensionInfo> = {}): ExtensionInfo {
return {
id: "sample.pi",
name: "Sample Pi",
id: "sample.tools",
name: "Sample Tools",
version: "1.0.0",
runtime: "pi",
description: "A compatible Pi extension.",
description: "Adds a small set of native tools.",
homepage: "",
license: "MIT",
scope: "user",
location: "/tmp/extensions/sample.pi",
location: "/tmp/extensions/sample.tools",
enabled: true,
trusted: false,
active: false,
requested_permissions: ["process.spawn"],
requested_permissions: ["network"],
granted_permissions: [],
source: "npm",
source_ref: "@sample/pi-extension",
integrity: "sha512-example",
source: "git",
source_ref: "https://example.com/sample-tools.git",
integrity: "sha256:example",
installed_at: "2026-07-26T00:00:00Z",
managed_by_store: true,
contributions: [{ kind: "tool", name: "sample", description: "" }],
dependencies: [],
permissions: [{ name: "process.spawn", reason: "Runs the extension host." }],
permissions: [{ name: "network", reason: "Fetch selected URLs." }],
...overrides,
};
}
@ -57,18 +53,18 @@ describe("ExtensionsView", () => {
vi.unstubAllGlobals();
});
it("requires permission grants before an extension can be trusted", async () => {
it("requires permission grants before trust", async () => {
let current = extension();
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, init });
if (url === "/api/extensions/permissions") {
current = extension({ granted_permissions: ["process.spawn"] });
current = extension({ granted_permissions: ["network"] });
}
if (url === "/api/extensions/trust") {
current = extension({
granted_permissions: ["process.spawn"],
granted_permissions: ["network"],
trusted: true,
active: true,
});
@ -79,110 +75,45 @@ describe("ExtensionsView", () => {
}));
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" });
expect(trust).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Grant permissions" }));
await waitFor(() => expect(trust).toBeEnabled());
fireEvent.click(trust);
await waitFor(() => {
expect(requests.some(({ url }) => url === "/api/extensions/trust")).toBe(true);
});
const permissionRequest = requests.find(
({ url }) => url === "/api/extensions/permissions",
await waitFor(() =>
expect(requests.some(({ url }) => url === "/api/extensions/trust")).toBe(true),
);
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 }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
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"
? response({ extensions: [], diagnostics: [] })
: response({});
}));
renderView();
fireEvent.click(screen.getByRole("button", { name: "Discover" }));
fireEvent.click(await screen.findByRole("button", {
name: "Install @sample/pi-extension",
}));
await waitFor(() => {
expect(requests.some(({ url }) => url === "/api/extensions/install")).toBe(true);
fireEvent.change(screen.getByPlaceholderText("https://github.com/acme/extension.git"), {
target: { value: "https://example.com/sample-tools.git" },
});
const installRequest = requests.find(({ url }) => url === "/api/extensions/install");
const encoded = new Headers(installRequest?.init?.headers).get(
fireEvent.click(screen.getByRole("button", { name: "Install" }));
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",
);
expect(JSON.parse(decodeURIComponent(encoded ?? ""))).toEqual({
source: "@sample/pi-extension",
kind: "npm",
source: "https://example.com/sample-tools.git",
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();
});
});