From bed0db49221c3f0f974d0ab43511dbdb98692a2b Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:37:05 +0800 Subject: [PATCH] refactor(extensions): simplify the native extension platform --- docs/README.md | 3 +- docs/cli-reference.md | 19 +- docs/configuration.md | 37 +- docs/extension-authoring.md | 301 ++------ docs/extension-system.md | 167 ----- docs/extensions.md | 240 ++----- docs/webui.md | 3 +- nanobot/agent/tools/loader.py | 18 +- nanobot/cli/commands.py | 10 +- nanobot/cli/extensions.py | 41 +- nanobot/command/router.py | 7 - nanobot/config/schema.py | 25 +- nanobot/extensions/__init__.py | 86 +-- nanobot/extensions/catalog.py | 96 +-- nanobot/extensions/codec.py | 187 +---- nanobot/extensions/compatibility.py | 221 ------ nanobot/extensions/discovery.py | 8 - nanobot/extensions/host.py | 8 - nanobot/extensions/manifest.py | 210 ++---- nanobot/extensions/market.py | 104 --- nanobot/extensions/native.py | 340 --------- nanobot/extensions/node_host.py | 236 ------- nanobot/extensions/node_sidecar.mjs | 426 ----------- nanobot/extensions/package_adapter.py | 222 ------ nanobot/extensions/preflight.py | 39 +- nanobot/extensions/protocol.py | 87 --- nanobot/extensions/registry.py | 318 +-------- nanobot/extensions/runtime.py | 222 +----- nanobot/extensions/service.py | 80 +-- nanobot/extensions/store.py | 334 ++------- nanobot/nanobot.py | 3 +- nanobot/webui/extensions_routes.py | 31 +- nanobot/webui/ws_http.py | 1 - pyproject.toml | 1 - tests/cli/test_commands.py | 65 +- tests/cli/test_extensions.py | 21 +- tests/config/test_extensions_config.py | 30 +- tests/extensions/test_catalog.py | 57 +- tests/extensions/test_codec.py | 32 +- tests/extensions/test_discovery.py | 28 +- tests/extensions/test_host.py | 5 +- tests/extensions/test_manifest.py | 65 +- tests/extensions/test_market.py | 70 -- tests/extensions/test_native.py | 151 ---- tests/extensions/test_node_compatibility.py | 342 --------- tests/extensions/test_package_adapter.py | 77 -- tests/extensions/test_preflight.py | 89 +-- tests/extensions/test_protocol.py | 39 -- tests/extensions/test_registry.py | 308 +------- tests/extensions/test_runtime.py | 419 +++-------- tests/extensions/test_service.py | 136 +--- tests/extensions/test_store.py | 661 +++--------------- tests/webui/test_extensions_routes.py | 65 +- .../extensions/ExtensionDetailSheet.tsx | 153 ++-- .../extensions/ExtensionsCatalog.tsx | 290 ++------ .../components/extensions/extension-ui.tsx | 45 +- webui/src/i18n/locales/en/common.json | 38 +- webui/src/i18n/locales/es/common.json | 40 +- webui/src/i18n/locales/fr/common.json | 48 +- webui/src/i18n/locales/id/common.json | 42 +- webui/src/i18n/locales/ja/common.json | 40 +- webui/src/i18n/locales/ko/common.json | 48 +- webui/src/i18n/locales/pt-BR/common.json | 44 +- webui/src/i18n/locales/vi/common.json | 44 +- webui/src/i18n/locales/zh-CN/common.json | 38 +- webui/src/i18n/locales/zh-TW/common.json | 50 +- webui/src/lib/api.ts | 16 - webui/src/lib/types.ts | 26 - webui/src/tests/api.test.ts | 4 +- webui/src/tests/app-layout.test.tsx | 4 - webui/src/tests/extensions-view.test.tsx | 123 +--- 71 files changed, 1079 insertions(+), 6805 deletions(-) delete mode 100644 docs/extension-system.md delete mode 100644 nanobot/extensions/compatibility.py delete mode 100644 nanobot/extensions/market.py delete mode 100644 nanobot/extensions/native.py delete mode 100644 nanobot/extensions/node_host.py delete mode 100644 nanobot/extensions/node_sidecar.mjs delete mode 100644 nanobot/extensions/package_adapter.py delete mode 100644 nanobot/extensions/protocol.py delete mode 100644 tests/extensions/test_market.py delete mode 100644 tests/extensions/test_native.py delete mode 100644 tests/extensions/test_node_compatibility.py delete mode 100644 tests/extensions/test_package_adapter.py delete mode 100644 tests/extensions/test_protocol.py diff --git a/docs/README.md b/docs/README.md index a4d825f90..a2ce9e1b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2429fb77f..e119445c5 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 "message"` | Created first with `/trigger ` 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 ` | Used by channels such as WhatsApp and WeChat | | Log in to OAuth model providers | `nanobot provider login ` | 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 ` | Show contributions, dependencies, requested permissions, and diagnostics | -| `nanobot extensions search [query]` | Search compatible packages on npm | -| `nanobot extensions search [query] --ecosystem ` | Filter to `nanobot`, `pi`, or `openclaw` | -| `nanobot extensions install ` | Install an npm package as untrusted | +| `nanobot extensions inspect ` | Show identity, dependencies, requested permissions, and diagnostics | | `nanobot extensions install --kind git [--ref ]` | Install from a Git branch, tag, or commit | | `nanobot extensions install --kind local` | Install from a local package directory | | `nanobot extensions permissions [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 diff --git a/docs/configuration.md b/docs/configuration.md index f545c0a3a..a0d0576d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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.` | 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..enabled` | `true` | Per-extension activation switch | -| `extensions.entries..trusted` | `false` | Approve executing that extension's code | -| `extensions.entries..permissions` | `[]` | Exact host permissions granted to the extension | -| `extensions.entries..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. diff --git a/docs/extension-authoring.md b/docs/extension-authoring.md index e16584606..70bf267aa 100644 --- a/docs/extension-authoring.md +++ b/docs/extension-authoring.md @@ -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. diff --git a/docs/extension-system.md b/docs/extension-system.md deleted file mode 100644 index 7e0a6a898..000000000 --- a/docs/extension-system.md +++ /dev/null @@ -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. diff --git a/docs/extensions.md b/docs/extensions.md index 5d5318806..d9c474554 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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 | `/.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 ``` -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 -nanobot extensions uninstall +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. diff --git a/docs/webui.md b/docs/webui.md index c2e457db9..28d344053 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -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. diff --git a/nanobot/agent/tools/loader.py b/nanobot/agent/tools/loader.py index bcda792f5..fb420562e 100644 --- a/nanobot/agent/tools/loader.py +++ b/nanobot/agent/tools/loader.py @@ -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) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5295d4acf..fc173d7cf 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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() diff --git a/nanobot/cli/extensions.py b/nanobot/cli/extensions.py index d4624d767..badfb17e4 100644 --- a/nanobot/cli/extensions.py +++ b/nanobot/cli/extensions.py @@ -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.""" diff --git a/nanobot/command/router.py b/nanobot/command/router.py index 7b3b97b8f..1df4c1657 100644 --- a/nanobot/command/router.py +++ b/nanobot/command/router.py @@ -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)) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index d107a08a6..d85f3912b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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): diff --git a/nanobot/extensions/__init__.py b/nanobot/extensions/__init__.py index c8d96f15d..025a3e9d2 100644 --- a/nanobot/extensions/__init__.py +++ b/nanobot/extensions/__init__.py @@ -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", ] diff --git a/nanobot/extensions/catalog.py b/nanobot/extensions/catalog.py index 8e301d55c..8fa83f646 100644 --- a/nanobot/extensions/catalog.py +++ b/nanobot/extensions/catalog.py @@ -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) - ), - ) diff --git a/nanobot/extensions/codec.py b/nanobot/extensions/codec.py index ad9879b0b..a6ba1b357 100644 --- a/nanobot/extensions/codec.py +++ b/nanobot/extensions/codec.py @@ -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) diff --git a/nanobot/extensions/compatibility.py b/nanobot/extensions/compatibility.py deleted file mode 100644 index a02d44110..000000000 --- a/nanobot/extensions/compatibility.py +++ /dev/null @@ -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) diff --git a/nanobot/extensions/discovery.py b/nanobot/extensions/discovery.py index a34cb47a5..261ba9afe 100644 --- a/nanobot/extensions/discovery.py +++ b/nanobot/extensions/discovery.py @@ -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: diff --git a/nanobot/extensions/host.py b/nanobot/extensions/host.py index 0281faa24..b8c21ce43 100644 --- a/nanobot/extensions/host.py +++ b/nanobot/extensions/host.py @@ -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) diff --git a/nanobot/extensions/manifest.py b/nanobot/extensions/manifest.py index 44ee377d4..e2ac581bf 100644 --- a/nanobot/extensions/manifest.py +++ b/nanobot/extensions/manifest.py @@ -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__}") diff --git a/nanobot/extensions/market.py b/nanobot/extensions/market.py deleted file mode 100644 index a94d020c2..000000000 --- a/nanobot/extensions/market.py +++ /dev/null @@ -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 ""), - ) diff --git a/nanobot/extensions/native.py b/nanobot/extensions/native.py deleted file mode 100644 index 4e18286d5..000000000 --- a/nanobot/extensions/native.py +++ /dev/null @@ -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" diff --git a/nanobot/extensions/node_host.py b/nanobot/extensions/node_host.py deleted file mode 100644 index 5a3b01947..000000000 --- a/nanobot/extensions/node_host.py +++ /dev/null @@ -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() diff --git a/nanobot/extensions/node_sidecar.mjs b/nanobot/extensions/node_sidecar.mjs deleted file mode 100644 index 48a102130..000000000 --- a/nanobot/extensions/node_sidecar.mjs +++ /dev/null @@ -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`, - ); - } - }); -}); diff --git a/nanobot/extensions/package_adapter.py b/nanobot/extensions/package_adapter.py deleted file mode 100644 index fc164521f..000000000 --- a/nanobot/extensions/package_adapter.py +++ /dev/null @@ -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) diff --git a/nanobot/extensions/preflight.py b/nanobot/extensions/preflight.py index 53e8b5580..7b2e4dbe0 100644 --- a/nanobot/extensions/preflight.py +++ b/nanobot/extensions/preflight.py @@ -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 diff --git a/nanobot/extensions/protocol.py b/nanobot/extensions/protocol.py deleted file mode 100644 index c95c6316a..000000000 --- a/nanobot/extensions/protocol.py +++ /dev/null @@ -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), - ) diff --git a/nanobot/extensions/registry.py b/nanobot/extensions/registry.py index 55a1f930e..17e1a1f4e 100644 --- a/nanobot/extensions/registry.py +++ b/nanobot/extensions/registry.py @@ -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)) diff --git a/nanobot/extensions/runtime.py b/nanobot/extensions/runtime.py index 7b4d86267..a3dce8936 100644 --- a/nanobot/extensions/runtime.py +++ b/nanobot/extensions/runtime.py @@ -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) diff --git a/nanobot/extensions/service.py b/nanobot/extensions/service.py index caf19e6c6..eb964c7a4 100644 --- a/nanobot/extensions/service.py +++ b/nanobot/extensions/service.py @@ -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") diff --git a/nanobot/extensions/store.py b/nanobot/extensions/store.py index 5a30b2cee..d55b95edd 100644 --- a/nanobot/extensions/store.py +++ b/nanobot/extensions/store.py @@ -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) diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 6b88e22d0..14316d4e4 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -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 diff --git a/nanobot/webui/extensions_routes.py b/nanobot/webui/extensions_routes.py index b45a03d5f..91618f426 100644 --- a/nanobot/webui/extensions_routes.py +++ b/nanobot/webui/extensions_routes.py @@ -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() diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 332ce0d49..68ceb36f9 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index f37757e0a..da5dede45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 81125e48a..c105f9f7b 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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, diff --git a/tests/cli/test_extensions.py b/tests/cli/test_extensions.py index 397769efa..778b44265 100644 --- a/tests/cli/test_extensions.py +++ b/tests/cli/test_extensions.py @@ -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"})), diff --git a/tests/config/test_extensions_config.py b/tests/config/test_extensions_config.py index b47b5e3cd..1c2f5b094 100644 --- a/tests/config/test_extensions_config.py +++ b/tests/config/test_extensions_config.py @@ -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 diff --git a/tests/extensions/test_catalog.py b/tests/extensions/test_catalog.py index 776843a9c..66640b5fc 100644 --- a/tests/extensions/test_catalog.py +++ b/tests/extensions/test_catalog.py @@ -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 == () diff --git a/tests/extensions/test_codec.py b/tests/extensions/test_codec.py index fc67169ce..097168e70 100644 --- a/tests/extensions/test_codec.py +++ b/tests/extensions/test_codec.py @@ -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"}], } ) diff --git a/tests/extensions/test_discovery.py b/tests/extensions/test_discovery.py index 7a49ab766..dd7e0ddec 100644 --- a/tests/extensions/test_discovery.py +++ b/tests/extensions/test_discovery.py @@ -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), - ] diff --git a/tests/extensions/test_host.py b/tests/extensions/test_host.py index 920c2d294..a3dd54dc9 100644 --- a/tests/extensions/test_host.py +++ b/tests/extensions/test_host.py @@ -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 diff --git a/tests/extensions/test_manifest.py b/tests/extensions/test_manifest.py index c3d4dc708..350d9cb5f 100644 --- a/tests/extensions/test_manifest.py +++ b/tests/extensions/test_manifest.py @@ -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), ) diff --git a/tests/extensions/test_market.py b/tests/extensions/test_market.py deleted file mode 100644 index 5884bad5c..000000000 --- a/tests/extensions/test_market.py +++ /dev/null @@ -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") diff --git a/tests/extensions/test_native.py b/tests/extensions/test_native.py deleted file mode 100644 index dff379265..000000000 --- a/tests/extensions/test_native.py +++ /dev/null @@ -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"), - ] diff --git a/tests/extensions/test_node_compatibility.py b/tests/extensions/test_node_compatibility.py deleted file mode 100644 index c1b23002f..000000000 --- a/tests/extensions/test_node_compatibility.py +++ /dev/null @@ -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() diff --git a/tests/extensions/test_package_adapter.py b/tests/extensions/test_package_adapter.py deleted file mode 100644 index bd5b7da7b..000000000 --- a/tests/extensions/test_package_adapter.py +++ /dev/null @@ -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" diff --git a/tests/extensions/test_preflight.py b/tests/extensions/test_preflight.py index 57e4f36ee..d409d8e5d 100644 --- a/tests/extensions/test_preflight.py +++ b/tests/extensions/test_preflight.py @@ -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 == () diff --git a/tests/extensions/test_protocol.py b/tests/extensions/test_protocol.py deleted file mode 100644 index bddc969b6..000000000 --- a/tests/extensions/test_protocol.py +++ /dev/null @@ -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"}, - } - ) diff --git a/tests/extensions/test_registry.py b/tests/extensions/test_registry.py index 08e250fd8..14576a837 100644 --- a/tests/extensions/test_registry.py +++ b/tests/extensions/test_registry.py @@ -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")) diff --git a/tests/extensions/test_runtime.py b/tests/extensions/test_runtime.py index f498159ae..ca6eadb28 100644 --- a/tests/extensions/test_runtime.py +++ b/tests/extensions/test_runtime.py @@ -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() diff --git a/tests/extensions/test_service.py b/tests/extensions/test_service.py index 53a468786..df96fc30d 100644 --- a/tests/extensions/test_service.py +++ b/tests/extensions/test_service.py @@ -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"] == [] diff --git a/tests/extensions/test_store.py b/tests/extensions/test_store.py index 4bd420bc4..c2a140815 100644 --- a/tests/extensions/test_store.py +++ b/tests/extensions/test_store.py @@ -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" diff --git a/tests/webui/test_extensions_routes.py b/tests/webui/test_extensions_routes.py index 476a7e346..31ac037dc 100644 --- a/tests/webui/test_extensions_routes.py +++ b/tests/webui/test_extensions_routes.py @@ -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 diff --git a/webui/src/components/extensions/ExtensionDetailSheet.tsx b/webui/src/components/extensions/ExtensionDetailSheet.tsx index c55ef324f..d527ebc7c 100644 --- a/webui/src/components/extensions/ExtensionDetailSheet.tsx +++ b/webui/src/components/extensions/ExtensionDetailSheet.tsx @@ -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({ >
- +
{extension.name} @@ -76,7 +73,6 @@ export function ExtensionDetailSheet({ {extension.description || extension.id}
- {extension.version}
@@ -91,10 +87,6 @@ export function ExtensionDetailSheet({ label={t("extensions.details.source")} value={extension.source} /> - - ({ - name: item.name, - meta: item.kind, - }))} - /> ({ @@ -138,15 +123,11 @@ export function ExtensionDetailSheet({ >
- {permission.name === "runtime.node" - ? t("extensions.knownPermissions.runtimeNode.label") - : permission.name} + {permission.name}
{permission.reason ? (

- {permission.name === "runtime.node" - ? t("extensions.knownPermissions.runtimeNode.reason") - : permission.reason} + {permission.reason}

) : null}
@@ -164,24 +145,22 @@ export function ExtensionDetailSheet({
))} - {extension.managed_by_store ? ( - - ) : null} +
) : (

@@ -212,56 +191,50 @@ export function ExtensionDetailSheet({

- {extension.managed_by_store ? ( -
- - - -
- ) : configManaged ? ( -
- {t("extensions.configManaged")} -
- ) : null} +
+ + + +
diff --git a/webui/src/components/extensions/ExtensionsCatalog.tsx b/webui/src/components/extensions/ExtensionsCatalog.tsx index 4aaf064f8..90e8ff6a6 100644 --- a/webui/src/components/extensions/ExtensionsCatalog.tsx +++ b/webui/src/components/extensions/ExtensionsCatalog.tsx @@ -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("installed"); - const [ecosystem, setEcosystem] = useState("all"); const [query, setQuery] = useState(""); + const [source, setSource] = useState(""); + const [kind, setKind] = useState("git"); const [extensions, setExtensions] = useState([]); const [diagnostics, setDiagnostics] = useState([]); - const [packages, setPackages] = useState([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); - const [searching, setSearching] = useState(false); const [busy, setBusy] = useState(null); const [error, setError] = useState(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 (
-
-
{ + event.preventDefault(); + const value = source.trim(); + if (!value) return; + void mutate("install", { source: value, kind }, "install"); + }} + > + + 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" + /> + + @@ -191,29 +144,12 @@ export function ExtensionsCatalog() {
) : null} - {tab === "discover" ? ( - - void mutate( - "install", - { source: item.name, kind: "npm" }, - `install:${item.name}`, - ) - } - /> - ) : ( - - )} + void; -}) { - const { t } = useTranslation(); - const items: ExtensionEcosystem[] = ["all", "nanobot", "pi", "openclaw"]; - return ( -
- {items.map((item) => ( - - ))} -
- ); -} - 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 ; if (!extensions.length) { - return ; + return ; } const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id)); return (
{extensions.map((extension, index) => ( @@ -314,80 +221,3 @@ function ExtensionList({
); } - -function MarketList({ - packages, - installedIds, - loading, - busy, - onInstall, -}: { - packages: ExtensionMarketPackage[]; - installedIds: Set; - loading: boolean; - busy: string | null; - onInstall: (item: ExtensionMarketPackage) => void; -}) { - const { t } = useTranslation(); - if (loading) return ; - if (!packages.length) return ; - return ( -
- {packages.map((item, index) => { - const installed = installedIds.has(item.name); - const actionKey = `install:${item.name}`; - return ( -
0 && "border-t border-border/40", - )} - > - -
-
-

- {item.name} -

- - - {item.version} - -
-

- {item.description} -

-
- -
- ); - })} -
- ); -} diff --git a/webui/src/components/extensions/extension-ui.tsx b/webui/src/components/extensions/extension-ui.tsx index 1834dcf6d..794bc44b6 100644 --- a/webui/src/components/extensions/extension-ui.tsx +++ b/webui/src/components/extensions/extension-ui.tsx @@ -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 (
- +
); } -export function RuntimeBadge({ runtime }: { runtime: string }) { - return ( - - {runtime} - - ); -} - 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), ), ); diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 78c329089..44b117604 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -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", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index e76acb81a..06ac3bace 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -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…" } } diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 53a7ec0f3..3c535d81a 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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 d’extension", "searchInstalled": "Rechercher des extensions", "empty": { - "installed": "Aucune extension externe installée.", - "discover": "Aucun paquet d’extension correspondant.", - "builtin": "Aucune capacité intégrée trouvée." + "installed": "Aucune extension installée." }, - "installed": "Installée", - "install": "Installer {{name}}", + "installKind": "Source d’installation", + "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 d’accueil", "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 l’extension ?", - "uninstallDescription": "Cette action supprime {{name}} et ses fichiers installés de nanobot.", + "uninstallTitle": "Désinstaller l’extension ?", + "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…" } } diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 101b4f663..b74c1a92d 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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" }, diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index e16f0895b..ee2336ba5 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -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": "停止中" diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index cfbebdac4..66393309b 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -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": "확장 로드 중…" } } diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index ae29b6120..1ad6e7096 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -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…" } } diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 240c354c2..a12e88068 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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" diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 49a623249..abaa4ac88 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -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": "停用", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 2aad5e581..9858bc7e1 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -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": "正在載入擴充功能…" } } diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 410a7e751..30072b3f6 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -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 { - const params = new URLSearchParams({ q: query, ecosystem }); - return request( - `${base}/api/extensions/market?${params}`, - token, - undefined, - API_READ_TIMEOUT_MS, - ); -} - export type ExtensionAction = | "install" | "enable" diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index ffb7af230..9a494a060 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -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; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 59ffd5420..bb9dfa300 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -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 () => { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 78f8860c9..e6c27f3f1 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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", diff --git a/webui/src/tests/extensions-view.test.tsx b/webui/src/tests/extensions-view.test.tsx index fc0e2604a..4161461a2 100644 --- a/webui/src/tests/extensions-view.test.tsx +++ b/webui/src/tests/extensions-view.test.tsx @@ -18,28 +18,24 @@ function response(body: unknown): Response { function extension(overrides: Partial = {}): 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(); - }); });