From 71a8d2fbee673b1f6879f2848fc4920eea1e0a7e Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 18:19:52 -0700 Subject: [PATCH 1/9] Add upstream plugin compatibility and CLI support --- .github/workflows/ci.yml | 24 +- compat/public-smoke.json | 7 +- docs/COMPATIBILITY.md | 39 +- docs/USER_GUIDE.md | 27 + docs/zh-CN/COMPATIBILITY.md | 2 +- src/OpenClaw.Agent/OpenClawToolExecutor.cs | 11 +- .../Plugins/BridgedPluginTool.cs | 4 +- .../Plugins/PluginBridgeProcess.cs | 35 +- src/OpenClaw.Agent/Plugins/PluginHost.cs | 98 ++- src/OpenClaw.Agent/Plugins/plugin-bridge.mjs | 449 +++++++++++++- src/OpenClaw.Cli/OpenClaw.Cli.csproj | 7 + src/OpenClaw.Cli/PluginCliCommands.cs | 318 ++++++++++ src/OpenClaw.Cli/PluginCommands.cs | 558 ++++++++++++++++-- src/OpenClaw.Cli/Program.cs | 5 +- src/OpenClaw.Core/Abstractions/ITool.cs | 9 + .../PublicCompatibilityCatalog.cs | 5 +- src/OpenClaw.Core/Models/OperatorApiModels.cs | 2 + src/OpenClaw.Core/Models/Session.cs | 2 + .../Plugins/PluginBundleDetector.cs | 262 ++++++++ .../Plugins/PluginCapabilityPolicy.cs | 1 + src/OpenClaw.Core/Plugins/PluginDiscovery.cs | 203 +++++-- src/OpenClaw.Core/Plugins/PluginModels.cs | 55 ++ .../Plugins/PluginPackageCompatibility.cs | 89 +++ src/OpenClaw.Core/Skills/SkillLoader.cs | 72 +++ src/OpenClaw.Dashboard/Models/PluginInfo.cs | 29 +- src/OpenClaw.Dashboard/Pages/Ops.razor | 65 +- ...tializationExtensions.CompositionStages.cs | 2 + src/OpenClaw.Gateway/PluginHealthService.cs | 9 + .../CompatibilityCommandsTests.cs | 6 +- .../PluginBridgeIntegrationTests.cs | 80 ++- src/OpenClaw.Tests/PluginCommandsTests.cs | 259 +++++++- src/OpenClaw.Tests/PluginTests.cs | 163 +++++ .../PublicCompatibilitySmokeTests.cs | 57 +- src/OpenClaw.Tests/SkillTests.cs | 39 ++ 34 files changed, 2817 insertions(+), 176 deletions(-) create mode 100644 src/OpenClaw.Cli/PluginCliCommands.cs create mode 100644 src/OpenClaw.Core/Plugins/PluginBundleDetector.cs create mode 100644 src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4aa5466a..28fadb6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,9 +328,31 @@ jobs: - name: Run Public Compatibility Smoke run: dotnet test --no-build -c Release --filter "Category=PublicSmoke" --verbosity normal --logger "trx;LogFileName=public-smoke.trx" src/OpenClaw.Tests + - name: Probe latest public plugin packages + id: latest_plugin_canary + continue-on-error: true + env: + OPENCLAW_LATEST_CANARY: "1" + run: dotnet test --no-build -c Release --filter "Category=LatestCanary" --verbosity normal --logger "trx;LogFileName=latest-plugin-canary.trx" src/OpenClaw.Tests + + - name: Report latest-package compatibility drift + if: always() + shell: bash + run: | + if [[ "${{ steps.latest_plugin_canary.outcome }}" == "success" ]]; then + echo "### Latest plugin compatibility canary: passing" >> "$GITHUB_STEP_SUMMARY" + echo "Current npm releases still match the pinned compatibility expectations." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning title=Latest plugin compatibility drift::One or more current npm releases no longer match the pinned compatibility expectations. Review the latest-plugin-canary artifact; the pinned release gate remains authoritative." + echo "### Latest plugin compatibility canary: drift detected" >> "$GITHUB_STEP_SUMMARY" + echo "Review the latest-plugin-canary test artifact. This signal is intentionally non-blocking; pinned public-smoke scenarios remain the release gate." >> "$GITHUB_STEP_SUMMARY" + fi + - name: Upload smoke results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: public-smoke-results - path: "**/public-smoke.trx" + path: | + **/public-smoke.trx + **/latest-plugin-canary.trx diff --git a/compat/public-smoke.json b/compat/public-smoke.json index 8a98bf1b..27b5cf05 100644 --- a/compat/public-smoke.json +++ b/compat/public-smoke.json @@ -62,15 +62,14 @@ }, { "id": "supermemory", - "category": "unsupported-surface-plugin", + "category": "cli-plugin", "kind": "npm-plugin", "spec": "@supermemory/openclaw-supermemory@2.0.2", "packageName": "@supermemory/openclaw-supermemory", "pluginId": "openclaw-supermemory", "installExtraPackages": ["jiti"], - "expectedStatus": "incompatible", - "configJson": "{}", - "expectedDiagnosticCodes": ["unsupported_cli_registration"] + "expectedStatus": "compatible", + "configJson": "{}" } ] } diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index b908c7be..b1914025 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -51,16 +51,24 @@ OpenClaw.NET keeps plugin compatibility explicit by runtime mode. The goal is to | Surface | Status | Notes | | --- | --- | --- | | `api.registerTool()` | Supported | Available in both `aot` and `jit`. Covered by hermetic bridge tests. | +| Tool `outputSchema` and structured `details` | Supported | Output schemas are exposed to the model tool contract and structured results remain JSON instead of being flattened to text. | | `api.registerService()` | Supported | Available in both `aot` and `jit`, including `start` / `stop` lifecycle coverage. | | `api.registerChannel()` | Supported with caveats | `jit` only. `aot` fails fast with `jit_mode_required`. | | `api.registerCommand()` | Supported with caveats | `jit` only. Registered as dynamic chat commands. | | `api.on(...)` | Supported with caveats | `jit` only. `tool:before` / `tool:after` hooks are bridged with timeout protections. | | `api.registerProvider()` | Supported with caveats | `jit` only. Plugin-provided LLMs are wired through the dynamic provider seam. | | `OpenClaw.Providers.MicrosoftExtensionsAI` | Supported with caveats | `jit` only through native dynamic plugins. Use this to bring an arbitrary `IChatClient`; AOT users should use built-in providers or OpenAI-compatible endpoints. | -| Standalone `.js`, `.mjs`, `.ts` in `.openclaw/extensions` | Supported with caveats | `.ts` requires local `jiti`. | -| Manifest/package discovery via `Plugins:Load:Paths` | Supported | Includes `openclaw.plugin.json` and `package.json` `openclaw.extensions`. | -| `openclaw plugins install --dry-run` trust inspection | Supported | Prints trust level, declared surface, diagnostics, and blocks install when compatibility errors are present. | +| Standalone `.js`, `.mjs`, `.cjs`, `.ts` in `.openclaw/extensions` | Supported with caveats | CLI installs add local `jiti` when a TypeScript entry needs it; manually configured TypeScript paths still require `jiti` in their dependency tree. | +| Manifest/package discovery via `Plugins:Load:Paths` | Supported | Includes `openclaw.plugin.json`; prefers `package.json` `openclaw.runtimeExtensions` and retains `openclaw.extensions` compatibility. Built JavaScript entries are preferred over TypeScript source. | +| Package compatibility metadata | Supported | `openclaw.compat.pluginApi`, `compat.minGatewayVersion`, `install.minHostVersion`, and `install.expectedIntegrity` are discovered. Unsupported version floors fail before load. | +| `openclaw plugins install --dry-run` trust inspection | Supported | Prints manifest/package/static compatibility rather than claiming runtime verification. Known unsupported registration APIs block installation. | +| Staged plugin installation | Supported | Dependencies and any required `jiti` runtime are installed in a sibling staging directory, the bridge initializes the staged plugin, and only then is the existing install replaced atomically. A failed update preserves the previous plugin. | +| Codex compatible bundles | Supported with caveats | Detects `.codex-plugin/plugin.json`; maps `skills/` into plugin skills. Hook packs, MCP metadata, and app metadata are reported as detected-only because OpenClaw.NET does not yet execute those bundle surfaces. | +| Claude compatible bundles | Supported with caveats | Detects `.claude-plugin/plugin.json` and manifestless Claude layouts. Maps `skills/` and Markdown `commands/`; agents, hook automation, MCP, LSP, settings, and output styles are reported as detected-only. | +| Cursor compatible bundles | Supported with caveats | Detects `.cursor-plugin/plugin.json` and `.cursor/` layouts. Maps `skills/` and `.cursor/commands/`; agents, rules, hooks, and MCP metadata are reported as detected-only. | +| Bundle trust boundary | Supported | Native plugin detection has precedence. Bundle JavaScript is never loaded as a native plugin, bundle installs do not run npm dependency/lifecycle scripts, and all mapped content paths remain constrained to the bundle root. | | Plugin config validation | Supported with caveats | Validated against the documented JSON Schema subset below before startup. | +| `api.registerCli()` | Supported with caveats | Root commands are discovered lazily and executed in a one-shot Node bridge with inherited terminal streams. Built-in CLI roots win; names are validated and duplicate plugin roots fail closed. The bridge implements the common Commander registration subset used by upstream plugins. | | Plugin diagnostics in `/doctor` | Supported | Discovery, load, config, and compatibility failures are reported explicitly. | | Plugin bridge runtime budgets | Supported | `OpenClaw:Plugins:RuntimeBudget` can auto-quarantine bridge plugins by restart count, working set, and compatibility error thresholds. | | `Plugins:Transport:Mode=stdio` | Supported | JSON-RPC over child process stdin/stdout. | @@ -71,12 +79,11 @@ OpenClaw.NET keeps plugin compatibility explicit by runtime mode. The goal is to ## Unsupported Today -These APIs are not bridged. If a plugin uses them, initialization fails fast with structured diagnostics instead of loading partially: +This API is not bridged. Static install inspection and bridge initialization fail with structured diagnostics instead of loading the plugin partially: | Surface | Status | Failure code | | --- | --- | --- | | `api.registerGatewayMethod()` | Not supported | `unsupported_gateway_method` | -| `api.registerCli()` | Not supported | `unsupported_cli_registration` | ## Canvas and A2UI Compatibility @@ -112,9 +119,9 @@ The messaging channels below now share the same operator model for DM policy, re ## TypeScript Requirements -TypeScript plugins are supported when `jiti` is available in the plugin dependency tree. +TypeScript plugins are supported when `jiti` is available in the plugin dependency tree. `openclaw plugins install` installs it into the staged plugin automatically when necessary. -Install it in the plugin directory or its parent workspace: +For plugins loaded directly from manually configured paths, install it in the plugin directory or its parent workspace: ```bash npm install jiti @@ -174,11 +181,13 @@ The catalog is scenario-based rather than marketing-based: - negative scenarios show pinned configs or packages expected to fail with explicit diagnostics - each entry includes install guidance, required config examples where relevant, and expected tools, skills, or diagnostics +Pinned scenarios remain the release gate. The scheduled/manual `LatestCanary` lane separately installs the current npm release for each distinct catalog package and compares it with the pinned expectation. That moving probe is intentionally non-blocking: drift produces a workflow warning, test artifact, resolved package version, and diagnostics without making a known-good pinned release fail. + ## Known Limitations - Public-bind setup defaults intentionally disable bridge plugins and shell until you opt into the relevant trust settings. - JIT-only capabilities remain JIT-only; `aot` does not attempt partial dynamic fallback. -- TypeScript plugin loading depends on `jiti`; OpenClaw.NET does not bundle a TypeScript runtime automatically. +- TypeScript plugin loading depends on `jiti`; CLI installs provision it when needed, while plugins loaded directly from configured paths must provide it locally. - Out-of-root plugin entry files, manifests, and native dynamic assemblies fail explicitly instead of being resolved elsewhere on disk. - Tool-name collisions are deterministic: the first tool wins, later duplicates are skipped and reported. @@ -195,6 +204,18 @@ The compatibility claim is backed by automated validation in `src/OpenClaw.Tests - plugin-packaged skills - config validation, including `oneOf` - unsupported-surface failure modes + - bridge restart readiness across stdio, socket, and hybrid transports + - structured output-schema and result preservation +- `PluginCommandsTests.cs` + - dry-run compatibility status and unsupported-surface rejection + - package API-floor rejection + - isolated runtime inspection diagnostics + - compatible bundle inspection and mapped/detected-only surface reporting +- `PluginTests.cs` / `SkillTests.cs` + - Codex, Claude, and Cursor bundle detection + - native-plugin precedence over dual-format bundle markers + - bundle loading without bridge execution + - Claude/Cursor Markdown command mapping into user-invocable skills - `NativeDynamicPluginHostTests.cs` - JIT-mode in-process plugin loading - command and service lifecycle @@ -207,4 +228,4 @@ The compatibility claim is backed by automated validation in `src/OpenClaw.Tests - pinned config-schema rejection case - pinned unsupported-surface plugin case -The nightly/manual CI smoke lane runs those public packages with `OPENCLAW_PUBLIC_SMOKE=1`. +The nightly/manual CI smoke lane runs pinned public packages with `OPENCLAW_PUBLIC_SMOKE=1`, then runs the non-blocking moving probe with `OPENCLAW_LATEST_CANARY=1`. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 68e9de52..0f8030b0 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -998,6 +998,33 @@ OpenClaw.NET is designed to be compatible with the original [OpenClaw](https://g OpenClaw.NET spawns a Node.js bridge process to run upstream plugins over JSON-RPC. For runtime requirements, the compatibility matrix, and install paths, see the **Bridged Tools** section of the [Tool Guide](TOOLS_GUIDE.md). For the full supported-feature breakdown, see the [Compatibility Guide](COMPATIBILITY.md). +Inspect a package without changing the workspace: + +```bash +openclaw plugins install --dry-run +``` + +Dry-run validates the manifest, package compatibility metadata, entry containment, config schema, skill paths, and known unsupported registration APIs. It reports `manifest-valid` only for that pre-execution evidence. A real install then installs dependencies and initializes the plugin in an isolated bridge process inside a staging directory before atomically replacing any existing copy, so failed updates preserve the working plugin. + +Codex, Claude, and Cursor compatible bundles use the same install path. They appear as `Format: bundle` with a `Bundle format` value, but they keep a narrower execution boundary: OpenClaw.NET maps bundle skill roots and Claude/Cursor Markdown command roots without executing arbitrary bundle modules. Other detected surfaces are shown as `bundle_capability_detected_only` diagnostics so users can distinguish reusable content from runtime gaps. + +Inspect an installed bundle or native plugin directly: + +```bash +openclaw plugins inspect +openclaw plugins inspect --runtime +``` + +For bundles, `--runtime` confirms that no arbitrary module was executed. For native plugins, it initializes the bridge and reports registered tool, channel, chat-command, root-CLI-command, and provider counts. + +Upstream plugins can also add root commands with `api.registerCli()`. OpenClaw.NET discovers those commands only after a built-in root does not match, then executes the selected plugin in a fresh Node process with the terminal attached: + +```bash +openclaw [arguments] +``` + +Built-in command names always take precedence. Disabled or quarantined plugins are not eligible, duplicate plugin command roots fail closed, and CLI discovery honors `OPENCLAW_CONFIG_PATH`, `OPENCLAW_WORKSPACE`, plugin allow/deny settings, per-plugin enablement, slots, package compatibility metadata, and plugin config validation. The bridge supports the common Commander-style `command`, `description`, `argument`, `option`, `requiredOption`, and `action` registration flow. Plugin commands that directly edit upstream-specific config files remain responsible for whether those files match the OpenClaw.NET deployment configuration. + --- ## Breaking Changes diff --git a/docs/zh-CN/COMPATIBILITY.md b/docs/zh-CN/COMPATIBILITY.md index cf19eedf..4b7d8430 100644 --- a/docs/zh-CN/COMPATIBILITY.md +++ b/docs/zh-CN/COMPATIBILITY.md @@ -33,6 +33,7 @@ | `api.registerCommand()` | jit only | | `api.on(...)` | jit only | | `api.registerProvider()` | jit only | +| `api.registerCli()` | Supported(惰性发现根命令;通过一次性 Node 桥接进程执行) | | 独立 `.js`/`.mjs`/`.ts` | `.ts` 需 `jiti` | | 原生动态 .NET 插件 | jit only | | 上游 TypeScript `payment` 插件 | Not supported(使用原生支付运行时) | @@ -42,7 +43,6 @@ | 接口 | 失败码 | | --- | --- | | `api.registerGatewayMethod()` | `unsupported_gateway_method` | -| `api.registerCli()` | `unsupported_cli_registration` | ## Canvas 和 A2UI 兼容性 diff --git a/src/OpenClaw.Agent/OpenClawToolExecutor.cs b/src/OpenClaw.Agent/OpenClawToolExecutor.cs index e4476ba6..831a4029 100644 --- a/src/OpenClaw.Agent/OpenClawToolExecutor.cs +++ b/src/OpenClaw.Agent/OpenClawToolExecutor.cs @@ -1242,11 +1242,20 @@ private static ValueTask InvokeToolAsync( internal static AIFunctionDeclaration CreateDeclaration(ITool tool) { using var doc = JsonDocument.Parse(tool.ParameterSchema); + JsonElement? returnSchema = null; + JsonDocument? returnSchemaDocument = null; + if (tool is IToolOutputSchema { OutputSchema: { Length: > 0 } outputSchema }) + { + returnSchemaDocument = JsonDocument.Parse(outputSchema); + returnSchema = returnSchemaDocument.RootElement.Clone(); + } + + using (returnSchemaDocument) return AIFunctionFactory.CreateDeclaration( tool.Name, tool.Description, doc.RootElement.Clone(), - returnJsonSchema: null); + returnJsonSchema: returnSchema); } private static string NormalizeApprovalToolName(string toolName) => diff --git a/src/OpenClaw.Agent/Plugins/BridgedPluginTool.cs b/src/OpenClaw.Agent/Plugins/BridgedPluginTool.cs index 6fba84e6..017b55af 100644 --- a/src/OpenClaw.Agent/Plugins/BridgedPluginTool.cs +++ b/src/OpenClaw.Agent/Plugins/BridgedPluginTool.cs @@ -7,7 +7,7 @@ namespace OpenClaw.Agent.Plugins; /// An ITool implementation that bridges to a tool registered by an OpenClaw /// TypeScript/JavaScript plugin running in a Node.js child process. /// -public sealed class BridgedPluginTool : ITool +public sealed class BridgedPluginTool : ITool, IToolOutputSchema { private readonly PluginBridgeProcess _bridge; private readonly string _pluginId; @@ -15,6 +15,7 @@ public sealed class BridgedPluginTool : ITool public string Name { get; } public string Description { get; } public string ParameterSchema { get; } + public string? OutputSchema { get; } /// Whether this tool is optional (opt-in only). public bool Optional { get; } @@ -29,6 +30,7 @@ public BridgedPluginTool( Name = registration.Name; Description = registration.Description; ParameterSchema = registration.Parameters.GetRawText(); + OutputSchema = registration.OutputSchema?.GetRawText(); Optional = registration.Optional; } diff --git a/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs b/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs index ec2b19b5..ebed3b1b 100644 --- a/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs +++ b/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs @@ -17,6 +17,7 @@ public sealed class PluginBridgeProcess : IAsyncDisposable { private Process? _process; private Task? _exitMonitor; + private volatile bool _initialized; private readonly string _bridgeScriptPath; private readonly ILogger _logger; private readonly SemaphoreSlim _lifecycleGate = new(1, 1); @@ -132,6 +133,12 @@ public async Task ExecuteToolAsync(string toolName, string argumentsJson if (response.Result is { } result && result.TryGetProperty("content", out var contentArray)) { + if (result.TryGetProperty("details", out var details) && + details.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) + { + return details.GetRawText(); + } + var sb = new StringBuilder(); foreach (var item in contentArray.EnumerateArray()) { @@ -205,7 +212,7 @@ await SendAndWaitAsync("shutdown", (JsonElement?)null, CancellationToken.None) private async Task EnsureProcessRunningAsync(CancellationToken ct) { - if (_process is not null && !_process.HasExited && _transport is not null) + if (_initialized && _process is not null && !_process.HasExited && _transport is not null) return; await RestartAsync(ct); @@ -225,7 +232,7 @@ private async Task RestartAsync(CancellationToken ct) if (_disposed) return; - if (_process is not null && !_process.HasExited && _transport is not null) + if (_initialized && _process is not null && !_process.HasExited && _transport is not null) return; var delay = TimeSpan.FromSeconds(1); @@ -270,6 +277,7 @@ private async Task RestartAsync(CancellationToken ct) private async Task InitializeProcessAsync(CancellationToken ct) { + _initialized = false; var (transport, runtimeTransport) = BridgeTransportFactory.Create(_transportConfig, _pluginId!, _logger, _runtimeRoot, _metrics); if (_notificationHandler is not null) transport.SetNotificationHandler(_notificationHandler); @@ -301,6 +309,7 @@ private async Task InitializeProcessAsync(CancellationToken ct) if (transport is HybridBridgeTransport hybrid) hybrid.UseSocketTransport(); + _initialized = true; return response; } catch @@ -410,9 +419,26 @@ private async Task MonitorProcessAsync(Process process) return; } - await DisposeTransportAsync(); + var shouldRestart = false; + await _lifecycleGate.WaitAsync(); + try + { + // A prior child can report its exit after a replacement has already + // initialized. Never let that stale monitor tear down the new transport. + if (!ReferenceEquals(_process, process)) + return; + + _initialized = false; + await DisposeTransportAsync(); + CleanupProcess(); + shouldRestart = !_disposed && !_intentionalShutdown; + } + finally + { + _lifecycleGate.Release(); + } - if (_disposed || _intentionalShutdown) + if (!shouldRestart) return; _logger.LogWarning("Plugin bridge process for '{PluginId}' exited unexpectedly. Restarting.", _pluginId ?? "unknown"); @@ -440,6 +466,7 @@ private async Task DisposeTransportAsync() private void CleanupProcess() { + _initialized = false; if (_process is null) return; diff --git a/src/OpenClaw.Agent/Plugins/PluginHost.cs b/src/OpenClaw.Agent/Plugins/PluginHost.cs index 9fb5e600..eb0846f4 100644 --- a/src/OpenClaw.Agent/Plugins/PluginHost.cs +++ b/src/OpenClaw.Agent/Plugins/PluginHost.cs @@ -131,8 +131,9 @@ public async Task> LoadAsync(string? workspacePath, Cancell { PluginId = plugin.Manifest.Id, SourcePath = plugin.RootPath, - EntryPath = plugin.EntryPath, - Origin = "bridge", + EntryPath = string.IsNullOrEmpty(plugin.EntryPath) ? null : plugin.EntryPath, + Origin = plugin.Format == PluginFormats.Bundle ? PluginFormats.Bundle : "bridge", + BundleFormat = plugin.BundleFormat, EffectiveRuntimeMode = _runtimeState.EffectiveModeName, Loaded = false, BlockedReason = message, @@ -162,8 +163,9 @@ public async Task> LoadAsync(string? workspacePath, Cancell { PluginId = plugin.Manifest.Id, SourcePath = plugin.RootPath, - EntryPath = plugin.EntryPath, - Origin = "bridge", + EntryPath = string.IsNullOrEmpty(plugin.EntryPath) ? null : plugin.EntryPath, + Origin = plugin.Format == PluginFormats.Bundle ? PluginFormats.Bundle : "bridge", + BundleFormat = plugin.BundleFormat, EffectiveRuntimeMode = _runtimeState.EffectiveModeName, Loaded = false, Error = ex.Message @@ -181,10 +183,19 @@ public async Task> LoadAsync(string? workspacePath, Cancell private async Task LoadPluginAsync(DiscoveredPlugin plugin, CancellationToken ct) { var id = plugin.Manifest.Id; + + if (plugin.Format == PluginFormats.Bundle) + { + LoadBundle(plugin); + return; + } + _logger.LogInformation("Loading plugin '{PluginId}' from {EntryPath}", id, plugin.EntryPath); - var configDiagnostics = PluginConfigValidator.Validate(plugin.Manifest, GetPluginConfig(id)); - if (configDiagnostics.Count > 0) + var configDiagnostics = PluginPackageCompatibility.Validate(plugin) + .Concat(PluginConfigValidator.Validate(plugin.Manifest, GetPluginConfig(id))) + .ToArray(); + if (configDiagnostics.Length > 0) { _reports.Add(new PluginLoadReport { @@ -195,9 +206,9 @@ private async Task LoadPluginAsync(DiscoveredPlugin plugin, CancellationToken ct EffectiveRuntimeMode = _runtimeState.EffectiveModeName, Loaded = false, Diagnostics = configDiagnostics.ToArray(), - Error = "Plugin config validation failed." + Error = "Plugin package or config compatibility validation failed." }); - _logger.LogError("Plugin '{PluginId}' failed config validation: {Errors}", + _logger.LogError("Plugin '{PluginId}' failed package/config compatibility validation: {Errors}", id, string.Join(" | ", configDiagnostics.Select(d => d.Message))); return; } @@ -388,6 +399,7 @@ private async Task LoadPluginAsync(DiscoveredPlugin plugin, CancellationToken ct ToolCount = registeredCount, ChannelCount = initResult.Channels.Length, CommandCount = initResult.Commands.Length, + CliCommandCount = initResult.CliCommands.Length, EventSubscriptionCount = initResult.EventSubscriptions.Length, ProviderCount = initResult.Providers.Length, SkillDirectories = skillDirs, @@ -395,6 +407,68 @@ private async Task LoadPluginAsync(DiscoveredPlugin plugin, CancellationToken ct }); } + private void LoadBundle(DiscoveredPlugin plugin) + { + var id = plugin.Manifest.Id; + var diagnostics = new List(); + var skillDirs = ResolveSkillDirectories(plugin, diagnostics).ToArray(); + foreach (var capability in plugin.BundleDetectedCapabilities) + { + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "warning", + Code = "bundle_capability_detected_only", + Message = $"Bundle capability '{capability}' was detected but has no OpenClaw.NET runtime mapping.", + Surface = capability, + Path = plugin.RootPath + }); + } + + if (plugin.BundleMappedCapabilities.Length == 0) + { + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "warning", + Code = "bundle_has_no_mapped_capabilities", + Message = $"{plugin.BundleFormat} bundle '{id}' was detected, but it contains no mapped skill or command content.", + Surface = "bundle", + Path = plugin.RootPath + }); + } + + var hasErrors = diagnostics.Any(static item => + string.Equals(item.Severity, "error", StringComparison.OrdinalIgnoreCase)); + if (!hasErrors) + { + foreach (var skillDir in skillDirs) + { + if (!_skillRoots.Contains(skillDir, StringComparer.Ordinal)) + _skillRoots.Add(skillDir); + } + } + + _reports.Add(new PluginLoadReport + { + PluginId = id, + SourcePath = plugin.RootPath, + EntryPath = null, + Origin = PluginFormats.Bundle, + BundleFormat = plugin.BundleFormat, + EffectiveRuntimeMode = _runtimeState.EffectiveModeName, + RequestedCapabilities = PluginCapabilityPolicy.Normalize(plugin.BundleMappedCapabilities), + Loaded = !hasErrors, + SkillDirectories = skillDirs, + Diagnostics = [.. diagnostics], + Error = hasErrors ? "Bundle content validation failed." : null + }); + + _logger.LogInformation( + "Loaded {BundleFormat} bundle '{PluginId}' with {SkillRootCount} mapped skill root(s).", + plugin.BundleFormat, + id, + skillDirs.Length); + } + private static string[] DetermineRequestedCapabilities(BridgeInitResult initResult, IReadOnlyCollection skillDirs) { var capabilities = new List(initResult.Capabilities); @@ -460,6 +534,14 @@ private IEnumerable ResolveSkillDirectories(DiscoveredPlugin plugin, ICo if (!Directory.Exists(resolved)) { _logger.LogWarning("Plugin '{PluginId}' declared missing skill directory {Path}", plugin.Manifest.Id, resolved); + diagnostics?.Add(new PluginCompatibilityDiagnostic + { + Severity = "error", + Code = "skill_directory_missing", + Message = $"Plugin '{plugin.Manifest.Id}' declared a skill directory that does not exist.", + Surface = "skills", + Path = resolved + }); continue; } diff --git a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs index fc615158..e66826c1 100644 --- a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs +++ b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs @@ -14,8 +14,15 @@ import { existsSync } from "node:fs"; import { createConnection } from "node:net"; import { join, dirname } from "node:path"; -console.log = console.error; -console.info = console.error; +const standaloneCliMode = process.argv[2] === "--cli-run"; +const standaloneCliDescribeMode = process.argv[2] === "--cli-describe"; + +// Runtime bridge traffic owns stdout. Standalone CLI execution inherits stdout +// so plugin commands can render output and use an interactive terminal. +if (!standaloneCliMode) { + console.log = console.error; + console.info = console.error; +} /** @type {Map} */ const registeredTools = new Map(); @@ -29,6 +36,12 @@ const registeredChannels = new Map(); /** @type {Map} */ const registeredCommands = new Map(); +/** @type {Array<{ factory: Function, options: any }>} */ +const registeredCliFactories = []; + +/** @type {CliCommandNode | null} */ +let registeredCliProgram = null; + /** @type {Map} */ const registeredEventHandlers = new Map(); @@ -126,6 +139,8 @@ function resetState() { registeredServices.clear(); registeredChannels.clear(); registeredCommands.clear(); + registeredCliFactories.length = 0; + registeredCliProgram = null; registeredEventHandlers.clear(); registeredProviders.clear(); compatibilityDiagnostics = []; @@ -187,6 +202,7 @@ function collectCapabilities() { if (registeredServices.size > 0) capabilities.push("services"); if (registeredChannels.size > 0) capabilities.push("channels"); if (registeredCommands.size > 0) capabilities.push("commands"); + if (registeredCliFactories.length > 0) capabilities.push("cli"); if (registeredProviders.size > 0) capabilities.push("providers"); if (registeredEventHandlers.size > 0) capabilities.push("hooks"); @@ -202,9 +218,10 @@ function getParam(params, name) { return params[name] ?? params[pascal]; } -function createPluginApi(pluginId, pluginConfig, logger) { +function createPluginApi(pluginId, pluginConfig, logger, registrationMode = "full") { return { pluginId, + registrationMode, config: pluginConfig ?? {}, pluginConfig: pluginConfig ?? {}, logger, @@ -233,6 +250,7 @@ function createPluginApi(pluginId, pluginConfig, logger) { name, description: def.description ?? "", parameters: parameters ?? { type: "object", properties: {} }, + outputSchema: def.outputSchema ?? def.returnSchema ?? null, optional: opts?.optional ?? false, execute: def.execute, }); @@ -267,11 +285,15 @@ function createPluginApi(pluginId, pluginConfig, logger) { addDiagnostic("unsupported_gateway_method", message, "registerGatewayMethod", name); }, - registerCli(_factory, _opts) { - const message = - `Plugin "${pluginId}" tried to register a CLI command, but CLI extensions are not supported by OpenClaw.NET.`; - logger.error(message); - addDiagnostic("unsupported_cli_registration", message, "registerCli"); + registerCli(factory, options) { + if (typeof factory !== "function") { + const message = `Plugin "${pluginId}" passed a non-function registrar to registerCli().`; + logger.error(message); + addDiagnostic("invalid_cli_registration", message, "registerCli"); + return; + } + + registeredCliFactories.push({ factory, options: options ?? {} }); }, registerCommand(def) { @@ -310,13 +332,358 @@ function createPluginApi(pluginId, pluginConfig, logger) { }; } +class CliCommandNode { + constructor(signature = "", parent = null) { + const tokens = String(signature).trim().split(/\s+/).filter(Boolean); + this._name = tokens.shift() ?? ""; + this.parent = parent; + this.commands = []; + this.options = []; + this._arguments = tokens.map(parseCliArgument); + this._description = ""; + this._aliases = []; + this._action = null; + this._parsedOptions = {}; + this._usage = ""; + } + + command(signature, description) { + const child = new CliCommandNode(signature, this); + if (typeof description === "string") child.description(description); + this.commands.push(child); + return child; + } + + addCommand(command) { + if (command && typeof command === "object") { + command.parent = this; + this.commands.push(command); + } + return this; + } + + description(value) { + if (value === undefined) return this._description; + this._description = sanitizeCliText(value); + return this; + } + + summary(value) { return this.description(value); } + name() { return this._name; } + alias(value) { this._aliases.push(String(value)); return this; } + aliases(values) { this._aliases.push(...(values ?? []).map(String)); return this; } + usage(value) { if (value === undefined) return this._usage; this._usage = String(value); return this; } + + argument(spec, description, defaultValue) { + const argument = parseCliArgument(spec); + argument.description = sanitizeCliText(description ?? ""); + argument.defaultValue = defaultValue; + this._arguments.push(argument); + return this; + } + + arguments(spec) { + for (const token of String(spec).trim().split(/\s+/).filter(Boolean)) { + this._arguments.push(parseCliArgument(token)); + } + return this; + } + + option(flags, description, defaultValue) { + this.options.push(parseCliOption(flags, description, defaultValue, false)); + return this; + } + + requiredOption(flags, description, defaultValue) { + this.options.push(parseCliOption(flags, description, defaultValue, true)); + return this; + } + + addOption(option) { + if (option && typeof option === "object") this.options.push(normalizeExternalCliOption(option)); + return this; + } + + action(handler) { this._action = handler; return this; } + opts() { return { ...this._parsedOptions }; } + optsWithGlobals() { return this.opts(); } + getOptionValue(name) { return this._parsedOptions[name]; } + setOptionValue(name, value) { this._parsedOptions[name] = value; return this; } + setOptionValueWithSource(name, value) { return this.setOptionValue(name, value); } + + // Commander configuration methods used by plugins during registration. The + // bridge owns parsing and help rendering, so these remain safe fluent no-ops. + allowUnknownOption() { return this; } + allowExcessArguments() { return this; } + passThroughOptions() { return this; } + enablePositionalOptions() { return this; } + showHelpAfterError() { return this; } + showSuggestionAfterError() { return this; } + configureHelp() { return this; } + configureOutput() { return this; } + helpOption() { return this; } + addHelpText() { return this; } + exitOverride() { return this; } + hook() { return this; } + version() { return this; } +} + +function parseCliArgument(spec) { + const text = String(spec ?? "").trim(); + const required = text.startsWith("<"); + const optional = text.startsWith("["); + const inner = required || optional ? text.slice(1, -1) : text; + const variadic = inner.endsWith("..."); + return { + name: variadic ? inner.slice(0, -3) : inner, + required, + variadic, + description: "", + defaultValue: undefined, + }; +} + +function parseCliOption(flags, description, defaultValue, requiredOption) { + const text = String(flags ?? ""); + const parts = text.split(/[ ,|]+/).filter(Boolean); + const long = parts.find((part) => part.startsWith("--")) ?? null; + const short = parts.find((part) => /^-[^-]/.test(part)) ?? null; + const valueToken = parts.find((part) => part.startsWith("<") || part.startsWith("[")); + const rawName = (long ?? short ?? "").replace(/^-+/, "").replace(/^no-/, ""); + return { + flags: text, + long: long?.split(/[<[\s]/, 1)[0] ?? null, + short: short?.split(/[<[\s]/, 1)[0] ?? null, + name: toCamelCase(rawName), + requiredValue: Boolean(valueToken?.startsWith("<")), + optionalValue: Boolean(valueToken?.startsWith("[")), + requiredOption, + negate: Boolean(long?.startsWith("--no-")), + defaultValue, + description: sanitizeCliText(description ?? ""), + }; +} + +function normalizeExternalCliOption(option) { + const normalized = parseCliOption( + option.flags ?? `${option.short ?? ""} ${option.long ?? ""}`, + option.description ?? "", + option.defaultValue, + option.mandatory === true, + ); + if (typeof option.attributeName === "function") normalized.name = option.attributeName(); + return normalized; +} + +function toCamelCase(value) { + return String(value).replace(/-([a-zA-Z0-9])/g, (_, char) => char.toUpperCase()); +} + +function sanitizeCliText(value) { + return String(value ?? "") + .replace(/[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, "") + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") + .slice(0, 500); +} + +function isValidCliRootName(name) { + return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(String(name ?? "")); +} + +async function buildCliProgram(metadataOnly = false) { + const program = new CliCommandNode(); + const declaredRoots = new Map(); + + for (const registration of registeredCliFactories) { + const descriptors = Array.isArray(registration.options?.descriptors) + ? registration.options.descriptors + : []; + for (const descriptor of descriptors) { + if (descriptor && typeof descriptor.name === "string") { + declaredRoots.set(descriptor.name, sanitizeCliText(descriptor.description ?? "")); + } + } + + const commands = Array.isArray(registration.options?.commands) + ? registration.options.commands + : []; + for (const command of commands) declaredRoots.set(String(command), ""); + + // Modern descriptors and the legacy commands list are sufficient for + // non-activating root discovery. Registrars without metadata use the eager + // compatibility fallback so older plugins do not disappear. + if (metadataOnly && (descriptors.length > 0 || commands.length > 0)) continue; + + try { + await registration.factory({ program }); + } catch (error) { + const message = `Plugin "${pluginId}" CLI registrar failed: ${error?.message ?? error}`; + logger.error(message); + addDiagnostic("cli_registration_failed", message, "registerCli"); + } + } + + for (const command of program.commands) { + if (!isValidCliRootName(command.name())) { + const message = `Plugin "${pluginId}" registered invalid CLI root "${command.name()}".`; + addDiagnostic("invalid_cli_command_name", message, "registerCli", command.name()); + } + } + + for (const name of declaredRoots.keys()) { + if (!isValidCliRootName(name)) { + const message = `Plugin "${pluginId}" declared invalid CLI root "${name}".`; + addDiagnostic("invalid_cli_command_name", message, "registerCli", name); + } + } + + const seenRoots = new Set(); + for (const command of program.commands) { + if (seenRoots.has(command.name())) { + const message = `Plugin "${pluginId}" registered duplicate CLI root "${command.name()}".`; + addDiagnostic("duplicate_cli_command_name", message, "registerCli", command.name()); + } + seenRoots.add(command.name()); + } + + registeredCliProgram = program; + const registrations = program.commands.map((command) => ({ + name: command.name(), + description: declaredRoots.get(command.name()) || command.description() || "", + })); + for (const [name, description] of declaredRoots) { + if (!registrations.some((command) => command.name === name)) { + registrations.push({ name, description }); + } + } + return registrations; +} + +function findCliChild(command, token) { + return command.commands.find((child) => child.name() === token || child._aliases.includes(token)); +} + +function parseCliInvocation(command, argv) { + const options = {}; + for (const option of command.options) { + if (option.defaultValue !== undefined) options[option.name] = option.defaultValue; + else if (option.negate) options[option.name] = true; + } + + const positionals = []; + let index = 0; + while (index < argv.length) { + const token = argv[index]; + if (token === "--") { + positionals.push(...argv.slice(index + 1)); + break; + } + + const [flag, inlineValue] = token.startsWith("--") && token.includes("=") + ? [token.slice(0, token.indexOf("=")), token.slice(token.indexOf("=") + 1)] + : [token, undefined]; + const option = command.options.find((candidate) => candidate.long === flag || candidate.short === flag); + if (!option) { + if (token.startsWith("-")) throw new Error(`Unknown option: ${token}`); + positionals.push(token); + index++; + continue; + } + + if (option.negate) { + options[option.name] = false; + } else if (option.requiredValue || option.optionalValue) { + const next = inlineValue ?? argv[index + 1]; + if (next === undefined || (option.requiredValue && next.startsWith("-"))) { + throw new Error(`Option ${flag} requires a value.`); + } + options[option.name] = next; + if (inlineValue === undefined) index++; + } else { + options[option.name] = true; + } + index++; + } + + for (const option of command.options) { + if (option.requiredOption && options[option.name] === undefined) { + throw new Error(`Required option missing: ${option.long ?? option.short}`); + } + } + + const actionArgs = []; + let positionalIndex = 0; + for (const argument of command._arguments) { + if (argument.variadic) { + const rest = positionals.slice(positionalIndex); + if (argument.required && rest.length === 0) throw new Error(`Missing required argument: ${argument.name}`); + actionArgs.push(rest.length > 0 ? rest : argument.defaultValue); + positionalIndex = positionals.length; + continue; + } + const value = positionals[positionalIndex] ?? argument.defaultValue; + if (argument.required && value === undefined) throw new Error(`Missing required argument: ${argument.name}`); + actionArgs.push(value); + if (positionalIndex < positionals.length) positionalIndex++; + } + if (positionalIndex < positionals.length) throw new Error(`Unexpected argument: ${positionals[positionalIndex]}`); + + command._parsedOptions = options; + return [...actionArgs, options, command]; +} + +function renderCliHelp(command) { + const path = []; + for (let current = command; current && current.name(); current = current.parent) path.unshift(current.name()); + console.log(`Usage: openclaw ${path.join(" ")}${command.commands.length > 0 ? " " : ""}`); + if (command.description()) console.log(`\n${command.description()}`); + if (command.commands.length > 0) { + console.log("\nCommands:"); + for (const child of command.commands) { + console.log(` ${child.name().padEnd(20)} ${child.description()}`.trimEnd()); + } + } + if (command.options.length > 0) { + console.log("\nOptions:"); + for (const option of command.options) { + console.log(` ${option.flags.padEnd(24)} ${option.description}`.trimEnd()); + } + } +} + +async function executeCli(argv) { + if (!registeredCliProgram) await buildCliProgram(); + let command = registeredCliProgram; + let index = 0; + while (index < argv.length) { + const child = findCliChild(command, argv[index]); + if (!child) break; + command = child; + index++; + } + + if (command === registeredCliProgram) throw new Error(`Unknown plugin CLI command: ${argv[0] ?? ""}`); + const remaining = argv.slice(index); + if (remaining.includes("-h") || remaining.includes("--help") || (!command._action && command.commands.length > 0)) { + renderCliHelp(command); + return 0; + } + if (typeof command._action !== "function") throw new Error(`No action registered for: ${argv.slice(0, index).join(" ")}`); + + const actionArgs = parseCliInvocation(command, remaining); + const result = await command._action(...actionArgs); + if (typeof result === "number") return result; + return Number.isInteger(process.exitCode) ? process.exitCode : 0; +} + function createLogger(pluginId) { const prefix = `[plugin:${pluginId}]`; + const quietStandalone = standaloneCliMode || standaloneCliDescribeMode; return { - info: (...args) => console.error(prefix, "INFO", ...args), + info: quietStandalone ? () => {} : (...args) => console.error(prefix, "INFO", ...args), warn: (...args) => console.error(prefix, "WARN", ...args), error: (...args) => console.error(prefix, "ERROR", ...args), - debug: (...args) => console.error(prefix, "DEBUG", ...args), + debug: quietStandalone ? () => {} : (...args) => console.error(prefix, "DEBUG", ...args), }; } @@ -402,7 +769,7 @@ async function handleRequest(req) { try { const pluginExport = await loadPlugin(entryPath); - const api = createPluginApi(pluginId, config, logger); + const api = createPluginApi(pluginId, config, logger, "full"); if (typeof pluginExport === "function") { await pluginExport(api); @@ -414,11 +781,14 @@ async function handleRequest(req) { addDiagnostic("invalid_plugin_export", message, "register"); } + const cliCommands = await buildCliProgram(); + if (compatibilityDiagnostics.length > 0) { return { tools: [], channels: [], commands: [], + cliCommands: [], eventSubscriptions: [], providers: [], capabilities: collectCapabilities(), @@ -441,6 +811,7 @@ async function handleRequest(req) { name: tool.name, description: tool.description, parameters: tool.parameters, + outputSchema: tool.outputSchema, optional: tool.optional, }); } @@ -466,6 +837,7 @@ async function handleRequest(req) { tools, channels, commands, + cliCommands, eventSubscriptions, providers, capabilities: collectCapabilities(), @@ -721,7 +1093,60 @@ function handleInboundLine(line, channel) { })(); } -if (transportMode === "stdio" || transportMode === "hybrid") { +function readStandaloneCliConfig() { + const encoded = process.env.OPENCLAW_PLUGIN_CLI_CONFIG_BASE64 ?? ""; + if (!encoded) return {}; + try { + return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + } catch (error) { + throw new Error(`Invalid standalone CLI plugin config: ${error?.message ?? error}`); + } +} + +async function initializeStandaloneCli(registrationMode, metadataOnly = false) { + const entryPath = process.env.OPENCLAW_PLUGIN_CLI_ENTRY; + pluginId = process.env.OPENCLAW_PLUGIN_CLI_ID ?? "unknown"; + if (!entryPath) throw new Error("OPENCLAW_PLUGIN_CLI_ENTRY is required."); + + logger = createLogger(pluginId); + resetState(); + const pluginExport = await loadPlugin(entryPath); + const api = createPluginApi(pluginId, readStandaloneCliConfig(), logger, registrationMode); + if (typeof pluginExport === "function") { + await pluginExport(api); + } else if (pluginExport && typeof pluginExport.register === "function") { + await pluginExport.register(api); + } else { + throw new Error(`Plugin "${pluginId}" did not export a function or { register } API.`); + } + + const cliCommands = await buildCliProgram(metadataOnly); + if (compatibilityDiagnostics.length > 0) { + throw new Error(compatibilityDiagnostics.map((item) => `[${item.code}] ${item.message}`).join(" | ")); + } + return cliCommands; +} + +async function runStandaloneCli() { + try { + if (standaloneCliDescribeMode) { + const cliCommands = await initializeStandaloneCli("cli-metadata", true); + process.stdout.write(`${JSON.stringify(cliCommands)}\n`, () => process.exit(0)); + return; + } + + await initializeStandaloneCli("full"); + const exitCode = await executeCli(process.argv.slice(3)); + process.exit(exitCode); + } catch (error) { + console.error(`Plugin CLI error: ${error?.message ?? error}`); + process.exit(1); + } +} + +if (standaloneCliMode || standaloneCliDescribeMode) { + void runStandaloneCli(); +} else if (transportMode === "stdio" || transportMode === "hybrid") { const rl = createInterface({ input: process.stdin, terminal: false }); rl.on("line", (line) => { handleInboundLine(line, "stdio"); diff --git a/src/OpenClaw.Cli/OpenClaw.Cli.csproj b/src/OpenClaw.Cli/OpenClaw.Cli.csproj index a666fedd..ea7fd902 100644 --- a/src/OpenClaw.Cli/OpenClaw.Cli.csproj +++ b/src/OpenClaw.Cli/OpenClaw.Cli.csproj @@ -20,4 +20,11 @@ + + + + diff --git a/src/OpenClaw.Cli/PluginCliCommands.cs b/src/OpenClaw.Cli/PluginCliCommands.cs new file mode 100644 index 00000000..2cb72e4b --- /dev/null +++ b/src/OpenClaw.Cli/PluginCliCommands.cs @@ -0,0 +1,318 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using OpenClaw.Core.Models; +using OpenClaw.Core.Plugins; + +namespace OpenClaw.Cli; + +/// +/// Lazily discovers and executes root CLI commands registered by upstream plugins. +/// Metadata discovery uses an isolated child process; execution uses a fresh child +/// with inherited terminal streams so interactive plugin commands remain usable. +/// +internal static class PluginCliCommands +{ + private const string ConfigPathEnvironment = "OPENCLAW_CONFIG_PATH"; + private const string WorkspaceEnvironment = "OPENCLAW_WORKSPACE"; + private static readonly TimeSpan DescribeTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan ExecuteTimeout = TimeSpan.FromMinutes(10); + + internal static async Task TryRunAsync( + string command, + string[] args, + CancellationToken cancellationToken = default) + { + var configPathValue = Environment.GetEnvironmentVariable(ConfigPathEnvironment); + var configPath = Path.GetFullPath(GatewayConfigFile.ExpandPath( + string.IsNullOrWhiteSpace(configPathValue) + ? GatewayConfigFile.DefaultConfigPath + : configPathValue)); + + GatewayConfig config; + var loadedConfig = File.Exists(configPath); + try + { + config = loadedConfig ? GatewayConfigFile.Load(configPath) : new GatewayConfig(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unable to load plugin CLI configuration from '{configPath}': {ex.Message}"); + return 1; + } + + if (!config.Plugins.Enabled) + return null; + + var bridgeScript = PluginCommands.ResolveBridgeScriptPath(); + if (bridgeScript is null) + { + Console.Error.WriteLine("Plugin bridge script was not found. Reinstall or republish the OpenClaw CLI."); + return 1; + } + + var blockedPluginIds = loadedConfig + ? LoadBlockedPluginIds(config.Memory.StoragePath) + : new HashSet(StringComparer.Ordinal); + return await TryRunAsync( + command, + args, + config.Plugins, + Environment.GetEnvironmentVariable(WorkspaceEnvironment), + blockedPluginIds, + bridgeScript, + cancellationToken); + } + + internal static async Task TryRunAsync( + string command, + string[] args, + PluginsConfig pluginsConfig, + string? workspacePath, + IReadOnlySet blockedPluginIds, + string bridgeScript, + CancellationToken cancellationToken) + { + var discovered = PluginDiscovery.Filter( + PluginDiscovery.Discover(pluginsConfig, workspacePath), + pluginsConfig); + var matches = new List<(DiscoveredPlugin Plugin, JsonElement? Config)>(); + + foreach (var plugin in discovered) + { + if (plugin.Format == PluginFormats.Bundle || + blockedPluginIds.Contains("*") || + blockedPluginIds.Contains(plugin.Manifest.Id) || + string.IsNullOrWhiteSpace(plugin.EntryPath)) + { + continue; + } + + pluginsConfig.Entries.TryGetValue(plugin.Manifest.Id, out var entryConfig); + var pluginConfig = entryConfig?.Config; + var diagnostics = PluginPackageCompatibility.Validate(plugin) + .Concat(PluginConfigValidator.Validate(plugin.Manifest, pluginConfig)); + if (diagnostics.Any(static item => + string.Equals(item.Severity, "error", StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var description = await DescribeAsync( + plugin.EntryPath, + plugin.Manifest.Id, + pluginConfig, + bridgeScript, + cancellationToken); + if (!description.Success) + continue; + + if (description.Commands.Any(item => + string.Equals(item.Name, command, StringComparison.Ordinal))) + { + matches.Add((plugin, pluginConfig)); + } + } + + if (matches.Count == 0) + return null; + + if (matches.Count > 1) + { + Console.Error.WriteLine( + $"Plugin CLI command '{command}' is ambiguous; registered by: " + + string.Join(", ", matches.Select(static item => item.Plugin.Manifest.Id))); + return 2; + } + + var match = matches[0]; + return await ExecuteAsync( + match.Plugin.EntryPath, + match.Plugin.Manifest.Id, + match.Config, + bridgeScript, + [command, .. args], + cancellationToken); + } + + internal static async Task DescribeAsync( + string entryPath, + string pluginId, + JsonElement? pluginConfig, + string bridgeScript, + CancellationToken cancellationToken) + { + using var process = CreateProcess(entryPath, pluginId, pluginConfig, bridgeScript, "--cli-describe"); + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + + try + { + process.Start(); + } + catch (Exception ex) + { + return PluginCliDescribeResult.Failure($"Unable to start Node.js for plugin CLI discovery: {ex.Message}"); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(DescribeTimeout); + await process.WaitForExitAsync(timeout.Token); + var stdout = await stdoutTask; + var stderr = await stderrTask; + if (process.ExitCode != 0) + { + return PluginCliDescribeResult.Failure( + string.IsNullOrWhiteSpace(stderr) + ? $"Plugin CLI discovery exited with code {process.ExitCode}." + : stderr.Trim()); + } + + var commands = JsonSerializer.Deserialize( + stdout, + CoreJsonContext.Default.BridgeCliCommandRegistrationArray); + return commands is null + ? PluginCliDescribeResult.Failure("Plugin CLI discovery returned unreadable metadata.") + : new PluginCliDescribeResult { Success = true, Commands = commands }; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKill(process); + return PluginCliDescribeResult.Failure("Plugin CLI discovery timed out after 20 seconds."); + } + catch (Exception ex) + { + TryKill(process); + return PluginCliDescribeResult.Failure($"Plugin CLI discovery failed: {ex.Message}"); + } + } + + private static async Task ExecuteAsync( + string entryPath, + string pluginId, + JsonElement? pluginConfig, + string bridgeScript, + string[] argv, + CancellationToken cancellationToken) + { + using var process = CreateProcess(entryPath, pluginId, pluginConfig, bridgeScript, "--cli-run"); + foreach (var arg in argv) + process.StartInfo.ArgumentList.Add(arg); + + try + { + process.Start(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unable to start plugin CLI command: {ex.Message}"); + return 1; + } + + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(ExecuteTimeout); + await process.WaitForExitAsync(timeout.Token); + return process.ExitCode; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKill(process); + Console.Error.WriteLine("Plugin CLI command timed out after 10 minutes."); + return 124; + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + } + + private static Process CreateProcess( + string entryPath, + string pluginId, + JsonElement? pluginConfig, + string bridgeScript, + string mode) + { + var nodeExecutable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "node.exe" : "node"; + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = nodeExecutable, + WorkingDirectory = Path.GetDirectoryName(entryPath) ?? Directory.GetCurrentDirectory(), + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add("--experimental-vm-modules"); + process.StartInfo.ArgumentList.Add(bridgeScript); + process.StartInfo.ArgumentList.Add(mode); + process.StartInfo.Environment["OPENCLAW_PLUGIN_CLI_ENTRY"] = Path.GetFullPath(entryPath); + process.StartInfo.Environment["OPENCLAW_PLUGIN_CLI_ID"] = pluginId; + process.StartInfo.Environment["OPENCLAW_PLUGIN_CLI_CONFIG_BASE64"] = Convert.ToBase64String( + Encoding.UTF8.GetBytes(pluginConfig?.GetRawText() ?? "{}")); + return process; + } + + private static HashSet LoadBlockedPluginIds(string storagePath) + { + var result = new HashSet(StringComparer.Ordinal); + try + { + var rootedStoragePath = Path.IsPathRooted(storagePath) + ? storagePath + : Path.GetFullPath(storagePath); + var statePath = Path.Combine(rootedStoragePath, "admin", "plugin-state.json"); + if (!File.Exists(statePath)) + return result; + + using var stream = File.OpenRead(statePath); + var states = JsonSerializer.Deserialize( + stream, + CoreJsonContext.Default.ListPluginOperatorState) ?? []; + foreach (var state in states) + { + if (state.Disabled || state.Quarantined) + result.Add(state.PluginId); + } + } + catch + { + // A malformed optional operator-state file must not activate a plugin. + // Treat discovery as empty rather than bypassing a possible quarantine. + return new HashSet(StringComparer.Ordinal) { "*" }; + } + + return result; + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + } + } + + internal sealed class PluginCliDescribeResult + { + public bool Success { get; init; } + public string? Error { get; init; } + public IReadOnlyList Commands { get; init; } = []; + + public static PluginCliDescribeResult Failure(string error) + => new() { Error = error }; + } +} diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index 90410fad..d1f460dd 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -31,6 +31,7 @@ public static async Task RunAsync(string[] args) "install" => await InstallAsync(rest), "remove" or "uninstall" => await RemoveAsync(rest), "list" or "ls" => ListInstalled(rest), + "inspect" => await InspectAsync(rest), "search" => await SearchAsync(rest), _ => UnknownSubcommand(subcommand) }; @@ -128,24 +129,17 @@ private static async Task InstallFromNpmAsync(string packageSpec, string ex // Step 4: Determine plugin name from manifest or package.json var pluginName = ResolvePluginName(packageDir) ?? SanitizePackageName(packageSpec); - // Step 5: Move to extensions directory + // Step 5: stage dependencies and atomically replace the installed plugin var targetDir = Path.Combine(extensionsDir, pluginName); - if (Directory.Exists(targetDir)) + var installResult = await InstallPreparedDirectoryAsync( + packageDir, + targetDir, + packageSpec, + sourceIsNpm: true); + if (!installResult.Success) { - Console.WriteLine($"Replacing existing plugin '{pluginName}'..."); - Directory.Delete(targetDir, recursive: true); - } - - CopyDirectory(packageDir, targetDir); - - // Step 6: Install npm dependencies if package.json exists - var packageJson = Path.Combine(targetDir, "package.json"); - if (File.Exists(packageJson)) - { - Console.WriteLine("Installing dependencies..."); - var npmInstall = await RunNpmAsync("install --production --no-optional", targetDir); - if (npmInstall.ExitCode != 0) - Console.Error.WriteLine($"Warning: npm install failed: {npmInstall.Stderr}"); + Console.Error.WriteLine(installResult.Error); + return 1; } Console.WriteLine($"Installed '{pluginName}' to {targetDir}"); @@ -199,10 +193,16 @@ private static async Task InstallFromLocalAsync(string localPath, string ex var pluginName = ResolvePluginName(packageDir) ?? Path.GetFileNameWithoutExtension(localPath); var targetDir = Path.Combine(extensionsDir, pluginName); - if (Directory.Exists(targetDir)) - Directory.Delete(targetDir, recursive: true); - - CopyDirectory(packageDir, targetDir); + var installResult = await InstallPreparedDirectoryAsync( + packageDir, + targetDir, + localPath, + sourceIsNpm: false); + if (!installResult.Success) + { + Console.Error.WriteLine(installResult.Error); + return 1; + } Console.WriteLine($"Installed '{pluginName}' from tarball to {targetDir}"); return 0; } @@ -229,10 +229,16 @@ private static async Task InstallFromLocalAsync(string localPath, string ex var pluginName = ResolvePluginName(sourcePath) ?? Path.GetFileName(sourcePath); var targetDir = Path.Combine(extensionsDir, pluginName); - if (Directory.Exists(targetDir)) - Directory.Delete(targetDir, recursive: true); - - CopyDirectory(sourcePath, targetDir); + var installResult = await InstallPreparedDirectoryAsync( + sourcePath, + targetDir, + localPath, + sourceIsNpm: false); + if (!installResult.Success) + { + Console.Error.WriteLine(installResult.Error); + return 1; + } Console.WriteLine($"Installed '{pluginName}' from local directory to {targetDir}"); return 0; } @@ -308,14 +314,73 @@ private static int ListInstalled(string[] args) var trustLevel = DetermineTrustLevel(plugin.RootPath, sourceIsNpm: false, errorCount: 0, hasStructuredSurface); Console.WriteLine($" {name} ({version}) - {desc}"); Console.WriteLine($" Path: {plugin.RootPath}"); + Console.WriteLine($" Format: {plugin.Format}"); + if (plugin.Format == PluginFormats.Bundle) + Console.WriteLine($" Bundle format: {plugin.BundleFormat}"); Console.WriteLine($" Trust: {trustLevel}"); Console.WriteLine($" Trust reason: {DetermineTrustReason(trustLevel, errorCount: 0, hasStructuredSurface)}"); - Console.WriteLine($" Declared: {BuildDeclaredSurfaceSummary(plugin.Manifest)}"); + Console.WriteLine($" Declared: {BuildDeclaredSurfaceSummary(plugin.Manifest, plugin)}"); } return 0; } + private static async Task InspectAsync(string[] args) + { + var target = args.FirstOrDefault(arg => !arg.StartsWith("-", StringComparison.Ordinal)); + if (string.IsNullOrWhiteSpace(target)) + { + Console.Error.WriteLine("Usage: openclaw plugins inspect [--runtime]"); + return 2; + } + + var candidatePath = target; + if (!Directory.Exists(candidatePath) && !File.Exists(candidatePath)) + { + var extensionsDir = ResolveExtensionsDir(args.Contains("--global") || args.Contains("-g")); + candidatePath = Path.Combine(extensionsDir, target); + if (!Directory.Exists(candidatePath)) + candidatePath = Path.Combine(extensionsDir, SanitizePackageName(target)); + } + + if (!Directory.Exists(candidatePath) && !File.Exists(candidatePath)) + { + Console.Error.WriteLine($"Plugin '{target}' was not found."); + return 1; + } + + var rootPath = Directory.Exists(candidatePath) + ? Path.GetFullPath(candidatePath) + : Path.GetDirectoryName(Path.GetFullPath(candidatePath))!; + var inspection = InspectCandidate(rootPath, target, sourceIsNpm: false); + if (!inspection.Success) + { + Console.Error.WriteLine(inspection.ErrorMessage); + return 1; + } + + PrintInspection(inspection); + if (!inspection.CanInstall) + return 1; + if (!args.Contains("--runtime")) + return 0; + + if (inspection.Format == PluginFormats.Bundle) + { + Console.WriteLine("Runtime: content bundle; no arbitrary bundle module was executed."); + return 0; + } + + var runtime = await InspectRuntimeAsync(inspection.EntryPath, inspection.PluginId, CancellationToken.None); + Console.WriteLine($"Runtime: {(runtime.Compatible ? "compatible" : "incompatible")}"); + Console.WriteLine($"Registered: tools={runtime.ToolCount}, channels={runtime.ChannelCount}, commands={runtime.CommandCount}, cli={runtime.CliCommandCount}, providers={runtime.ProviderCount}"); + foreach (var diagnostic in runtime.Diagnostics) + Console.WriteLine($"{(diagnostic.Severity == "error" ? "Error" : "Warning")}: [{diagnostic.Code}] {diagnostic.Message}"); + if (!runtime.Compatible && !string.IsNullOrWhiteSpace(runtime.Error)) + Console.Error.WriteLine(runtime.Error); + return runtime.Compatible ? 0 : 1; + } + private static async Task SearchAsync(string[] args) { if (args.Length == 0) @@ -472,6 +537,275 @@ private static void CopyDirectory(string source, string destination) } } + internal static async Task<(bool Success, string? Error)> InstallPreparedDirectoryAsync( + string sourceDir, + string targetDir, + string sourceLabel, + bool sourceIsNpm) + { + var parentDir = Path.GetDirectoryName(targetDir) + ?? throw new InvalidOperationException($"Plugin target '{targetDir}' has no parent directory."); + Directory.CreateDirectory(parentDir); + var suffix = Guid.NewGuid().ToString("N")[..12]; + var stagingDir = Path.Combine(parentDir, $".{Path.GetFileName(targetDir)}.installing-{suffix}"); + var backupDir = Path.Combine(parentDir, $".{Path.GetFileName(targetDir)}.backup-{suffix}"); + + try + { + CopyDirectory(sourceDir, stagingDir); + + var stagedInspection = InspectCandidate(stagingDir, sourceLabel, sourceIsNpm); + if (!stagedInspection.Success) + return (false, $"Staged plugin inspection failed; the existing plugin was preserved: {stagedInspection.ErrorMessage}"); + if (!stagedInspection.CanInstall) + { + var codes = string.Join(", ", stagedInspection.Diagnostics.Select(static item => item.Code).Distinct(StringComparer.Ordinal)); + return (false, $"Staged plugin compatibility failed; the existing plugin was preserved. Diagnostics: {codes}"); + } + + var packageJson = Path.Combine(stagingDir, "package.json"); + if (File.Exists(packageJson) && stagedInspection.Format != PluginFormats.Bundle) + { + Console.WriteLine("Installing dependencies in staging..."); + var npmInstall = await RunNpmAsync("install --omit=dev --omit=optional", stagingDir); + if (npmInstall.ExitCode != 0) + return (false, $"Dependency installation failed; the existing plugin was preserved: {npmInstall.Stderr}"); + + stagedInspection = InspectCandidate(stagingDir, sourceLabel, sourceIsNpm); + if (!stagedInspection.Success) + return (false, $"Post-dependency inspection failed; the existing plugin was preserved: {stagedInspection.ErrorMessage}"); + if (!stagedInspection.CanInstall) + { + var codes = string.Join(", ", stagedInspection.Diagnostics.Select(static item => item.Code).Distinct(StringComparer.Ordinal)); + return (false, $"Post-dependency compatibility inspection failed; the existing plugin was preserved. Diagnostics: {codes}"); + } + } + + if (Path.GetExtension(stagedInspection.EntryPath).Equals(".ts", StringComparison.OrdinalIgnoreCase) && + stagedInspection.Format != PluginFormats.Bundle && + !HasLocalJiti(stagedInspection.EntryPath)) + { + Console.WriteLine("Installing the TypeScript runtime dependency jiti in staging..."); + var jitiInstall = await RunNpmAsync("install --no-save --omit=dev --omit=optional jiti", stagingDir); + if (jitiInstall.ExitCode != 0) + return (false, $"TypeScript runtime dependency installation failed; the existing plugin was preserved: {jitiInstall.Stderr}"); + } + + if (stagedInspection.Format != PluginFormats.Bundle) + { + var runtimeInspection = await InspectRuntimeAsync( + stagedInspection.EntryPath, + stagedInspection.PluginId, + CancellationToken.None); + if (!runtimeInspection.Compatible) + { + var details = runtimeInspection.Diagnostics.Count == 0 + ? runtimeInspection.Error ?? "unknown runtime inspection failure" + : string.Join(", ", runtimeInspection.Diagnostics.Select(static item => item.Code).Distinct(StringComparer.Ordinal)); + return (false, $"Plugin runtime inspection failed; the existing plugin was preserved. Diagnostics: {details}"); + } + } + + if (Directory.Exists(targetDir)) + { + Console.WriteLine($"Replacing existing plugin '{Path.GetFileName(targetDir)}' atomically..."); + Directory.Move(targetDir, backupDir); + } + + try + { + Directory.Move(stagingDir, targetDir); + } + catch + { + if (Directory.Exists(backupDir) && !Directory.Exists(targetDir)) + Directory.Move(backupDir, targetDir); + throw; + } + + if (Directory.Exists(backupDir)) + { + try { Directory.Delete(backupDir, recursive: true); } catch { /* successful install; stale backup is recoverable */ } + } + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Plugin installation failed; the existing plugin was preserved when possible: {ex.Message}"); + } + finally + { + if (Directory.Exists(stagingDir)) + { + try { Directory.Delete(stagingDir, recursive: true); } catch { } + } + + if (Directory.Exists(backupDir) && Directory.Exists(targetDir)) + { + try { Directory.Delete(backupDir, recursive: true); } catch { } + } + } + } + + private static bool HasLocalJiti(string entryPath) + { + var current = Path.GetDirectoryName(entryPath); + while (!string.IsNullOrWhiteSpace(current)) + { + if (Directory.Exists(Path.Combine(current, "node_modules", "jiti"))) + return true; + var parent = Path.GetDirectoryName(current); + if (string.Equals(parent, current, StringComparison.Ordinal)) + break; + current = parent; + } + + return false; + } + + internal static async Task InspectRuntimeAsync( + string entryPath, + string pluginId, + CancellationToken cancellationToken) + { + var bridgeScript = ResolveBridgeScriptPath(); + if (bridgeScript is null) + { + return PluginRuntimeInspection.Failure( + "Plugin bridge script was not found. Reinstall or republish the OpenClaw CLI."); + } + + var nodeExecutable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "node.exe" : "node"; + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = nodeExecutable, + WorkingDirectory = Path.GetDirectoryName(entryPath) ?? Directory.GetCurrentDirectory(), + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.StartInfo.ArgumentList.Add("--experimental-vm-modules"); + process.StartInfo.ArgumentList.Add(bridgeScript); + process.StartInfo.Environment["OPENCLAW_BRIDGE_TRANSPORT_MODE"] = "stdio"; + + try + { + process.Start(); + } + catch (Exception ex) + { + return PluginRuntimeInspection.Failure($"Unable to start Node.js for runtime inspection: {ex.Message}"); + } + + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var initRequest = new BridgeInitRequest + { + EntryPath = Path.GetFullPath(entryPath), + PluginId = pluginId, + Config = JsonDocument.Parse("{}").RootElement.Clone(), + Transport = new BridgeTransportRuntimeConfig { Mode = "stdio" } + }; + var request = new BridgeRequest + { + Id = "inspection-1", + Method = "init", + Params = JsonSerializer.SerializeToElement(initRequest, CoreJsonContext.Default.BridgeInitRequest) + }; + var requestJson = JsonSerializer.Serialize(request, CoreJsonContext.Default.BridgeRequest); + await process.StandardInput.WriteLineAsync(requestJson.AsMemory(), timeout.Token); + await process.StandardInput.FlushAsync(timeout.Token); + + var responseLine = await process.StandardOutput.ReadLineAsync(timeout.Token); + if (string.IsNullOrWhiteSpace(responseLine)) + { + var stderr = await stderrTask; + return PluginRuntimeInspection.Failure($"Plugin bridge exited without an inspection response. {stderr}".Trim()); + } + + var response = JsonSerializer.Deserialize(responseLine, CoreJsonContext.Default.BridgeResponse); + if (response?.Error is not null) + return PluginRuntimeInspection.Failure(response.Error.Message); + if (response?.Result is null) + return PluginRuntimeInspection.Failure("Plugin bridge returned an empty inspection result."); + + var result = JsonSerializer.Deserialize( + response.Result.Value.GetRawText(), + CoreJsonContext.Default.BridgeInitResult); + if (result is null) + return PluginRuntimeInspection.Failure("Plugin bridge returned an unreadable inspection result."); + + return new PluginRuntimeInspection + { + Compatible = result.Compatible, + Diagnostics = result.Diagnostics, + ToolCount = result.Tools.Length, + ChannelCount = result.Channels.Length, + CommandCount = result.Commands.Length, + CliCommandCount = result.CliCommands.Length, + ProviderCount = result.Providers.Length + }; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return PluginRuntimeInspection.Failure("Plugin runtime inspection timed out after 20 seconds."); + } + catch (Exception ex) + { + return PluginRuntimeInspection.Failure($"Plugin runtime inspection failed: {ex.Message}"); + } + finally + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + } + } + } + + internal static string? ResolveBridgeScriptPath() + { + var packaged = Path.Combine(AppContext.BaseDirectory, "Plugins", "plugin-bridge.mjs"); + if (File.Exists(packaged)) + return packaged; + + var source = Path.GetFullPath(Path.Combine( + Directory.GetCurrentDirectory(), + "src", + "OpenClaw.Agent", + "Plugins", + "plugin-bridge.mjs")); + return File.Exists(source) ? source : null; + } + + internal sealed class PluginRuntimeInspection + { + public bool Compatible { get; init; } + public string? Error { get; init; } + public IReadOnlyList Diagnostics { get; init; } = []; + public int ToolCount { get; init; } + public int ChannelCount { get; init; } + public int CommandCount { get; init; } + public int CliCommandCount { get; init; } + public int ProviderCount { get; init; } + + public static PluginRuntimeInspection Failure(string error) + => new() { Compatible = false, Error = error }; + } + private static string Quote(string path) => path.Contains(' ') ? $"\"{path}\"" : path; @@ -491,6 +825,7 @@ openclaw plugins — Manage OpenClaw plugins openclaw plugins install Install a plugin from npm/ClawHub or local source openclaw plugins remove Remove an installed plugin openclaw plugins list List installed plugins + openclaw plugins inspect [--runtime] Inspect static and optional runtime compatibility openclaw plugins search Search npm for OpenClaw plugins Options: @@ -504,6 +839,7 @@ openclaw plugins install ./my-local-plugin openclaw plugins install ./my-plugin.tgz openclaw plugins remove qqbot openclaw plugins list + openclaw plugins inspect ./my-codex-bundle --runtime openclaw plugins search openclaw dingtalk """); } @@ -545,9 +881,12 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string description = root.TryGetProperty("description", out var descriptionProp) ? descriptionProp.GetString() : null; hasExtensionsConfig = root.TryGetProperty("openclaw", out var openClaw) && - openClaw.TryGetProperty("extensions", out var extensions) && - extensions.ValueKind == JsonValueKind.Array && - extensions.GetArrayLength() > 0; + ((openClaw.TryGetProperty("runtimeExtensions", out var runtimeExtensions) && + runtimeExtensions.ValueKind == JsonValueKind.Array && + runtimeExtensions.GetArrayLength() > 0) || + (openClaw.TryGetProperty("extensions", out var extensions) && + extensions.ValueKind == JsonValueKind.Array && + extensions.GetArrayLength() > 0)); } catch (Exception ex) { @@ -555,11 +894,17 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string } } - var entryPath = FindEntryFile(rootPath); + var discovery = PluginDiscovery.DiscoverWithDiagnostics(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [rootPath] } + }); + var discoveredPlugin = discovery.Plugins.FirstOrDefault(); + var isBundle = discoveredPlugin?.Format == PluginFormats.Bundle; + var entryPath = isBundle ? rootPath : discoveredPlugin?.EntryPath ?? FindEntryFile(rootPath); if (entryPath is null) - return PluginInstallInspection.Failure($"No plugin entry file found under {rootPath}. Expected openclaw.plugin.json with an entry, package.json openclaw.extensions, or an index.ts/js/mjs file."); + return PluginInstallInspection.Failure($"No plugin entry file or compatible Codex/Claude/Cursor bundle was found under {rootPath}."); - var effectiveManifest = manifest ?? new PluginManifest + var effectiveManifest = manifest ?? discoveredPlugin?.Manifest ?? new PluginManifest { Id = ResolvePluginName(rootPath) ?? SanitizePackageName(packageName ?? Path.GetFileName(rootPath)), Name = packageName, @@ -569,18 +914,39 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string var warnings = new List(); var diagnostics = new List(); - if (!hasManifest) + if (!hasManifest && !isBundle) warnings.Add("No openclaw.plugin.json manifest was found. Install is allowed, but declared capabilities and config validation metadata are limited."); - if (!hasExtensionsConfig && !hasManifest) + if (!hasExtensionsConfig && !hasManifest && !isBundle) warnings.Add("Package relies on standalone entry-file discovery. Review the source before enabling it on a public bind."); var declaredChannels = effectiveManifest.Channels ?? []; var declaredProviders = effectiveManifest.Providers ?? []; var declaredSkills = effectiveManifest.Skills ?? []; - if (effectiveManifest.ConfigSchema is not null) + if (!isBundle && effectiveManifest.ConfigSchema is not null) diagnostics.AddRange(PluginConfigValidator.Validate(effectiveManifest, config: null)); + if (discoveredPlugin is not null && !isBundle) + diagnostics.AddRange(PluginPackageCompatibility.Validate(discoveredPlugin)); + + if (!isBundle) + InspectUnsupportedRuntimeSurfaces(rootPath, diagnostics); + + if (isBundle) + { + foreach (var capability in discoveredPlugin!.BundleDetectedCapabilities) + { + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "warning", + Code = "bundle_capability_detected_only", + Message = $"Bundle capability '{capability}' is detected but has no OpenClaw.NET runtime mapping.", + Surface = capability, + Path = rootPath + }); + } + } + foreach (var skillDir in declaredSkills) { if (!PluginDiscovery.TryResolveContainedPath(rootPath, skillDir, out var resolvedSkillDir)) @@ -611,7 +977,11 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string var rootSkillFile = Path.Combine(resolvedSkillDir, "SKILL.md"); var nestedSkillFiles = Directory.GetFiles(resolvedSkillDir, "SKILL.md", SearchOption.AllDirectories); - if (!File.Exists(rootSkillFile) && nestedSkillFiles.Length == 0) + var isBundleCommandRoot = isBundle && + string.Equals(Path.GetFileName(Path.TrimEndingDirectorySeparator(resolvedSkillDir)), "commands", StringComparison.OrdinalIgnoreCase); + var hasBundleCommands = isBundleCommandRoot && + Directory.GetFiles(resolvedSkillDir, "*.md", SearchOption.AllDirectories).Length > 0; + if (!File.Exists(rootSkillFile) && nestedSkillFiles.Length == 0 && !hasBundleCommands) { diagnostics.Add(new PluginCompatibilityDiagnostic { @@ -630,8 +1000,9 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string ? "errors" : warningCount > 0 ? "warnings" - : "verified"; + : "manifest-valid"; var hasStructuredSurface = + (isBundle && discoveredPlugin!.BundleMappedCapabilities.Length > 0) || declaredChannels.Length > 0 || declaredProviders.Length > 0 || declaredSkills.Length > 0 || @@ -648,12 +1019,14 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string Version = effectiveManifest.Version ?? version ?? "?", Description = effectiveManifest.Description ?? description ?? "", EntryPath = entryPath, + Format = discoveredPlugin?.Format ?? PluginFormats.Native, + BundleFormat = discoveredPlugin?.BundleFormat, TrustLevel = trustLevel, TrustReason = trustReason, CompatibilityStatus = compatibilityStatus, ErrorCount = errorCount, WarningCount = warningCount, - DeclaredSurface = BuildDeclaredSurfaceSummary(effectiveManifest), + DeclaredSurface = BuildDeclaredSurfaceSummary(effectiveManifest, discoveredPlugin), Diagnostics = diagnostics, Warnings = warnings }; @@ -663,6 +1036,9 @@ private static void PrintInspection(PluginInstallInspection inspection) { Console.WriteLine($"Plugin: {inspection.DisplayName} ({inspection.PluginId})"); Console.WriteLine($"Version: {inspection.Version}"); + Console.WriteLine($"Format: {inspection.Format}"); + if (inspection.Format == PluginFormats.Bundle) + Console.WriteLine($"Bundle format: {inspection.BundleFormat}"); if (!string.IsNullOrWhiteSpace(inspection.Description)) Console.WriteLine($"Description: {inspection.Description}"); Console.WriteLine($"Trust: {inspection.TrustLevel}"); @@ -699,14 +1075,22 @@ private static string DetermineTrustReason(string trustLevel, int errorCount, bo => trustLevel switch { "first-party" => "Package source matches an official OpenClaw or ClawDotNet scope.", - "upstream-compatible" => "Plugin declares structured OpenClaw surfaces and passed install-time compatibility checks.", + "upstream-compatible" => "Plugin declares structured OpenClaw surfaces and passed manifest, package metadata, and static compatibility checks.", _ when hasStructuredSurface && errorCount > 0 => "Plugin declares OpenClaw surfaces, but compatibility verification reported blocking errors.", _ => "Plugin relies on entry discovery without a structured manifest-backed capability declaration." }; - private static string BuildDeclaredSurfaceSummary(PluginManifest manifest) + private static string BuildDeclaredSurfaceSummary(PluginManifest manifest, DiscoveredPlugin? plugin = null) { var items = new List(); + if (plugin?.Format == PluginFormats.Bundle) + { + items.Add($"bundle={plugin.BundleFormat}"); + if (plugin.BundleMappedCapabilities.Length > 0) + items.Add($"mapped={string.Join(",", plugin.BundleMappedCapabilities)}"); + if (plugin.BundleDetectedCapabilities.Length > 0) + items.Add($"detected_only={string.Join(",", plugin.BundleDetectedCapabilities)}"); + } var channels = manifest.Channels ?? []; var providers = manifest.Providers ?? []; var skills = manifest.Skills ?? []; @@ -724,13 +1108,6 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest) private static string? FindEntryFile(string rootPath) { - foreach (var candidate in new[] { "index.ts", "index.js", "index.mjs", "src/index.ts", "src/index.js", "src/index.mjs" }) - { - var path = Path.Combine(rootPath, candidate); - if (File.Exists(path)) - return path; - } - var packageJsonPath = Path.Combine(rootPath, "package.json"); if (File.Exists(packageJsonPath)) { @@ -739,10 +1116,18 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest) using var stream = File.OpenRead(packageJsonPath); using var doc = JsonDocument.Parse(stream); var root = doc.RootElement; - if (root.TryGetProperty("openclaw", out var openClaw) && - openClaw.TryGetProperty("extensions", out var extensions) && - extensions.ValueKind == JsonValueKind.Array) + if (root.TryGetProperty("openclaw", out var openClaw)) { + var extensions = openClaw.TryGetProperty("runtimeExtensions", out var runtimeExtensions) && + runtimeExtensions.ValueKind == JsonValueKind.Array + ? runtimeExtensions + : openClaw.TryGetProperty("extensions", out var sourceExtensions) && + sourceExtensions.ValueKind == JsonValueKind.Array + ? sourceExtensions + : default; + if (extensions.ValueKind != JsonValueKind.Array) + return null; + foreach (var extension in extensions.EnumerateArray()) { var relPath = extension.GetString(); @@ -763,9 +1148,82 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest) } } + foreach (var candidate in new[] { "index.js", "index.mjs", "index.cjs", "index.ts", "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" }) + { + var path = Path.Combine(rootPath, candidate); + if (File.Exists(path)) + return path; + } + return null; } + private static void InspectUnsupportedRuntimeSurfaces( + string rootPath, + ICollection diagnostics) + { + foreach (var file in EnumeratePluginSourceFiles(rootPath)) + { + string source; + try + { + source = File.ReadAllText(file); + } + catch + { + continue; + } + + AddUnsupportedSurfaceDiagnostic(source, file, "registerGatewayMethod", "unsupported_gateway_method", diagnostics); + } + } + + private static IEnumerable EnumeratePluginSourceFiles(string rootPath) + { + var pending = new Stack(); + pending.Push(rootPath); + while (pending.Count > 0) + { + var directory = pending.Pop(); + foreach (var child in Directory.EnumerateDirectories(directory)) + { + var name = Path.GetFileName(child); + if (name is not "node_modules" and not ".git") + pending.Push(child); + } + + foreach (var file in Directory.EnumerateFiles(directory)) + { + if (Path.GetExtension(file) is ".js" or ".mjs" or ".cjs" or ".ts") + yield return file; + } + } + } + + private static void AddUnsupportedSurfaceDiagnostic( + string source, + string file, + string apiName, + string code, + ICollection diagnostics) + { + if (!System.Text.RegularExpressions.Regex.IsMatch( + source, + $@"\b{System.Text.RegularExpressions.Regex.Escape(apiName)}\s*\(", + System.Text.RegularExpressions.RegexOptions.CultureInvariant) || + diagnostics.Any(item => string.Equals(item.Code, code, StringComparison.Ordinal))) + return; + + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "error", + Code = code, + Message = $"Plugin source references api.{apiName}(), which is not supported by OpenClaw.NET.", + Surface = apiName, + Path = file + }); + } + internal sealed class PluginInstallInspection { public required bool Success { get; init; } @@ -776,6 +1234,8 @@ internal sealed class PluginInstallInspection public string Version { get; init; } = ""; public string Description { get; init; } = ""; public string EntryPath { get; init; } = ""; + public string Format { get; init; } = PluginFormats.Native; + public string? BundleFormat { get; init; } public string TrustLevel { get; init; } = ""; public string TrustReason { get; init; } = ""; public string CompatibilityStatus { get; init; } = ""; diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs index e44e0ca6..88e55f8d 100644 --- a/src/OpenClaw.Cli/Program.cs +++ b/src/OpenClaw.Cli/Program.cs @@ -55,7 +55,7 @@ public static async Task Main(string[] args) "skills" => await SkillCommands.RunAsync(rest), "clawhub" => await ClawHubCommand.RunAsync(rest), "version" or "--version" or "-v" => PrintVersion(), - _ => UnknownCommand(command) + _ => await RunPluginOrUnknownAsync(command, rest) }; } catch (OperationCanceledException) @@ -83,6 +83,9 @@ private static int UnknownCommand(string command) return 2; } + private static async Task RunPluginOrUnknownAsync(string command, string[] args) + => await PluginCliCommands.TryRunAsync(command, args) ?? UnknownCommand(command); + private static void PrintHelp() { Console.WriteLine( diff --git a/src/OpenClaw.Core/Abstractions/ITool.cs b/src/OpenClaw.Core/Abstractions/ITool.cs index 1d22e640..b21c6797 100644 --- a/src/OpenClaw.Core/Abstractions/ITool.cs +++ b/src/OpenClaw.Core/Abstractions/ITool.cs @@ -14,3 +14,12 @@ public interface ITool /// Execute the tool with the given JSON arguments. ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct); } + +/// +/// Optional structured output contract for tools that return stable JSON values. +/// +public interface IToolOutputSchema +{ + /// JSON schema describing the tool's structured result. + string? OutputSchema { get; } +} diff --git a/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs b/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs index 978c49d2..5f0429eb 100644 --- a/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs +++ b/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs @@ -124,6 +124,8 @@ private static string BuildSummary(CompatibilityCatalogManifestEntry entry, stri => "Pinned TypeScript bridge plugin expected to load when jiti is present in the plugin dependency tree.", "config-schema-plugin" => "Negative compatibility scenario proving that invalid plugin config fails fast with structured diagnostics.", + "cli-plugin" when compatibilityStatus.Equals("compatible", StringComparison.OrdinalIgnoreCase) + => "Pinned upstream plugin expected to load and expose its lazy root CLI command through the Node bridge.", "unsupported-surface-plugin" => "Negative compatibility scenario proving that unsupported upstream plugin surfaces fail explicitly instead of loading partially.", _ when compatibilityStatus.Equals("compatible", StringComparison.OrdinalIgnoreCase) @@ -162,9 +164,6 @@ private static IEnumerable BuildGuidance(CompatibilityCatalogManifestEnt yield return $"Expected failure diagnostics: {string.Join(", ", entry.ExpectedDiagnosticCodes)}."; } - if (entry.ExpectedDiagnosticCodes?.Any(code => string.Equals(code, "unsupported_cli_registration", StringComparison.Ordinal)) == true) - yield return "This package depends on `api.registerCli()`, which OpenClaw.NET does not bridge today."; - if (entry.ExpectedDiagnosticCodes?.Any(code => string.Equals(code, "config_one_of_mismatch", StringComparison.Ordinal)) == true) yield return "Adjust the plugin config to the supported JSON-schema subset; this scenario intentionally demonstrates a failing shape."; } diff --git a/src/OpenClaw.Core/Models/OperatorApiModels.cs b/src/OpenClaw.Core/Models/OperatorApiModels.cs index b6cf2dce..6c00e726 100644 --- a/src/OpenClaw.Core/Models/OperatorApiModels.cs +++ b/src/OpenClaw.Core/Models/OperatorApiModels.cs @@ -118,6 +118,7 @@ public sealed class PluginHealthSnapshot { public required string PluginId { get; init; } public required string Origin { get; init; } + public string? BundleFormat { get; init; } public bool Loaded { get; init; } public bool BlockedByRuntimeMode { get; init; } public bool Disabled { get; init; } @@ -145,6 +146,7 @@ public sealed class PluginHealthSnapshot public int ToolCount { get; init; } public int ChannelCount { get; init; } public int CommandCount { get; init; } + public int CliCommandCount { get; init; } public int ProviderCount { get; init; } public IReadOnlyList BudgetViolations { get; init; } = []; public IReadOnlyList Diagnostics { get; init; } = []; diff --git a/src/OpenClaw.Core/Models/Session.cs b/src/OpenClaw.Core/Models/Session.cs index b7df2d26..7738ae7a 100644 --- a/src/OpenClaw.Core/Models/Session.cs +++ b/src/OpenClaw.Core/Models/Session.cs @@ -1085,6 +1085,8 @@ public sealed class SessionDelegationChildSummary [JsonSerializable(typeof(BridgeChannelRegistration[]))] [JsonSerializable(typeof(BridgeCommandRegistration))] [JsonSerializable(typeof(BridgeCommandRegistration[]))] +[JsonSerializable(typeof(BridgeCliCommandRegistration))] +[JsonSerializable(typeof(BridgeCliCommandRegistration[]))] [JsonSerializable(typeof(BridgeProviderRegistration))] [JsonSerializable(typeof(BridgeProviderRegistration[]))] [JsonSerializable(typeof(BridgeProviderRequest))] diff --git a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs new file mode 100644 index 00000000..3de32fca --- /dev/null +++ b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs @@ -0,0 +1,262 @@ +using System.Text.Json; + +namespace OpenClaw.Core.Plugins; + +/// +/// Detects Codex, Claude, and Cursor compatible content bundles without loading +/// arbitrary JavaScript from them. Native plugin detection always runs first. +/// +internal static class PluginBundleDetector +{ + private const string CodexManifest = ".codex-plugin/plugin.json"; + private const string ClaudeManifest = ".claude-plugin/plugin.json"; + private const string CursorManifest = ".cursor-plugin/plugin.json"; + + public static bool TryDetect( + string rootPath, + out DiscoveredPlugin? plugin, + out PluginCompatibilityDiagnostic? diagnostic) + { + plugin = null; + diagnostic = null; + + var bundleFormat = DetectFormat(rootPath, out var manifestRelativePath); + if (bundleFormat is null) + return false; + + JsonDocument? manifestDocument = null; + try + { + if (manifestRelativePath is not null) + { + var manifestPath = Path.Combine(rootPath, manifestRelativePath.Replace('/', Path.DirectorySeparatorChar)); + try + { + manifestDocument = JsonDocument.Parse(File.ReadAllText(manifestPath)); + } + catch (Exception ex) + { + diagnostic = new PluginCompatibilityDiagnostic + { + Code = "invalid_bundle_manifest", + Message = $"Failed to parse {bundleFormat} bundle manifest '{manifestPath}': {ex.Message}", + Surface = "bundle_manifest", + Path = manifestPath + }; + return true; + } + } + + var manifestRoot = manifestDocument?.RootElement; + var rawId = GetString(manifestRoot, "id") + ?? GetString(manifestRoot, "name") + ?? Path.GetFileName(Path.TrimEndingDirectorySeparator(rootPath)); + var pluginId = NormalizeId(rawId); + if (string.IsNullOrWhiteSpace(pluginId)) + pluginId = $"{bundleFormat}-bundle"; + + var skillRoots = new HashSet(StringComparer.Ordinal); + var commandRoots = new HashSet(StringComparer.Ordinal); + AddDefaultDirectory(rootPath, "skills", skillRoots); + AddManifestPaths(manifestRoot, "skills", skillRoots); + + if (bundleFormat == "claude") + { + AddDefaultDirectory(rootPath, "commands", commandRoots); + AddManifestPaths(manifestRoot, "commands", commandRoots); + } + else if (bundleFormat == "cursor") + { + AddDefaultDirectory(rootPath, ".cursor/commands", commandRoots); + AddManifestPaths(manifestRoot, "commands", commandRoots); + } + + var mappedCapabilities = new List(); + if (skillRoots.Count > 0) + mappedCapabilities.Add("skills"); + if (commandRoots.Count > 0) + mappedCapabilities.Add("commands"); + + var detectedOnly = DetectNonMappedCapabilities(rootPath, bundleFormat, manifestRoot); + plugin = new DiscoveredPlugin + { + Manifest = new PluginManifest + { + Id = pluginId, + Name = GetString(manifestRoot, "displayName") ?? GetString(manifestRoot, "name") ?? pluginId, + Description = GetString(manifestRoot, "description"), + Version = GetString(manifestRoot, "version"), + Skills = + [ + .. skillRoots.OrderBy(static path => path, StringComparer.Ordinal), + .. commandRoots.OrderBy(static path => path, StringComparer.Ordinal) + ] + }, + RootPath = Path.GetFullPath(rootPath), + EntryPath = string.Empty, + Format = PluginFormats.Bundle, + BundleFormat = bundleFormat, + BundleMappedCapabilities = [.. mappedCapabilities.OrderBy(static item => item, StringComparer.Ordinal)], + BundleDetectedCapabilities = detectedOnly + }; + return true; + } + finally + { + manifestDocument?.Dispose(); + } + } + + private static string? DetectFormat(string rootPath, out string? manifestRelativePath) + { + if (File.Exists(Path.Combine(rootPath, CodexManifest.Replace('/', Path.DirectorySeparatorChar)))) + { + manifestRelativePath = CodexManifest; + return "codex"; + } + + if (File.Exists(Path.Combine(rootPath, ClaudeManifest.Replace('/', Path.DirectorySeparatorChar)))) + { + manifestRelativePath = ClaudeManifest; + return "claude"; + } + + if (File.Exists(Path.Combine(rootPath, CursorManifest.Replace('/', Path.DirectorySeparatorChar)))) + { + manifestRelativePath = CursorManifest; + return "cursor"; + } + + manifestRelativePath = null; + var cursorRoot = Path.Combine(rootPath, ".cursor"); + if (Directory.Exists(cursorRoot) && + (Directory.Exists(Path.Combine(cursorRoot, "commands")) || + Directory.Exists(Path.Combine(cursorRoot, "agents")) || + Directory.Exists(Path.Combine(cursorRoot, "rules")) || + File.Exists(Path.Combine(cursorRoot, "hooks.json")))) + { + return "cursor"; + } + + if (Directory.Exists(Path.Combine(rootPath, "skills")) || + Directory.Exists(Path.Combine(rootPath, "commands")) || + Directory.Exists(Path.Combine(rootPath, "agents")) || + Directory.Exists(Path.Combine(rootPath, "hooks")) || + File.Exists(Path.Combine(rootPath, ".mcp.json")) || + File.Exists(Path.Combine(rootPath, ".lsp.json")) || + File.Exists(Path.Combine(rootPath, "settings.json"))) + { + return "claude"; + } + + return null; + } + + private static string[] DetectNonMappedCapabilities( + string rootPath, + string bundleFormat, + JsonElement? manifestRoot) + { + var capabilities = new HashSet(StringComparer.Ordinal); + AddIfPresent(rootPath, "hooks", capabilities, "hooks"); + AddIfPresent(rootPath, ".mcp.json", capabilities, "mcp"); + AddIfPresent(rootPath, ".app.json", capabilities, "app_metadata"); + + if (bundleFormat == "claude") + { + AddIfPresent(rootPath, "agents", capabilities, "agents"); + AddIfPresent(rootPath, "outputStyles", capabilities, "output_styles"); + AddIfPresent(rootPath, ".lsp.json", capabilities, "lsp"); + AddIfPresent(rootPath, "settings.json", capabilities, "settings"); + AddIfPresent(rootPath, "hooks/hooks.json", capabilities, "hook_automation"); + } + else if (bundleFormat == "cursor") + { + AddIfPresent(rootPath, ".cursor/agents", capabilities, "agents"); + AddIfPresent(rootPath, ".cursor/rules", capabilities, "rules"); + AddIfPresent(rootPath, ".cursor/hooks.json", capabilities, "hook_automation"); + } + + foreach (var (propertyName, capability) in new[] + { + ("hooks", "hooks"), + ("mcpServers", "mcp"), + ("lspServers", "lsp"), + ("settings", "settings"), + ("agents", "agents"), + ("outputStyles", "output_styles"), + ("rules", "rules") + }) + { + if (manifestRoot is { ValueKind: JsonValueKind.Object } root && root.TryGetProperty(propertyName, out _)) + capabilities.Add(capability); + } + + return [.. capabilities.OrderBy(static item => item, StringComparer.Ordinal)]; + } + + private static void AddDefaultDirectory(string rootPath, string relativePath, ISet paths) + { + var fullPath = Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (Directory.Exists(fullPath)) + paths.Add(relativePath); + } + + private static void AddManifestPaths(JsonElement? manifestRoot, string propertyName, ISet paths) + { + if (manifestRoot is not { ValueKind: JsonValueKind.Object } root || + !root.TryGetProperty(propertyName, out var property)) + { + return; + } + + if (property.ValueKind == JsonValueKind.String) + { + AddPath(property.GetString(), paths); + return; + } + + if (property.ValueKind != JsonValueKind.Array) + return; + + foreach (var item in property.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + AddPath(item.GetString(), paths); + } + } + + private static void AddPath(string? path, ISet paths) + { + if (string.IsNullOrWhiteSpace(path)) + return; + + var normalized = path.Replace('\\', '/').Trim(); + while (normalized.StartsWith("./", StringComparison.Ordinal)) + normalized = normalized[2..]; + if (normalized.Length > 0) + paths.Add(normalized); + } + + private static void AddIfPresent(string rootPath, string relativePath, ISet capabilities, string capability) + { + var path = Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (File.Exists(path) || Directory.Exists(path)) + capabilities.Add(capability); + } + + private static string? GetString(JsonElement? element, string propertyName) + => element is { ValueKind: JsonValueKind.Object } value && + value.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static string NormalizeId(string value) + => value.Trim() + .Replace('@', ' ') + .Replace('/', '-') + .Replace('\\', '-') + .Replace(' ', '-') + .Trim('-'); +} diff --git a/src/OpenClaw.Core/Plugins/PluginCapabilityPolicy.cs b/src/OpenClaw.Core/Plugins/PluginCapabilityPolicy.cs index a6f4376c..9edec300 100644 --- a/src/OpenClaw.Core/Plugins/PluginCapabilityPolicy.cs +++ b/src/OpenClaw.Core/Plugins/PluginCapabilityPolicy.cs @@ -15,6 +15,7 @@ public enum ExecutionHostKind public const string Skills = "skills"; public const string Channels = "channels"; public const string Commands = "commands"; + public const string Cli = "cli"; public const string Providers = "providers"; public const string Hooks = "hooks"; public const string Memory = "memory"; diff --git a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs index 23b8ac4f..7ccf1b62 100644 --- a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs +++ b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs @@ -113,21 +113,13 @@ private static void ScanExtensionsDirectory(string extensionsDir, HashSet seen, PluginDiscoveryResult result) @@ -140,17 +132,73 @@ private static void ScanDirectory(string dir, HashSet seen, PluginDiscov return; } - // Check for package pack (package.json with openclaw.extensions) + // Native package metadata takes precedence over compatible bundle markers. var packageJsonPath = Path.Combine(dir, PackageJsonFileName); - if (File.Exists(packageJsonPath)) + if (File.Exists(packageJsonPath) && TryAddPluginPack(dir, packageJsonPath, seen, result)) + return; + + if (PluginBundleDetector.TryDetect(dir, out var bundle, out var bundleDiagnostic)) + { + if (bundleDiagnostic is not null) + { + result.Reports.Add(new PluginLoadReport + { + PluginId = Path.GetFileName(dir), + SourcePath = Path.GetFullPath(dir), + EntryPath = null, + Origin = PluginFormats.Bundle, + Loaded = false, + Diagnostics = [bundleDiagnostic] + }); + return; + } + + if (bundle is not null && seen.Add(bundle.Manifest.Id)) + { + result.Plugins.Add(bundle); + } + else if (bundle is not null) + { + result.Reports.Add(new PluginLoadReport + { + PluginId = bundle.Manifest.Id, + SourcePath = bundle.RootPath, + EntryPath = null, + Origin = PluginFormats.Bundle, + Loaded = false, + Diagnostics = + [ + new PluginCompatibilityDiagnostic + { + Code = "duplicate_plugin_id", + Message = $"Plugin id '{bundle.Manifest.Id}' was discovered more than once. Later entries are skipped.", + Surface = "bundle_manifest", + Path = bundle.RootPath + } + ] + }); + } + return; + } + + foreach (var candidate in new[] { "index.js", "index.mjs", "index.cjs", "index.ts" }) { - TryAddPluginPack(dir, packageJsonPath, seen, result); + var entryPath = Path.Combine(dir, candidate); + if (!File.Exists(entryPath)) + continue; + + TryAddPluginFromFile(entryPath, seen, result); return; } // Scan subdirectories foreach (var subDir in Directory.EnumerateDirectories(dir)) + { + var name = Path.GetFileName(subDir); + if (name is "node_modules" or ".git") + continue; ScanDirectory(subDir, seen, result); + } } private static void TryAddPluginFromFile(string filePath, HashSet seen, PluginDiscoveryResult result) @@ -171,11 +219,16 @@ private static void TryAddPluginFromFile(string filePath, HashSet seen, if (!seen.Add(id)) return; + var packageMetadata = ReadPackageMetadata(dir); + result.Plugins.Add(new DiscoveredPlugin { Manifest = new PluginManifest { Id = id }, RootPath = dir, - EntryPath = Path.GetFullPath(filePath) + EntryPath = Path.GetFullPath(filePath), + PluginApiRange = packageMetadata.PluginApiRange, + MinHostVersion = packageMetadata.MinHostVersion, + ExpectedIntegrity = packageMetadata.ExpectedIntegrity }); } } @@ -248,7 +301,7 @@ private static void TryAddPluginFromManifest(string pluginRoot, string manifestP new PluginCompatibilityDiagnostic { Code = entryDiagnostic?.Code ?? "entry_not_found", - Message = entryDiagnostic?.Message ?? $"No plugin entry file was found for '{manifest.Id}'. Expected index.ts, index.js, index.mjs, src/index.*, or a package.json openclaw.extensions entry.", + Message = entryDiagnostic?.Message ?? $"No plugin entry file was found for '{manifest.Id}'. Expected index.js/mjs/cjs/ts, src/index.*, or a package.json openclaw.runtimeExtensions/openclaw.extensions entry.", Path = entryDiagnostic?.Path ?? Path.GetFullPath(pluginRoot) } ] @@ -277,15 +330,19 @@ private static void TryAddPluginFromManifest(string pluginRoot, string manifestP return; } + var packageMetadata = ReadPackageMetadata(pluginRoot); result.Plugins.Add(new DiscoveredPlugin { Manifest = manifest, RootPath = Path.GetFullPath(pluginRoot), - EntryPath = containedEntryPath + EntryPath = containedEntryPath, + PluginApiRange = packageMetadata.PluginApiRange, + MinHostVersion = packageMetadata.MinHostVersion, + ExpectedIntegrity = packageMetadata.ExpectedIntegrity }); } - private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet seen, PluginDiscoveryResult result) + private static bool TryAddPluginPack(string dir, string packageJsonPath, HashSet seen, PluginDiscoveryResult result) { try { @@ -294,11 +351,11 @@ private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet var root = doc.RootElement; if (!root.TryGetProperty("openclaw", out var ocProp)) - return; - if (!ocProp.TryGetProperty("extensions", out var extProp)) - return; + return false; + if (!TryGetRuntimeEntryArray(ocProp, out var extProp)) + return false; if (extProp.ValueKind != JsonValueKind.Array) - return; + return false; var packName = root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? Path.GetFileName(dir) @@ -401,13 +458,19 @@ private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet manifest = new PluginManifest { Id = pluginId }; } + var packageMetadata = ReadPackageMetadata(dir); result.Plugins.Add(new DiscoveredPlugin { Manifest = manifest, RootPath = Path.GetFullPath(dir), - EntryPath = entryPath + EntryPath = entryPath, + PluginApiRange = packageMetadata.PluginApiRange, + MinHostVersion = packageMetadata.MinHostVersion, + ExpectedIntegrity = packageMetadata.ExpectedIntegrity }); } + + return true; } catch { @@ -427,27 +490,14 @@ private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet } ] }); + return true; } } private static string? FindEntryFile(string pluginRoot, out PluginCompatibilityDiagnostic? diagnostic) { diagnostic = null; - // Check common entry points - string[] candidates = - [ - "index.ts", "index.js", "index.mjs", - "src/index.ts", "src/index.js", "src/index.mjs" - ]; - - foreach (var candidate in candidates) - { - var path = Path.Combine(pluginRoot, candidate); - if (File.Exists(path)) - return path; - } - - // Check package.json for openclaw.extensions + // Installed packages publish built runtime entries separately from source entries. var packageJson = Path.Combine(pluginRoot, PackageJsonFileName); if (File.Exists(packageJson)) { @@ -458,8 +508,7 @@ private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet var root = doc.RootElement; if (root.TryGetProperty("openclaw", out var ocProp) && - ocProp.TryGetProperty("extensions", out var extProp) && - extProp.ValueKind == JsonValueKind.Array) + TryGetRuntimeEntryArray(ocProp, out var extProp)) { foreach (var ext in extProp.EnumerateArray()) { @@ -489,8 +538,22 @@ private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet } } + // Check common entry points after package-owned runtime metadata. + string[] candidates = + [ + "index.js", "index.mjs", "index.cjs", "index.ts", + "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" + ]; + + foreach (var candidate in candidates) + { + var path = Path.Combine(pluginRoot, candidate); + if (File.Exists(path)) + return path; + } + // Fallback: any .ts, .js, or .mjs file in root - foreach (var ext in new[] { "*.ts", "*.js", "*.mjs" }) + foreach (var ext in new[] { "*.js", "*.mjs", "*.cjs", "*.ts" }) { var files = Directory.GetFiles(pluginRoot, ext); if (files.Length == 1) @@ -500,6 +563,60 @@ private static void TryAddPluginPack(string dir, string packageJsonPath, HashSet return null; } + private static bool TryGetRuntimeEntryArray(JsonElement openClaw, out JsonElement entries) + { + if (openClaw.TryGetProperty("runtimeExtensions", out entries) && entries.ValueKind == JsonValueKind.Array) + return true; + + return openClaw.TryGetProperty("extensions", out entries) && entries.ValueKind == JsonValueKind.Array; + } + + private static PluginPackageMetadata ReadPackageMetadata(string pluginRoot) + { + var packageJson = Path.Combine(pluginRoot, PackageJsonFileName); + if (!File.Exists(packageJson)) + return new PluginPackageMetadata(); + + try + { + using var stream = File.OpenRead(packageJson); + using var document = JsonDocument.Parse(stream); + if (!document.RootElement.TryGetProperty("openclaw", out var openClaw)) + return new PluginPackageMetadata(); + + string? pluginApiRange = null; + string? minHostVersion = null; + string? expectedIntegrity = null; + if (openClaw.TryGetProperty("compat", out var compat) && compat.ValueKind == JsonValueKind.Object) + { + pluginApiRange = GetString(compat, "pluginApi"); + minHostVersion = GetString(compat, "minGatewayVersion"); + } + + if (openClaw.TryGetProperty("install", out var install) && install.ValueKind == JsonValueKind.Object) + { + minHostVersion ??= GetString(install, "minHostVersion"); + expectedIntegrity = GetString(install, "expectedIntegrity"); + } + + return new PluginPackageMetadata(pluginApiRange, minHostVersion, expectedIntegrity); + } + catch + { + return new PluginPackageMetadata(); + } + } + + private static string? GetString(JsonElement value, string propertyName) + => value.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private sealed record PluginPackageMetadata( + string? PluginApiRange = null, + string? MinHostVersion = null, + string? ExpectedIntegrity = null); + public static bool TryResolveContainedPath(string rootPath, string relativePath, out string resolvedPath) { if (Path.IsPathRooted(relativePath)) diff --git a/src/OpenClaw.Core/Plugins/PluginModels.cs b/src/OpenClaw.Core/Plugins/PluginModels.cs index c7796e8f..a6b46be6 100644 --- a/src/OpenClaw.Core/Plugins/PluginModels.cs +++ b/src/OpenClaw.Core/Plugins/PluginModels.cs @@ -39,6 +39,18 @@ public sealed class PluginManifest /// UI hints for config rendering. public JsonElement? UiHints { get; init; } + + /// Static activation metadata published by newer OpenClaw plugins. + public JsonElement? Activation { get; init; } + + /// Static capability ownership metadata published by newer OpenClaw plugins. + public JsonElement? Contracts { get; init; } + + /// Channel configuration metadata available before plugin activation. + public JsonElement? ChannelConfigs { get; init; } + + /// Setup and onboarding metadata available before plugin activation. + public JsonElement? Setup { get; init; } } /// @@ -53,6 +65,33 @@ public sealed class DiscoveredPlugin /// Absolute path to the plugin entry file (TypeScript/JavaScript). public required string EntryPath { get; init; } + + /// Discovery format: native or bundle. + public string Format { get; init; } = PluginFormats.Native; + + /// Compatible bundle ecosystem: codex, claude, or cursor. + public string? BundleFormat { get; init; } + + /// Bundle capabilities mapped into native OpenClaw.NET features. + public string[] BundleMappedCapabilities { get; init; } = []; + + /// Bundle capabilities detected for operator visibility but not executed. + public string[] BundleDetectedCapabilities { get; init; } = []; + + /// Minimum plugin API range declared in package.json openclaw.compat.pluginApi. + public string? PluginApiRange { get; init; } + + /// Minimum host version declared by package metadata. + public string? MinHostVersion { get; init; } + + /// Expected package integrity declared by package metadata. + public string? ExpectedIntegrity { get; init; } +} + +public static class PluginFormats +{ + public const string Native = "native"; + public const string Bundle = "bundle"; } /// @@ -699,6 +738,7 @@ public sealed class PluginLoadReport public required string SourcePath { get; init; } public string? EntryPath { get; init; } public string Origin { get; init; } = "bridge"; + public string? BundleFormat { get; init; } public bool Loaded { get; init; } public string EffectiveRuntimeMode { get; init; } = "jit"; public string[] RequestedCapabilities { get; init; } = []; @@ -707,6 +747,7 @@ public sealed class PluginLoadReport public int ToolCount { get; init; } public int ChannelCount { get; init; } public int CommandCount { get; init; } + public int CliCommandCount { get; init; } public int EventSubscriptionCount { get; init; } public int ProviderCount { get; init; } public string[] SkillDirectories { get; init; } = []; @@ -725,6 +766,9 @@ public sealed class PluginToolRegistration /// JSON Schema for tool parameters. public required JsonElement Parameters { get; init; } + /// Optional JSON Schema for the tool's structured result. + public JsonElement? OutputSchema { get; init; } + /// Whether this tool is optional (opt-in only). public bool Optional { get; init; } } @@ -766,6 +810,7 @@ public sealed class BridgeInitResult public PluginToolRegistration[] Tools { get; init; } = []; public BridgeChannelRegistration[] Channels { get; init; } = []; public BridgeCommandRegistration[] Commands { get; init; } = []; + public BridgeCliCommandRegistration[] CliCommands { get; init; } = []; public string[] EventSubscriptions { get; init; } = []; public BridgeProviderRegistration[] Providers { get; init; } = []; public string[] Capabilities { get; init; } = []; @@ -854,6 +899,15 @@ public sealed class BridgeCommandRegistration public string Description { get; init; } = ""; } +/// +/// Root CLI command registered by a bridge plugin through registerCli(). +/// +public sealed class BridgeCliCommandRegistration +{ + public required string Name { get; init; } + public string Description { get; init; } = ""; +} + /// /// Provider registration from a plugin bridge. /// @@ -1067,6 +1121,7 @@ public sealed class BridgeHookAfterRequest public sealed class BridgeToolResult { public ToolContentItem[] Content { get; init; } = []; + public JsonElement? Details { get; init; } } /// diff --git a/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs b/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs new file mode 100644 index 00000000..a3bb1f6c --- /dev/null +++ b/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs @@ -0,0 +1,89 @@ +namespace OpenClaw.Core.Plugins; + +/// +/// Validates package-declared bridge API and host version floors before plugin code runs. +/// +public static class PluginPackageCompatibility +{ + // This host implements the upstream bridge surface exercised by the 2026.5.4 + // public compatibility fixtures. Raise deliberately as newer SDK contracts land. + public static readonly Version SupportedPluginApiVersion = new(2026, 5, 4); + public static readonly Version HostCompatibilityVersion = new(2026, 5, 4); + + public static IReadOnlyList Validate(DiscoveredPlugin plugin) + => Validate(plugin.PluginApiRange, plugin.MinHostVersion, plugin.Manifest.Id, plugin.RootPath); + + public static IReadOnlyList Validate( + string? pluginApiRange, + string? minHostVersion, + string pluginId, + string path) + { + var diagnostics = new List(); + ValidateFloor( + pluginApiRange, + SupportedPluginApiVersion, + "plugin_api_version_unsupported", + "plugin API", + pluginId, + path, + diagnostics); + ValidateFloor( + minHostVersion, + HostCompatibilityVersion, + "host_version_unsupported", + "host", + pluginId, + path, + diagnostics); + return diagnostics; + } + + private static void ValidateFloor( + string? declaredRange, + Version supportedVersion, + string diagnosticCode, + string label, + string pluginId, + string path, + ICollection diagnostics) + { + if (string.IsNullOrWhiteSpace(declaredRange)) + return; + + var normalized = declaredRange.Trim(); + if (normalized.StartsWith(">=", StringComparison.Ordinal)) + normalized = normalized[2..].Trim(); + else if (normalized.StartsWith('v')) + normalized = normalized[1..]; + + var suffixIndex = normalized.IndexOfAny(['-', '+', ' ', '<', '>', '|']); + if (suffixIndex >= 0) + normalized = normalized[..suffixIndex]; + + if (!Version.TryParse(normalized, out var requiredVersion)) + { + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "error", + Code = "invalid_plugin_version_range", + Message = $"Plugin '{pluginId}' declares an unsupported {label} version range '{declaredRange}'.", + Surface = "package_metadata", + Path = path + }); + return; + } + + if (requiredVersion <= supportedVersion) + return; + + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "error", + Code = diagnosticCode, + Message = $"Plugin '{pluginId}' requires {label} version {requiredVersion}, but this bridge supports {supportedVersion}.", + Surface = "package_metadata", + Path = path + }); + } +} diff --git a/src/OpenClaw.Core/Skills/SkillLoader.cs b/src/OpenClaw.Core/Skills/SkillLoader.cs index acc4632d..089b059b 100644 --- a/src/OpenClaw.Core/Skills/SkillLoader.cs +++ b/src/OpenClaw.Core/Skills/SkillLoader.cs @@ -174,6 +174,12 @@ private static void ScanDirectory( { try { + if (source == SkillSource.Plugin && + string.Equals(Path.GetFileName(Path.TrimEndingDirectorySeparator(rootDir)), "commands", StringComparison.OrdinalIgnoreCase)) + { + ScanBundleCommandFiles(rootDir, results, logger); + } + var rootSkillFile = Path.Combine(rootDir, "SKILL.md"); if (File.Exists(rootSkillFile)) { @@ -250,6 +256,72 @@ private static void ScanDirectory( } } + private static void ScanBundleCommandFiles( + string commandRoot, + IDictionary results, + ILogger logger) + { + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + foreach (var commandFile in Directory.EnumerateFiles(commandRoot, "*.md", options) + .OrderBy(static path => path, StringComparer.Ordinal)) + { + if (string.Equals(Path.GetFileName(commandFile), "SKILL.md", StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + var commandName = Path.GetFileNameWithoutExtension(commandFile); + var content = File.ReadAllText(commandFile); + var normalized = NormalizeBundleCommandContent(content, commandName); + var commandDir = Path.GetDirectoryName(commandFile) ?? commandRoot; + var skill = ParseSkillContent(normalized, commandDir, SkillSource.Plugin); + if (skill is not null) + results[skill.Name] = skill; + else + logger.LogWarning("Failed to map bundle command at {Path} into a skill", commandFile); + } + catch (Exception ex) when (IsPathException(ex)) + { + logger.LogWarning(ex, "Skipping inaccessible bundle command at {Path}", commandFile); + } + } + } + + private static string NormalizeBundleCommandContent(string content, string commandName) + { + if (content.StartsWith("---", StringComparison.Ordinal) && + content.IndexOf("\n---", 3, StringComparison.Ordinal) >= 0) + { + var frontmatterEnd = content.IndexOf("\n---", 3, StringComparison.Ordinal); + var frontmatter = content[3..frontmatterEnd]; + if (!frontmatter.Split('\n').Any(static line => + line.TrimStart().StartsWith("name:", StringComparison.OrdinalIgnoreCase))) + { + var firstNewline = content.IndexOf('\n'); + return firstNewline >= 0 + ? content.Insert(firstNewline + 1, $"name: {commandName}\n") + : content; + } + + return content; + } + + return $""" + --- + name: {commandName} + description: Imported bundle command {commandName} + user-invocable: true + --- + {content} + """; + } + private static string[] EnumerateSkillDirectories( string rootDir, string searchPattern, diff --git a/src/OpenClaw.Dashboard/Models/PluginInfo.cs b/src/OpenClaw.Dashboard/Models/PluginInfo.cs index 2c1288b7..b90b724b 100644 --- a/src/OpenClaw.Dashboard/Models/PluginInfo.cs +++ b/src/OpenClaw.Dashboard/Models/PluginInfo.cs @@ -1,12 +1,29 @@ namespace OpenClaw.Dashboard.Models; public record PluginInfo( - string Id, - string? Name, - string? Description, - bool Enabled, - string? Version -); + string PluginId, + string Origin, + string? BundleFormat, + bool Loaded, + bool Disabled, + bool Quarantined, + bool Reviewed, + string TrustLevel, + string CompatibilityStatus, + int ErrorCount, + int WarningCount, + string DeclaredSurface, + string? PendingReason, + string? LastError, + int RestartCount, + int ToolCount, + int ChannelCount, + int ProviderCount) +{ + public bool Enabled => !Disabled && !Quarantined; + public string Status => Quarantined ? "quarantined" : Disabled ? "disabled" : Loaded ? "loaded" : "not loaded"; + public string Detail => LastError ?? PendingReason ?? DeclaredSurface; +} public record ApprovalPolicy( string? Id, diff --git a/src/OpenClaw.Dashboard/Pages/Ops.razor b/src/OpenClaw.Dashboard/Pages/Ops.razor index aa9618ee..b6e8a0c2 100644 --- a/src/OpenClaw.Dashboard/Pages/Ops.razor +++ b/src/OpenClaw.Dashboard/Pages/Ops.razor @@ -58,47 +58,70 @@ else Elevation="0" Class="ops-grid"> - +
- @(string.IsNullOrEmpty(context.Item.Name) ? context.Item.Id : context.Item.Name) + @context.Item.PluginId
- + - @(context.Item.Description ?? "—") + @context.Item.Detail + + + @context.Item.Origin@(context.Item.BundleFormat is null ? "" : $"/{context.Item.BundleFormat}") · @context.Item.TrustLevel · @context.Item.CompatibilityStatus - + - @(context.Item.Version ?? "—") + T @context.Item.ToolCount · C @context.Item.ChannelCount · P @context.Item.ProviderCount - + - @(context.Item.Enabled ? L["common.enabled"] : L["common.disabled"]) + Color="@(context.Item.Loaded && context.Item.Enabled ? Color.Success : context.Item.Quarantined ? Color.Error : Color.Default)" + Variant="@(context.Item.Loaded && context.Item.Enabled ? Variant.Filled : Variant.Outlined)"> + @context.Item.Status - - @(context.Item.Enabled ? L["common.disable"] : L["common.enable"]) - +
+ @if (context.Item.Quarantined) + { + + Clear quarantine + + } + else + { + + @(context.Item.Disabled ? L["common.enable"] : L["common.disable"]) + + } + + @(context.Item.Reviewed ? "Clear review" : "Mark reviewed") + +
@@ -307,7 +330,7 @@ else var deadLettersTask = Api.GetRawAsync("/admin/webhooks/dead-letter"); await Task.WhenAll(pluginsTask, policiesTask, deadLettersTask); - _plugins = await ParseListAsync(pluginsTask.Result, "plugins"); + _plugins = await ParseListAsync(pluginsTask.Result, "items"); _policies = await ParseListAsync(policiesTask.Result, "policies"); _deadLetters = await ParseListAsync(deadLettersTask.Result, "items"); } @@ -367,10 +390,10 @@ else } } - private async Task TogglePlugin(PluginInfo plugin) + private async Task MutatePlugin(PluginInfo plugin, string action) { - var action = plugin.Enabled ? "disable" : "enable"; - using var resp = await Api.PostRawAsync($"/admin/plugins/{plugin.Id}/{action}"); + var pluginId = Uri.EscapeDataString(plugin.PluginId); + using var resp = await Api.PostRawAsync($"/admin/plugins/{pluginId}/{action}"); if (resp.IsSuccessStatusCode) { Snackbar.Add(L["common.success"], Severity.Success); diff --git a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.CompositionStages.cs b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.CompositionStages.cs index 0be23d54..54752d63 100644 --- a/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.CompositionStages.cs +++ b/src/OpenClaw.Gateway/Composition/RuntimeInitializationExtensions.CompositionStages.cs @@ -542,6 +542,7 @@ private static IReadOnlyList GetCombinedPluginReports( SourcePath = report.SourcePath, EntryPath = report.EntryPath, Origin = report.Origin, + BundleFormat = report.BundleFormat, Loaded = report.Loaded, EffectiveRuntimeMode = report.EffectiveRuntimeMode, RequestedCapabilities = report.RequestedCapabilities, @@ -550,6 +551,7 @@ private static IReadOnlyList GetCombinedPluginReports( ToolCount = report.ToolCount, ChannelCount = report.ChannelCount, CommandCount = report.CommandCount, + CliCommandCount = report.CliCommandCount, EventSubscriptionCount = report.EventSubscriptionCount, ProviderCount = report.ProviderCount, SkillDirectories = report.SkillDirectories, diff --git a/src/OpenClaw.Gateway/PluginHealthService.cs b/src/OpenClaw.Gateway/PluginHealthService.cs index 82807582..922957e7 100644 --- a/src/OpenClaw.Gateway/PluginHealthService.cs +++ b/src/OpenClaw.Gateway/PluginHealthService.cs @@ -74,6 +74,7 @@ public IReadOnlyList ListSnapshots() { PluginId = report.PluginId, Origin = report.Origin, + BundleFormat = report.BundleFormat, Loaded = report.Loaded, BlockedByRuntimeMode = report.BlockedByRuntimeMode, Disabled = state?.Disabled ?? false, @@ -101,6 +102,7 @@ public IReadOnlyList ListSnapshots() ToolCount = report.ToolCount, ChannelCount = report.ChannelCount, CommandCount = report.CommandCount, + CliCommandCount = report.CliCommandCount, ProviderCount = report.ProviderCount, BudgetViolations = budgetViolations, Diagnostics = report.Diagnostics @@ -145,6 +147,7 @@ public IReadOnlyList ListSnapshots() ToolCount = 0, ChannelCount = 0, CommandCount = 0, + CliCommandCount = 0, ProviderCount = 0, BudgetViolations = [], Diagnostics = [] @@ -392,6 +395,7 @@ private static (string TrustLevel, string TrustReason) DetermineTrust(PluginLoad return ("third-party-reviewed", "Operator marked this plugin as reviewed for deployment."); if (!string.Equals(report.Origin, "bridge", StringComparison.OrdinalIgnoreCase) && + !string.Equals(report.Origin, PluginFormats.Bundle, StringComparison.OrdinalIgnoreCase) && !string.Equals(report.Origin, "unknown", StringComparison.OrdinalIgnoreCase)) { return ("first-party", "Plugin is loaded through a built-in or native runtime path."); @@ -402,6 +406,7 @@ private static (string TrustLevel, string TrustReason) DetermineTrust(PluginLoad report.ToolCount > 0 || report.ChannelCount > 0 || report.CommandCount > 0 || + report.CliCommandCount > 0 || report.ProviderCount > 0 || report.SkillDirectories.Length > 0; var hasErrors = report.Diagnostics.Any(static diagnostic => string.Equals(diagnostic.Severity, "error", StringComparison.OrdinalIgnoreCase)); @@ -430,6 +435,8 @@ private static (string CompatibilityStatus, int ErrorCount, int WarningCount) Su private static string BuildDeclaredSurfaceSummary(PluginLoadReport report) { var items = new List(); + if (string.Equals(report.Origin, PluginFormats.Bundle, StringComparison.OrdinalIgnoreCase)) + items.Add($"bundle={report.BundleFormat ?? "unknown"}"); if (report.RequestedCapabilities.Length > 0) items.Add($"capabilities={string.Join(",", report.RequestedCapabilities)}"); if (report.ToolCount > 0) @@ -438,6 +445,8 @@ private static string BuildDeclaredSurfaceSummary(PluginLoadReport report) items.Add($"channels={report.ChannelCount}"); if (report.CommandCount > 0) items.Add($"commands={report.CommandCount}"); + if (report.CliCommandCount > 0) + items.Add($"cli={report.CliCommandCount}"); if (report.ProviderCount > 0) items.Add($"providers={report.ProviderCount}"); if (report.SkillDirectories.Length > 0) diff --git a/src/OpenClaw.Tests/CompatibilityCommandsTests.cs b/src/OpenClaw.Tests/CompatibilityCommandsTests.cs index 67c3fd39..34176ed9 100644 --- a/src/OpenClaw.Tests/CompatibilityCommandsTests.cs +++ b/src/OpenClaw.Tests/CompatibilityCommandsTests.cs @@ -29,13 +29,13 @@ public void Run_CatalogText_PrintsScenarioSummary() using var output = new StringWriter(); using var error = new StringWriter(); - var exitCode = CompatibilityCommands.Run(["catalog", "--category", "unsupported-surface-plugin"], output, error); + var exitCode = CompatibilityCommands.Run(["catalog", "--category", "cli-plugin"], output, error); Assert.Equal(0, exitCode); var text = output.ToString(); Assert.Contains("supermemory", text, StringComparison.Ordinal); - Assert.Contains("unsupported upstream plugin surfaces fail explicitly", text, StringComparison.OrdinalIgnoreCase); - Assert.Contains("unsupported_cli_registration", text, StringComparison.Ordinal); + Assert.Contains("lazy root CLI command", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("compatible", text, StringComparison.OrdinalIgnoreCase); Assert.Equal(string.Empty, error.ToString()); } } diff --git a/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs b/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs index 47b3b830..35c1ac01 100644 --- a/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs +++ b/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs @@ -124,6 +124,40 @@ public async Task LoadAsync_JsPlugin_RegistersToolAndExecutes() Assert.Single(host.Reports, r => r.PluginId == "js-tool" && r.Loaded); } + [Fact] + public async Task LoadAsync_StructuredTool_PreservesOutputSchemaAndDetails() + { + if (!HasNode()) return; + + var pluginDir = CreatePlugin( + "structured-js-tool", + "index.js", + """ + module.exports = function(api) { + api.registerTool({ + name: "structured_echo", + description: "Structured echo", + parameters: { type: "object", properties: { text: { type: "string" } } }, + outputSchema: { type: "object", properties: { echoed: { type: "string" } }, required: ["echoed"] }, + execute: async (_pluginId, params) => ({ + content: [{ type: "text", text: `Echoed ${params.text}` }], + details: { echoed: params.text } + }) + }); + }; + """); + + await using var host = CreateHost(new PluginsConfig + { + Enabled = true, + Load = new PluginLoadConfig { Paths = [pluginDir] } + }); + + var tool = Assert.IsType(Assert.Single(await host.LoadAsync(null, TestContext.Current.CancellationToken))); + Assert.Contains("\"echoed\"", tool.OutputSchema, StringComparison.Ordinal); + Assert.Equal("{\"echoed\":\"hello\"}", await tool.ExecuteAsync("""{"text":"hello"}""", TestContext.Current.CancellationToken)); + } + [Fact] public async Task LoadAsync_StandaloneMjsPlugin_IsDiscoveredFromWorkspaceExtensions() { @@ -917,6 +951,41 @@ public async Task LoadAsync_RegisterCommand_LoadsSuccessfully() Assert.Contains(PluginCapabilityPolicy.Commands, report.RequestedCapabilities); } + [Fact] + public async Task LoadAsync_RegisterCli_LoadsAndReportsRootDescriptor() + { + if (!HasNode()) return; + + var pluginDir = CreatePlugin( + "cli-plugin", + "index.js", + """ + module.exports = function(api) { + api.registerCli(({ program }) => { + program.command("fixture") + .description("Fixture commands") + .command("hello ") + .option("--loud", "Uppercase the greeting") + .action(async () => {}); + }, { commands: ["fixture"] }); + }; + """); + + await using var host = CreateHost(new PluginsConfig + { + Enabled = true, + Load = new PluginLoadConfig { Paths = [pluginDir] } + }); + + _ = await host.LoadAsync(null, TestContext.Current.CancellationToken); + + var report = Assert.Single(host.Reports, r => r.PluginId == "cli-plugin"); + Assert.True(report.Loaded); + Assert.Equal(1, report.CliCommandCount); + Assert.Contains(PluginCapabilityPolicy.Cli, report.RequestedCapabilities); + Assert.DoesNotContain(report.Diagnostics, item => item.Code == "unsupported_cli_registration"); + } + [Fact] public async Task LoadAsync_RegisterProvider_LoadsSuccessfully() { @@ -1444,9 +1513,14 @@ public async Task BridgeTransportModes_RestartAfterChildExit(string transportMod var tool = Assert.Single(tools); Assert.Equal("echo:first", await tool.ExecuteAsync("""{"text":"first"}""", TestContext.Current.CancellationToken)); - Assert.Equal("restarting", await tool.ExecuteAsync("""{"kill":true}""", TestContext.Current.CancellationToken)); - await Task.Delay(500, TestContext.Current.CancellationToken); - Assert.Equal("echo:second", await tool.ExecuteAsync("""{"text":"second"}""", TestContext.Current.CancellationToken)); + for (var attempt = 1; attempt <= 5; attempt++) + { + Assert.Equal("restarting", await tool.ExecuteAsync("""{"kill":true}""", TestContext.Current.CancellationToken)); + await Task.Delay(150, TestContext.Current.CancellationToken); + Assert.Equal( + $"echo:after-{attempt}", + await tool.ExecuteAsync($$"""{"text":"after-{{attempt}}"}""", TestContext.Current.CancellationToken)); + } } [Theory] diff --git a/src/OpenClaw.Tests/PluginCommandsTests.cs b/src/OpenClaw.Tests/PluginCommandsTests.cs index 91554a8e..9c7269e0 100644 --- a/src/OpenClaw.Tests/PluginCommandsTests.cs +++ b/src/OpenClaw.Tests/PluginCommandsTests.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; +using System.Text.Json; using OpenClaw.Cli; +using OpenClaw.Core.Plugins; using Xunit; namespace OpenClaw.Tests; @@ -35,7 +38,7 @@ public void InspectCandidate_WithManifest_ReturnsUpstreamCompatibleSummary() Assert.True(inspection.CanInstall); Assert.Equal("sample-plugin", inspection.PluginId); Assert.Equal("upstream-compatible", inspection.TrustLevel); - Assert.Equal("verified", inspection.CompatibilityStatus); + Assert.Equal("manifest-valid", inspection.CompatibilityStatus); Assert.Contains("channels=telegram", inspection.DeclaredSurface, StringComparison.Ordinal); Assert.Contains("providers=sample-provider", inspection.DeclaredSurface, StringComparison.Ordinal); Assert.Contains("skills=1", inspection.DeclaredSurface, StringComparison.Ordinal); @@ -46,6 +49,142 @@ public void InspectCandidate_WithManifest_ReturnsUpstreamCompatibleSummary() } } + [Fact] + public void InspectCandidate_WithRegisterCli_AllowsInstall() + { + var root = CreateTempRoot(); + try + { + File.WriteAllText( + Path.Combine(root, "openclaw.plugin.json"), + """{"id":"cli-plugin","configSchema":{"type":"object"}}"""); + File.WriteAllText( + Path.Combine(root, "index.js"), + "module.exports = api => api.registerCli(({ program }) => program.command('fixture'), { commands: ['fixture'] });"); + + var inspection = PluginCommands.InspectCandidate(root, "./cli-plugin", sourceIsNpm: false); + + Assert.True(inspection.Success); + Assert.True(inspection.CanInstall); + Assert.Equal("manifest-valid", inspection.CompatibilityStatus); + Assert.DoesNotContain(inspection.Diagnostics, item => item.Code == "unsupported_cli_registration"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void InspectCandidate_WithNewerPluginApiFloor_BlocksInstall() + { + var root = CreateTempRoot(); + try + { + File.WriteAllText( + Path.Combine(root, "openclaw.plugin.json"), + """{"id":"future-plugin","configSchema":{"type":"object"}}"""); + File.WriteAllText(Path.Combine(root, "dist.js"), "module.exports = () => {};"); + File.WriteAllText( + Path.Combine(root, "package.json"), + """ + { + "name": "future-plugin", + "openclaw": { + "runtimeExtensions": ["./dist.js"], + "compat": { "pluginApi": ">=2026.7.1" } + } + } + """); + + var inspection = PluginCommands.InspectCandidate(root, "./future-plugin", sourceIsNpm: false); + + Assert.False(inspection.CanInstall); + Assert.Contains(inspection.Diagnostics, item => item.Code == "plugin_api_version_unsupported"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task InspectRuntimeAsync_WithRegisterCli_ReturnsDescriptorCount() + { + if (!HasNode()) + return; + + var root = CreateTempRoot(); + try + { + var entryPath = Path.Combine(root, "index.js"); + File.WriteAllText( + entryPath, + "module.exports = api => api.registerCli(({ program }) => program.command('fixture').description('Fixture commands'), { commands: ['fixture'] });"); + + var inspection = await PluginCommands.InspectRuntimeAsync( + entryPath, + "runtime-cli", + TestContext.Current.CancellationToken); + + Assert.True(inspection.Compatible); + Assert.Equal(1, inspection.CliCommandCount); + Assert.DoesNotContain(inspection.Diagnostics, item => item.Code == "unsupported_cli_registration"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task PluginCliCommands_ExecutesNestedCommandWithArgumentsAndOptions() + { + if (!HasNode()) + return; + + var root = CreateTempRoot(); + try + { + var markerPath = Path.Combine(root, "result.txt"); + File.WriteAllText( + Path.Combine(root, "openclaw.plugin.json"), + """{"id":"cli-execution-plugin","configSchema":{"type":"object"}}"""); + File.WriteAllText( + Path.Combine(root, "index.js"), + $$""" + const fs = require("node:fs"); + module.exports = api => api.registerCli(({ program }) => { + const root = program.command("fixture").description("Fixture commands"); + root.command("write") + .argument("", "Value to write") + .option("--upper", "Uppercase the value") + .action(async (value, options) => { + fs.writeFileSync({{JsonSerializer.Serialize(markerPath)}}, options.upper ? value.toUpperCase() : value); + }); + }, { commands: ["fixture"] }); + """); + + var bridgeScript = PluginCommands.ResolveBridgeScriptPath(); + Assert.NotNull(bridgeScript); + var result = await PluginCliCommands.TryRunAsync( + "fixture", + ["write", "hello", "--upper"], + new PluginsConfig { Load = new PluginLoadConfig { Paths = [root] } }, + workspacePath: null, + new HashSet(StringComparer.Ordinal), + bridgeScript, + TestContext.Current.CancellationToken); + + Assert.Equal(0, result); + Assert.Equal("HELLO", File.ReadAllText(markerPath)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void InspectCandidate_WithStandaloneEntry_ReturnsUntrustedWarning() { @@ -68,6 +207,102 @@ public void InspectCandidate_WithStandaloneEntry_ReturnsUntrustedWarning() } } + [Fact] + public void InspectCandidate_WithCompatibleBundle_ReportsMappedAndDetectedCapabilities() + { + var root = CreateTempRoot(); + try + { + Directory.CreateDirectory(Path.Combine(root, ".claude-plugin")); + Directory.CreateDirectory(Path.Combine(root, "commands")); + Directory.CreateDirectory(Path.Combine(root, "agents")); + File.WriteAllText( + Path.Combine(root, ".claude-plugin", "plugin.json"), + "{\"name\":\"claude-value-bundle\",\"version\":\"1.0.0\"}"); + File.WriteAllText(Path.Combine(root, "commands", "summarize.md"), "Summarize the current task."); + + var inspection = PluginCommands.InspectCandidate(root, "./claude-value-bundle", sourceIsNpm: false); + + Assert.True(inspection.Success); + Assert.True(inspection.CanInstall); + Assert.Equal(PluginFormats.Bundle, inspection.Format); + Assert.Equal("claude", inspection.BundleFormat); + Assert.Contains("mapped=commands", inspection.DeclaredSurface, StringComparison.Ordinal); + Assert.Contains("detected_only=agents", inspection.DeclaredSurface, StringComparison.Ordinal); + Assert.Contains(inspection.Diagnostics, item => item.Code == "bundle_capability_detected_only"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task InstallPreparedDirectoryAsync_BundleDoesNotRunNpmLifecycleScripts() + { + var root = CreateTempRoot(); + var targetParent = CreateTempRoot(); + try + { + Directory.CreateDirectory(Path.Combine(root, ".codex-plugin")); + Directory.CreateDirectory(Path.Combine(root, "skills", "safe-bundle")); + File.WriteAllText(Path.Combine(root, ".codex-plugin", "plugin.json"), "{\"name\":\"safe-bundle\"}"); + File.WriteAllText( + Path.Combine(root, "skills", "safe-bundle", "SKILL.md"), + "---\nname: safe-bundle\ndescription: Safe bundle\n---\nUse safe content."); + File.WriteAllText( + Path.Combine(root, "package.json"), + "{\"name\":\"safe-bundle\",\"scripts\":{\"install\":\"node -e \\\"require('fs').writeFileSync('lifecycle-ran','yes')\\\"\"}}"); + var target = Path.Combine(targetParent, "safe-bundle"); + + var result = await PluginCommands.InstallPreparedDirectoryAsync( + root, + target, + "./safe-bundle", + sourceIsNpm: false); + + Assert.True(result.Success, result.Error); + Assert.True(Directory.Exists(target)); + Assert.False(File.Exists(Path.Combine(target, "lifecycle-ran"))); + } + finally + { + Directory.Delete(root, recursive: true); + Directory.Delete(targetParent, recursive: true); + } + } + + [Fact] + public async Task InstallPreparedDirectoryAsync_InvalidBundlePreservesExistingInstall() + { + var root = CreateTempRoot(); + var targetParent = CreateTempRoot(); + try + { + Directory.CreateDirectory(Path.Combine(root, ".claude-plugin")); + File.WriteAllText( + Path.Combine(root, ".claude-plugin", "plugin.json"), + "{\"name\":\"preserved-bundle\",\"skills\":[\"missing-skills\"]}"); + var target = Path.Combine(targetParent, "preserved-bundle"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "sentinel.txt"), "working-version"); + + var result = await PluginCommands.InstallPreparedDirectoryAsync( + root, + target, + "./preserved-bundle", + sourceIsNpm: false); + + Assert.False(result.Success); + Assert.Equal("working-version", File.ReadAllText(Path.Combine(target, "sentinel.txt"))); + } + finally + { + Directory.Delete(root, recursive: true); + Directory.Delete(targetParent, recursive: true); + } + } + [Fact] public void InspectCandidate_WithInvalidConfigSchema_BlocksInstall() { @@ -108,4 +343,26 @@ private static string CreateTempRoot() Directory.CreateDirectory(root); return root; } + + private static bool HasNode() + { + try + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = OperatingSystem.IsWindows() ? "node.exe" : "node", + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }); + process?.WaitForExit(3000); + return process is { ExitCode: 0 }; + } + catch + { + return false; + } + } } diff --git a/src/OpenClaw.Tests/PluginTests.cs b/src/OpenClaw.Tests/PluginTests.cs index b05d9a98..6aa49b55 100644 --- a/src/OpenClaw.Tests/PluginTests.cs +++ b/src/OpenClaw.Tests/PluginTests.cs @@ -2,6 +2,7 @@ using OpenClaw.Core.Models; using OpenClaw.Core.Plugins; using OpenClaw.Agent.Plugins; +using OpenClaw.Core.Skills; using Xunit; namespace OpenClaw.Tests; @@ -43,6 +44,109 @@ public void Discover_FindsPluginWithManifest() Assert.EndsWith("index.ts", discovered[0].EntryPath); } + [Fact] + public void Discover_PrefersBuiltRuntimeExtensionAndReadsCompatibilityMetadata() + { + var pluginDir = Path.Combine(_tempDir, "modern-plugin"); + Directory.CreateDirectory(Path.Combine(pluginDir, "src")); + Directory.CreateDirectory(Path.Combine(pluginDir, "dist")); + File.WriteAllText(Path.Combine(pluginDir, "openclaw.plugin.json"), """{"id":"modern-plugin"}"""); + File.WriteAllText(Path.Combine(pluginDir, "src", "index.ts"), "export default function() {}"); + File.WriteAllText(Path.Combine(pluginDir, "dist", "index.js"), "module.exports = function() {};"); + File.WriteAllText( + Path.Combine(pluginDir, "package.json"), + """ + { + "name": "modern-plugin", + "openclaw": { + "extensions": ["./src/index.ts"], + "runtimeExtensions": ["./dist/index.js"], + "compat": { + "pluginApi": ">=2026.5.4", + "minGatewayVersion": ">=2026.5.4" + }, + "install": { "expectedIntegrity": "sha512-example" } + } + } + """); + + var plugin = Assert.Single(PluginDiscovery.Discover(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [pluginDir] } + })); + + Assert.EndsWith(Path.Combine("dist", "index.js"), plugin.EntryPath); + Assert.Equal(">=2026.5.4", plugin.PluginApiRange); + Assert.Equal(">=2026.5.4", plugin.MinHostVersion); + Assert.Equal("sha512-example", plugin.ExpectedIntegrity); + Assert.Empty(PluginPackageCompatibility.Validate(plugin)); + } + + [Theory] + [InlineData("codex", ".codex-plugin")] + [InlineData("claude", ".claude-plugin")] + [InlineData("cursor", ".cursor-plugin")] + public void Discover_DetectsCompatibleBundleManifests(string bundleFormat, string markerDirectory) + { + var bundleDir = Path.Combine(_tempDir, $"{bundleFormat}-bundle"); + Directory.CreateDirectory(Path.Combine(bundleDir, markerDirectory)); + Directory.CreateDirectory(Path.Combine(bundleDir, "skills", "bundle-skill")); + File.WriteAllText( + Path.Combine(bundleDir, markerDirectory, "plugin.json"), + $$"""{"name":"{{bundleFormat}}-sample","version":"1.0.0"}"""); + File.WriteAllText( + Path.Combine(bundleDir, "skills", "bundle-skill", "SKILL.md"), + "---\nname: bundle-skill\ndescription: Bundle skill\n---\nUse the bundle."); + + var plugin = Assert.Single(PluginDiscovery.Discover(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [bundleDir] } + })); + + Assert.Equal(PluginFormats.Bundle, plugin.Format); + Assert.Equal(bundleFormat, plugin.BundleFormat); + Assert.Contains("skills", plugin.BundleMappedCapabilities); + Assert.Empty(plugin.EntryPath); + } + + [Fact] + public void Discover_ManifestlessClaudeBundle_MapsCommandsAndReportsOtherSurfaces() + { + var bundleDir = Path.Combine(_tempDir, "claude-default-bundle"); + Directory.CreateDirectory(Path.Combine(bundleDir, "commands")); + Directory.CreateDirectory(Path.Combine(bundleDir, "agents")); + File.WriteAllText(Path.Combine(bundleDir, "commands", "review.md"), "Review this change carefully."); + File.WriteAllText(Path.Combine(bundleDir, ".mcp.json"), "{}"); + + var plugin = Assert.Single(PluginDiscovery.Discover(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [bundleDir] } + })); + + Assert.Equal("claude", plugin.BundleFormat); + Assert.Contains("commands", plugin.BundleMappedCapabilities); + Assert.Contains("agents", plugin.BundleDetectedCapabilities); + Assert.Contains("mcp", plugin.BundleDetectedCapabilities); + } + + [Fact] + public void Discover_NativePluginTakesPrecedenceOverBundleMarkers() + { + var pluginDir = Path.Combine(_tempDir, "dual-format"); + Directory.CreateDirectory(Path.Combine(pluginDir, ".claude-plugin")); + File.WriteAllText(Path.Combine(pluginDir, ".claude-plugin", "plugin.json"), "{\"name\":\"bundle-copy\"}"); + File.WriteAllText(Path.Combine(pluginDir, "openclaw.plugin.json"), "{\"id\":\"native-wins\"}"); + File.WriteAllText(Path.Combine(pluginDir, "index.js"), "module.exports = () => {};"); + + var plugin = Assert.Single(PluginDiscovery.Discover(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [pluginDir] } + })); + + Assert.Equal(PluginFormats.Native, plugin.Format); + Assert.Equal("native-wins", plugin.Manifest.Id); + } + [Fact] public void Discover_SkipsBrokenManifestJson() { @@ -352,6 +456,22 @@ public void Constructor_SetsOptional() Assert.True(tool.Optional); } + + [Fact] + public void Constructor_PreservesOutputSchema() + { + var reg = new PluginToolRegistration + { + Name = "structured-tool", + Description = "Structured tool", + Parameters = JsonDocument.Parse("{}").RootElement, + OutputSchema = JsonDocument.Parse("""{"type":"object","properties":{"value":{"type":"string"}}}""").RootElement + }; + + var tool = new BridgedPluginTool(null!, "test-plugin", reg); + + Assert.Contains("\"value\"", tool.OutputSchema, StringComparison.Ordinal); + } } public class PluginHostTests @@ -381,6 +501,49 @@ public async Task LoadAsync_NoPluginsFound_ReturnsEmpty() Assert.Empty(tools); } + [Fact] + public async Task LoadAsync_BundleMapsSkillsWithoutStartingBridgeCode() + { + var root = Path.Combine(Path.GetTempPath(), "openclaw-bundle-host-tests", Guid.NewGuid().ToString("n")); + var skillDir = Path.Combine(root, "skills", "bundle-value"); + Directory.CreateDirectory(Path.Combine(root, ".codex-plugin")); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(root, ".codex-plugin", "plugin.json"), "{\"name\":\"codex-value\"}"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: bundle-value\ndescription: Adds value\n---\nDeliver useful value."); + + try + { + var config = new PluginsConfig { Load = new PluginLoadConfig { Paths = [root] } }; + await using var host = new PluginHost(config, "/nonexistent/bridge.mjs", new TestLogger()); + + var tools = await host.LoadAsync(null, TestContext.Current.CancellationToken); + var report = Assert.Single(host.Reports); + + Assert.Empty(tools); + Assert.True(report.Loaded); + Assert.Equal(PluginFormats.Bundle, report.Origin); + Assert.Equal("codex", report.BundleFormat); + Assert.Contains(host.SkillRoots, path => path.EndsWith(Path.DirectorySeparatorChar + "skills", StringComparison.Ordinal)); + + var skills = SkillLoader.LoadAll( + new SkillsConfig + { + Enabled = true, + Load = new SkillLoadConfig { IncludeBundled = false, IncludeManaged = false, IncludeWorkspace = false } + }, + null, + new TestLogger(), + host.SkillRoots); + Assert.Contains(skills, skill => skill.Name == "bundle-value"); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + /// Minimal ILogger for testing without DI. private sealed class TestLogger : Microsoft.Extensions.Logging.ILogger { diff --git a/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs b/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs index c14218bc..5c07b10a 100644 --- a/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs +++ b/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs @@ -13,6 +13,7 @@ namespace OpenClaw.Tests; public sealed class PublicCompatibilitySmokeTests : IDisposable { private const string SmokeEnvVar = "OPENCLAW_PUBLIC_SMOKE"; + private const string LatestCanaryEnvVar = "OPENCLAW_LATEST_CANARY"; private readonly string _tempDir; public PublicCompatibilitySmokeTests() @@ -50,6 +51,32 @@ public async Task PublicPackages_MatchPinnedCompatibilityManifest() } } + public static IEnumerable LatestNpmScenarioIds() + => PublicCompatibilityCatalog.GetCatalog().Items + .Where(static entry => string.Equals(entry.Kind, "npm-plugin", StringComparison.Ordinal)) + .GroupBy(static entry => entry.PackageName, StringComparer.Ordinal) + .Select(static group => group + .OrderByDescending(entry => string.Equals(entry.CompatibilityStatus, "compatible", StringComparison.Ordinal)) + .First()) + .OrderBy(static entry => entry.Id, StringComparer.Ordinal) + .Select(static entry => new object[] { entry.Id }); + + [Theory] + [MemberData(nameof(LatestNpmScenarioIds))] + [Trait("Category", "LatestCanary")] + public async Task LatestNpmPackage_ReportsCompatibilityDrift(string scenarioId) + { + if (!HasNode() || !IsLatestCanaryEnabled()) + return; + + var entry = PublicCompatibilityCatalog.GetCatalog().Items.Single(item => item.Id == scenarioId); + Assert.False(string.IsNullOrWhiteSpace(entry.PackageName)); + await VerifyNpmPluginAsync( + entry, + packageSpecOverride: $"{entry.PackageName}@latest", + scenarioIdOverride: $"latest-{entry.Id}"); + } + private async Task VerifyClawHubSkillAsync(CompatibilityCatalogEntry entry) { Assert.False(string.IsNullOrWhiteSpace(entry.SkillSlug), $"Smoke entry '{entry.Id}' must declare a skill slug."); @@ -85,18 +112,21 @@ await RunCommandAsync( Assert.NotEmpty(skills); } - private async Task VerifyNpmPluginAsync(CompatibilityCatalogEntry entry) + private async Task VerifyNpmPluginAsync( + CompatibilityCatalogEntry entry, + string? packageSpecOverride = null, + string? scenarioIdOverride = null) { Assert.False(string.IsNullOrWhiteSpace(entry.PackageSpec), $"Smoke entry '{entry.Id}' must declare an npm spec."); Assert.False(string.IsNullOrWhiteSpace(entry.PackageName), $"Smoke entry '{entry.Id}' must declare packageName."); Assert.False(string.IsNullOrWhiteSpace(entry.PluginId), $"Smoke entry '{entry.Id}' must declare pluginId."); Assert.False(string.IsNullOrWhiteSpace(entry.CompatibilityStatus), $"Smoke entry '{entry.Id}' must declare expectedStatus."); - var scenarioDir = CreateScenarioDirectory(entry.Id); + var scenarioDir = CreateScenarioDirectory(scenarioIdOverride ?? entry.Id); var installDir = Path.Combine(scenarioDir, "npm"); Directory.CreateDirectory(installDir); - var packages = new List { entry.PackageSpec! }; + var packages = new List { packageSpecOverride ?? entry.PackageSpec! }; if (entry.InstallExtraPackages is { Length: > 0 }) packages.AddRange(entry.InstallExtraPackages); await InstallPackagesAsync(installDir, packages); @@ -105,6 +135,8 @@ private async Task VerifyNpmPluginAsync(CompatibilityCatalogEntry entry) .Replace('/', Path.DirectorySeparatorChar) .Replace('\\', Path.DirectorySeparatorChar)); Assert.True(Directory.Exists(packageDir), $"Installed package directory '{packageDir}' was not found."); + var installedVersion = ReadInstalledPackageVersion(packageDir); + var scenarioLabel = $"{entry.PackageName}@{installedVersion ?? "unknown"}"; var workspaceDir = Path.Combine(scenarioDir, "workspace"); Directory.CreateDirectory(workspaceDir); @@ -116,7 +148,7 @@ private async Task VerifyNpmPluginAsync(CompatibilityCatalogEntry entry) if (string.Equals(entry.CompatibilityStatus, "compatible", StringComparison.Ordinal)) { - Assert.True(report!.Loaded, $"Expected plugin '{entry.Id}' to load. Error: {report.Error}"); + Assert.True(report!.Loaded, $"Expected plugin '{entry.Id}' ({scenarioLabel}) to load. Error: {report.Error}. Diagnostics: [{string.Join(", ", report.Diagnostics.Select(d => d.Code))}]"); foreach (var toolName in entry.ExpectedToolNames ?? []) Assert.Contains(tools, tool => string.Equals(tool.Name, toolName, StringComparison.Ordinal)); @@ -144,7 +176,7 @@ private async Task VerifyNpmPluginAsync(CompatibilityCatalogEntry entry) { Assert.True( report.Diagnostics.Any(diag => string.Equals(diag.Code, diagnosticCode, StringComparison.Ordinal)), - $"Expected plugin '{entry.Id}' to report diagnostic '{diagnosticCode}', but got: [{string.Join(", ", report.Diagnostics.Select(d => d.Code))}]"); + $"Expected plugin '{entry.Id}' ({scenarioLabel}) to report diagnostic '{diagnosticCode}', but got: [{string.Join(", ", report.Diagnostics.Select(d => d.Code))}]"); } } else @@ -190,6 +222,18 @@ await RunCommandAsync( args.ToArray()); } + private static string? ReadInstalledPackageVersion(string packageDir) + { + var packageJson = Path.Combine(packageDir, "package.json"); + if (!File.Exists(packageJson)) + return null; + + using var document = JsonDocument.Parse(File.ReadAllText(packageJson)); + return document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.String + ? version.GetString() + : null; + } + private string CreateScenarioDirectory(string id) { var path = Path.Combine(_tempDir, id); @@ -260,6 +304,9 @@ private static bool HasNode() private static bool IsSmokeEnabled() => string.Equals(Environment.GetEnvironmentVariable(SmokeEnvVar), "1", StringComparison.Ordinal); + private static bool IsLatestCanaryEnabled() + => string.Equals(Environment.GetEnvironmentVariable(LatestCanaryEnvVar), "1", StringComparison.Ordinal); + private static string ResolveCommand(string name) => OperatingSystem.IsWindows() ? $"{name}.cmd" : name; diff --git a/src/OpenClaw.Tests/SkillTests.cs b/src/OpenClaw.Tests/SkillTests.cs index 9b0376f4..173aa341 100644 --- a/src/OpenClaw.Tests/SkillTests.cs +++ b/src/OpenClaw.Tests/SkillTests.cs @@ -3943,6 +3943,45 @@ public void BuildPromptPatch_WithPromptAssumptionPolicy_FormatsConstraint() Directory.Delete(tempDir, true); } } + + [Fact] + public void LoadAll_PluginCommandRoot_MapsMarkdownCommandsIntoSkills() + { + var root = Path.Combine(Path.GetTempPath(), $"openclaw-bundle-commands-{Guid.NewGuid():N}"); + var commands = Path.Combine(root, "commands"); + Directory.CreateDirectory(commands); + File.WriteAllText( + Path.Combine(commands, "review.md"), + "---\ndescription: Review a proposed change\n---\nReview the change and report concrete risks."); + + try + { + var skills = SkillLoader.LoadAll( + new SkillsConfig + { + Enabled = true, + Load = new SkillLoadConfig + { + IncludeBundled = false, + IncludeManaged = false, + IncludeWorkspace = false + } + }, + null, + NullLogger.Instance, + [commands]); + + var command = Assert.Single(skills); + Assert.Equal("review", command.Name); + Assert.Equal("Review a proposed change", command.Description); + Assert.Contains("concrete risks", command.Instructions, StringComparison.Ordinal); + Assert.True(command.UserInvocable); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } /// Minimal ILogger for tests. From 27696315626c437f700fff768776443b8b0767df Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 22:35:27 -0700 Subject: [PATCH 2/9] Address plugin compatibility review feedback --- .../Plugins/PluginBridgeProcess.cs | 5 +- src/OpenClaw.Agent/Plugins/PluginHost.cs | 6 +-- src/OpenClaw.Agent/Plugins/plugin-bridge.mjs | 2 +- src/OpenClaw.Cli/PluginCliCommands.cs | 2 +- src/OpenClaw.Cli/PluginCommands.cs | 12 ++--- .../Plugins/PluginBundleDetector.cs | 52 +++++++++---------- src/OpenClaw.Core/Plugins/PluginDiscovery.cs | 3 +- src/OpenClaw.Core/Skills/SkillLoader.cs | 7 +-- src/OpenClaw.Dashboard/Pages/Ops.razor | 6 +-- .../wwwroot/locales/en-US.json | 4 ++ .../wwwroot/locales/zh-CN.json | 4 ++ .../PluginBridgeIntegrationTests.cs | 1 - 12 files changed, 55 insertions(+), 49 deletions(-) diff --git a/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs b/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs index ebed3b1b..1f7353e2 100644 --- a/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs +++ b/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs @@ -131,7 +131,7 @@ public async Task ExecuteToolAsync(string toolName, string argumentsJson if (response.Error is not null) return $"Error: {response.Error.Message}"; - if (response.Result is { } result && result.TryGetProperty("content", out var contentArray)) + if (response.Result is { } result) { if (result.TryGetProperty("details", out var details) && details.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined) @@ -139,6 +139,9 @@ public async Task ExecuteToolAsync(string toolName, string argumentsJson return details.GetRawText(); } + if (!result.TryGetProperty("content", out var contentArray)) + return result.GetRawText(); + var sb = new StringBuilder(); foreach (var item in contentArray.EnumerateArray()) { diff --git a/src/OpenClaw.Agent/Plugins/PluginHost.cs b/src/OpenClaw.Agent/Plugins/PluginHost.cs index eb0846f4..09b5631e 100644 --- a/src/OpenClaw.Agent/Plugins/PluginHost.cs +++ b/src/OpenClaw.Agent/Plugins/PluginHost.cs @@ -440,10 +440,10 @@ private void LoadBundle(DiscoveredPlugin plugin) string.Equals(item.Severity, "error", StringComparison.OrdinalIgnoreCase)); if (!hasErrors) { - foreach (var skillDir in skillDirs) + foreach (var skillDir in skillDirs.Where(skillDir => + !_skillRoots.Contains(skillDir, StringComparer.Ordinal))) { - if (!_skillRoots.Contains(skillDir, StringComparer.Ordinal)) - _skillRoots.Add(skillDir); + _skillRoots.Add(skillDir); } } diff --git a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs index e66826c1..100ad7dc 100644 --- a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs +++ b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs @@ -860,7 +860,7 @@ async function handleRequest(req) { try { const result = await tool.execute(pluginId, params ?? {}); - if (result && Array.isArray(result.content)) { + if (result && (Array.isArray(result.content) || result.details !== undefined)) { return result; } if (typeof result === "string") { diff --git a/src/OpenClaw.Cli/PluginCliCommands.cs b/src/OpenClaw.Cli/PluginCliCommands.cs index 2cb72e4b..1857da5c 100644 --- a/src/OpenClaw.Cli/PluginCliCommands.cs +++ b/src/OpenClaw.Cli/PluginCliCommands.cs @@ -36,7 +36,7 @@ internal static class PluginCliCommands { config = loadedConfig ? GatewayConfigFile.Load(configPath) : new GatewayConfig(); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException) { Console.Error.WriteLine($"Unable to load plugin CLI configuration from '{configPath}': {ex.Message}"); return 1; diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index d1f460dd..c92a1d37 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -1148,9 +1148,9 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest, Disco } } - foreach (var candidate in new[] { "index.js", "index.mjs", "index.cjs", "index.ts", "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" }) + foreach (var path in new[] { "index.js", "index.mjs", "index.cjs", "index.ts", "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" } + .Select(candidate => Path.Combine(rootPath, candidate))) { - var path = Path.Combine(rootPath, candidate); if (File.Exists(path)) return path; } @@ -1192,11 +1192,9 @@ private static IEnumerable EnumeratePluginSourceFiles(string rootPath) pending.Push(child); } - foreach (var file in Directory.EnumerateFiles(directory)) - { - if (Path.GetExtension(file) is ".js" or ".mjs" or ".cjs" or ".ts") - yield return file; - } + foreach (var file in Directory.EnumerateFiles(directory) + .Where(file => Path.GetExtension(file) is ".js" or ".mjs" or ".cjs" or ".ts")) + yield return file; } } diff --git a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs index 3de32fca..0e95992f 100644 --- a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs +++ b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs @@ -24,29 +24,33 @@ public static bool TryDetect( if (bundleFormat is null) return false; - JsonDocument? manifestDocument = null; - try + JsonDocument? manifestDocument; + if (manifestRelativePath is not null) { - if (manifestRelativePath is not null) + var manifestPath = Path.Combine(rootPath, manifestRelativePath.Replace('/', Path.DirectorySeparatorChar)); + try { - var manifestPath = Path.Combine(rootPath, manifestRelativePath.Replace('/', Path.DirectorySeparatorChar)); - try - { - manifestDocument = JsonDocument.Parse(File.ReadAllText(manifestPath)); - } - catch (Exception ex) + manifestDocument = JsonDocument.Parse(File.ReadAllText(manifestPath)); + } + catch (Exception ex) + { + diagnostic = new PluginCompatibilityDiagnostic { - diagnostic = new PluginCompatibilityDiagnostic - { - Code = "invalid_bundle_manifest", - Message = $"Failed to parse {bundleFormat} bundle manifest '{manifestPath}': {ex.Message}", - Surface = "bundle_manifest", - Path = manifestPath - }; - return true; - } + Code = "invalid_bundle_manifest", + Message = $"Failed to parse {bundleFormat} bundle manifest '{manifestPath}': {ex.Message}", + Surface = "bundle_manifest", + Path = manifestPath + }; + return true; } + } + else + { + manifestDocument = null; + } + using (manifestDocument) + { var manifestRoot = manifestDocument?.RootElement; var rawId = GetString(manifestRoot, "id") ?? GetString(manifestRoot, "name") @@ -101,10 +105,6 @@ .. commandRoots.OrderBy(static path => path, StringComparer.Ordinal) }; return true; } - finally - { - manifestDocument?.Dispose(); - } } private static string? DetectFormat(string rootPath, out string? manifestRelativePath) @@ -219,11 +219,9 @@ private static void AddManifestPaths(JsonElement? manifestRoot, string propertyN if (property.ValueKind != JsonValueKind.Array) return; - foreach (var item in property.EnumerateArray()) - { - if (item.ValueKind == JsonValueKind.String) - AddPath(item.GetString(), paths); - } + foreach (var item in property.EnumerateArray() + .Where(static item => item.ValueKind == JsonValueKind.String)) + AddPath(item.GetString(), paths); } private static void AddPath(string? path, ISet paths) diff --git a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs index 7ccf1b62..558d1668 100644 --- a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs +++ b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs @@ -545,9 +545,8 @@ private static bool TryAddPluginPack(string dir, string packageJsonPath, HashSet "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" ]; - foreach (var candidate in candidates) + foreach (var path in candidates.Select(candidate => Path.Combine(pluginRoot, candidate))) { - var path = Path.Combine(pluginRoot, candidate); if (File.Exists(path)) return path; } diff --git a/src/OpenClaw.Core/Skills/SkillLoader.cs b/src/OpenClaw.Core/Skills/SkillLoader.cs index 089b059b..34381b5d 100644 --- a/src/OpenClaw.Core/Skills/SkillLoader.cs +++ b/src/OpenClaw.Core/Skills/SkillLoader.cs @@ -269,11 +269,12 @@ private static void ScanBundleCommandFiles( }; foreach (var commandFile in Directory.EnumerateFiles(commandRoot, "*.md", options) + .Where(static path => !string.Equals( + Path.GetFileName(path), + "SKILL.md", + StringComparison.OrdinalIgnoreCase)) .OrderBy(static path => path, StringComparer.Ordinal)) { - if (string.Equals(Path.GetFileName(commandFile), "SKILL.md", StringComparison.OrdinalIgnoreCase)) - continue; - try { var commandName = Path.GetFileNameWithoutExtension(commandFile); diff --git a/src/OpenClaw.Dashboard/Pages/Ops.razor b/src/OpenClaw.Dashboard/Pages/Ops.razor index b6e8a0c2..c3eb0d28 100644 --- a/src/OpenClaw.Dashboard/Pages/Ops.razor +++ b/src/OpenClaw.Dashboard/Pages/Ops.razor @@ -78,7 +78,7 @@ else - + T @context.Item.ToolCount · C @context.Item.ChannelCount · P @context.Item.ProviderCount @@ -102,7 +102,7 @@ else Variant="Variant.Outlined" Color="Color.Warning" OnClick="@(() => MutatePlugin(context.Item, "clear-quarantine"))"> - Clear quarantine + @L["ops.clearQuarantine"] } else @@ -119,7 +119,7 @@ else Variant="Variant.Text" Color="Color.Default" OnClick="@(() => MutatePlugin(context.Item, context.Item.Reviewed ? "unreview" : "review"))"> - @(context.Item.Reviewed ? "Clear review" : "Mark reviewed") + @(context.Item.Reviewed ? L["ops.clearReview"] : L["ops.markReviewed"]) diff --git a/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json b/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json index 6328c8ad..0942ae67 100644 --- a/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json +++ b/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json @@ -356,6 +356,10 @@ "plugins": "Plugins", "pluginName": "Plugin Name", "pluginStatus": "Status", + "surface": "Surface", + "clearQuarantine": "Clear quarantine", + "clearReview": "Clear review", + "markReviewed": "Mark reviewed", "approvalPolicies": "Approval Policies", "toolPattern": "Tool Pattern", "policy": "Policy", diff --git a/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json b/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json index 99382f6f..f0e46216 100644 --- a/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json +++ b/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json @@ -356,6 +356,10 @@ "plugins": "插件", "pluginName": "插件名称", "pluginStatus": "状态", + "surface": "功能面", + "clearQuarantine": "解除隔离", + "clearReview": "撤销审查", + "markReviewed": "标记为已审查", "approvalPolicies": "审批策略", "toolPattern": "工具匹配模式", "policy": "策略", diff --git a/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs b/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs index 35c1ac01..ae44194d 100644 --- a/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs +++ b/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs @@ -140,7 +140,6 @@ public async Task LoadAsync_StructuredTool_PreservesOutputSchemaAndDetails() parameters: { type: "object", properties: { text: { type: "string" } } }, outputSchema: { type: "object", properties: { echoed: { type: "string" } }, required: ["echoed"] }, execute: async (_pluginId, params) => ({ - content: [{ type: "text", text: `Echoed ${params.text}` }], details: { echoed: params.text } }) }); From df72b73802f10424d1eded649aa18498619f0424 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 22:43:28 -0700 Subject: [PATCH 3/9] Refine plugin CLI failure handling --- src/OpenClaw.Cli/PluginCliCommands.cs | 21 +++++++++++++++----- src/OpenClaw.Cli/PluginCommands.cs | 19 +++++++++--------- src/OpenClaw.Core/Plugins/PluginDiscovery.cs | 10 +++++----- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/OpenClaw.Cli/PluginCliCommands.cs b/src/OpenClaw.Cli/PluginCliCommands.cs index 1857da5c..24235b15 100644 --- a/src/OpenClaw.Cli/PluginCliCommands.cs +++ b/src/OpenClaw.Cli/PluginCliCommands.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; @@ -151,7 +152,7 @@ internal static async Task DescribeAsync( { process.Start(); } - catch (Exception ex) + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) { return PluginCliDescribeResult.Failure($"Unable to start Node.js for plugin CLI discovery: {ex.Message}"); } @@ -185,7 +186,12 @@ internal static async Task DescribeAsync( TryKill(process); return PluginCliDescribeResult.Failure("Plugin CLI discovery timed out after 20 seconds."); } - catch (Exception ex) + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException or NotSupportedException) { TryKill(process); return PluginCliDescribeResult.Failure($"Plugin CLI discovery failed: {ex.Message}"); @@ -208,7 +214,7 @@ private static async Task ExecuteAsync( { process.Start(); } - catch (Exception ex) + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) { Console.Error.WriteLine($"Unable to start plugin CLI command: {ex.Message}"); return 1; @@ -284,7 +290,12 @@ private static HashSet LoadBlockedPluginIds(string storagePath) result.Add(state.PluginId); } } - catch + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or InvalidOperationException + or NotSupportedException + or ArgumentException) { // A malformed optional operator-state file must not activate a plugin. // Treat discovery as empty rather than bypassing a possible quarantine. @@ -301,7 +312,7 @@ private static void TryKill(Process process) if (!process.HasExited) process.Kill(entireProcessTree: true); } - catch + catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or NotSupportedException) { } } diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index c92a1d37..894d41dd 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -630,7 +630,13 @@ private static void CopyDirectory(string source, string destination) return (true, null); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidOperationException + or JsonException + or System.ComponentModel.Win32Exception + or NotSupportedException + or ArgumentException) { return (false, $"Plugin installation failed; the existing plugin was preserved when possible: {ex.Message}"); } @@ -1148,14 +1154,9 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest, Disco } } - foreach (var path in new[] { "index.js", "index.mjs", "index.cjs", "index.ts", "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" } - .Select(candidate => Path.Combine(rootPath, candidate))) - { - if (File.Exists(path)) - return path; - } - - return null; + return new[] { "index.js", "index.mjs", "index.cjs", "index.ts", "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" } + .Select(candidate => Path.Combine(rootPath, candidate)) + .FirstOrDefault(File.Exists); } private static void InspectUnsupportedRuntimeSurfaces( diff --git a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs index 558d1668..30e24cb8 100644 --- a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs +++ b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs @@ -545,11 +545,11 @@ private static bool TryAddPluginPack(string dir, string packageJsonPath, HashSet "src/index.js", "src/index.mjs", "src/index.cjs", "src/index.ts" ]; - foreach (var path in candidates.Select(candidate => Path.Combine(pluginRoot, candidate))) - { - if (File.Exists(path)) - return path; - } + var conventionalEntry = candidates + .Select(candidate => Path.Combine(pluginRoot, candidate)) + .FirstOrDefault(File.Exists); + if (conventionalEntry is not null) + return conventionalEntry; // Fallback: any .ts, .js, or .mjs file in root foreach (var ext in new[] { "*.js", "*.mjs", "*.cjs", "*.ts" }) From 69307fffda7a01c8a8e2264cab4e026f1c917863 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 22:47:15 -0700 Subject: [PATCH 4/9] Preserve native plugin discovery precedence --- .../Plugins/PluginBundleDetector.cs | 22 +++++++++++++++++++ src/OpenClaw.Core/Plugins/PluginDiscovery.cs | 17 +++++++++----- src/OpenClaw.Tests/PluginTests.cs | 17 ++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs index 0e95992f..08727de8 100644 --- a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs +++ b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs @@ -12,6 +12,28 @@ internal static class PluginBundleDetector private const string ClaudeManifest = ".claude-plugin/plugin.json"; private const string CursorManifest = ".cursor-plugin/plugin.json"; + internal static bool HasExplicitOrStrongMarker(string rootPath) + { + if (File.Exists(Path.Combine(rootPath, CodexManifest.Replace('/', Path.DirectorySeparatorChar))) || + File.Exists(Path.Combine(rootPath, ClaudeManifest.Replace('/', Path.DirectorySeparatorChar))) || + File.Exists(Path.Combine(rootPath, CursorManifest.Replace('/', Path.DirectorySeparatorChar)))) + { + return true; + } + + var cursorRoot = Path.Combine(rootPath, ".cursor"); + return (Directory.Exists(cursorRoot) && + (Directory.Exists(Path.Combine(cursorRoot, "commands")) || + Directory.Exists(Path.Combine(cursorRoot, "agents")) || + Directory.Exists(Path.Combine(cursorRoot, "rules")) || + File.Exists(Path.Combine(cursorRoot, "hooks.json")))) || + Directory.Exists(Path.Combine(rootPath, "agents")) || + Directory.Exists(Path.Combine(rootPath, "hooks")) || + File.Exists(Path.Combine(rootPath, ".mcp.json")) || + File.Exists(Path.Combine(rootPath, ".lsp.json")) || + File.Exists(Path.Combine(rootPath, "settings.json")); + } + public static bool TryDetect( string rootPath, out DiscoveredPlugin? plugin, diff --git a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs index 30e24cb8..ec83697a 100644 --- a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs +++ b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs @@ -137,6 +137,15 @@ private static void ScanDirectory(string dir, HashSet seen, PluginDiscov if (File.Exists(packageJsonPath) && TryAddPluginPack(dir, packageJsonPath, seen, result)) return; + var conventionalEntry = new[] { "index.js", "index.mjs", "index.cjs", "index.ts" } + .Select(candidate => Path.Combine(dir, candidate)) + .FirstOrDefault(File.Exists); + if (conventionalEntry is not null && !PluginBundleDetector.HasExplicitOrStrongMarker(dir)) + { + TryAddPluginFromFile(conventionalEntry, seen, result); + return; + } + if (PluginBundleDetector.TryDetect(dir, out var bundle, out var bundleDiagnostic)) { if (bundleDiagnostic is not null) @@ -181,13 +190,9 @@ private static void ScanDirectory(string dir, HashSet seen, PluginDiscov return; } - foreach (var candidate in new[] { "index.js", "index.mjs", "index.cjs", "index.ts" }) + if (conventionalEntry is not null) { - var entryPath = Path.Combine(dir, candidate); - if (!File.Exists(entryPath)) - continue; - - TryAddPluginFromFile(entryPath, seen, result); + TryAddPluginFromFile(conventionalEntry, seen, result); return; } diff --git a/src/OpenClaw.Tests/PluginTests.cs b/src/OpenClaw.Tests/PluginTests.cs index 6aa49b55..8c07b94a 100644 --- a/src/OpenClaw.Tests/PluginTests.cs +++ b/src/OpenClaw.Tests/PluginTests.cs @@ -147,6 +147,23 @@ public void Discover_NativePluginTakesPrecedenceOverBundleMarkers() Assert.Equal("native-wins", plugin.Manifest.Id); } + [Fact] + public void Discover_StandaloneEntryWithWeakBundleFolders_RemainsNative() + { + var pluginDir = Path.Combine(_tempDir, "standalone-with-content"); + Directory.CreateDirectory(Path.Combine(pluginDir, "skills")); + Directory.CreateDirectory(Path.Combine(pluginDir, "commands")); + File.WriteAllText(Path.Combine(pluginDir, "index.js"), "module.exports = () => {};"); + + var plugin = Assert.Single(PluginDiscovery.Discover(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [pluginDir] } + })); + + Assert.Equal(PluginFormats.Native, plugin.Format); + Assert.EndsWith("index.js", plugin.EntryPath, StringComparison.Ordinal); + } + [Fact] public void Discover_SkipsBrokenManifestJson() { From ce7f9f984bdb344f13fdcb012c9a2f3428ff817f Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 22:57:57 -0700 Subject: [PATCH 5/9] Harden plugin CLI compatibility edge cases --- src/OpenClaw.Agent/Plugins/plugin-bridge.mjs | 15 +++++++-- src/OpenClaw.Cli/PluginCliCommands.cs | 15 +++++---- src/OpenClaw.Cli/PluginCommands.cs | 28 +++++++++++------ .../Plugins/PluginBundleDetector.cs | 2 +- src/OpenClaw.Core/Plugins/PluginDiscovery.cs | 2 +- src/OpenClaw.Dashboard/Pages/Ops.razor | 11 ++++++- .../wwwroot/locales/en-US.json | 3 ++ .../wwwroot/locales/zh-CN.json | 3 ++ src/OpenClaw.Tests/PluginCommandsTests.cs | 31 +++++++++++++++++-- 9 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs index 100ad7dc..cf3def02 100644 --- a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs +++ b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs @@ -594,11 +594,20 @@ function parseCliInvocation(command, argv) { options[option.name] = false; } else if (option.requiredValue || option.optionalValue) { const next = inlineValue ?? argv[index + 1]; - if (next === undefined || (option.requiredValue && next.startsWith("-"))) { + const nextFlag = typeof next === "string" && next.startsWith("-") + ? next.slice(0, next.includes("=") ? next.indexOf("=") : undefined) + : undefined; + const nextIsKnownOption = inlineValue === undefined && nextFlag !== undefined && + command.options.some((candidate) => candidate.long === nextFlag || candidate.short === nextFlag); + if (next === undefined || (option.requiredValue && nextIsKnownOption)) { throw new Error(`Option ${flag} requires a value.`); } - options[option.name] = next; - if (inlineValue === undefined) index++; + if (option.optionalValue && nextIsKnownOption) { + options[option.name] = true; + } else { + options[option.name] = next; + if (inlineValue === undefined) index++; + } } else { options[option.name] = true; } diff --git a/src/OpenClaw.Cli/PluginCliCommands.cs b/src/OpenClaw.Cli/PluginCliCommands.cs index 24235b15..ad4b5a28 100644 --- a/src/OpenClaw.Cli/PluginCliCommands.cs +++ b/src/OpenClaw.Cli/PluginCliCommands.cs @@ -53,9 +53,7 @@ internal static class PluginCliCommands return 1; } - var blockedPluginIds = loadedConfig - ? LoadBlockedPluginIds(config.Memory.StoragePath) - : new HashSet(StringComparer.Ordinal); + var blockedPluginIds = LoadBlockedPluginIds(config.Memory.StoragePath); return await TryRunAsync( command, args, @@ -174,8 +172,13 @@ internal static async Task DescribeAsync( : stderr.Trim()); } - var commands = JsonSerializer.Deserialize( - stdout, + var descriptorLine = stdout + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + var commands = descriptorLine is null + ? null + : JsonSerializer.Deserialize( + descriptorLine, CoreJsonContext.Default.BridgeCliCommandRegistrationArray); return commands is null ? PluginCliDescribeResult.Failure("Plugin CLI discovery returned unreadable metadata.") @@ -268,7 +271,7 @@ private static Process CreateProcess( return process; } - private static HashSet LoadBlockedPluginIds(string storagePath) + internal static HashSet LoadBlockedPluginIds(string storagePath) { var result = new HashSet(StringComparer.Ordinal); try diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index 894d41dd..1767b82d 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -616,7 +616,7 @@ private static void CopyDirectory(string source, string destination) { Directory.Move(stagingDir, targetDir); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { if (Directory.Exists(backupDir) && !Directory.Exists(targetDir)) Directory.Move(backupDir, targetDir); @@ -625,7 +625,11 @@ private static void CopyDirectory(string source, string destination) if (Directory.Exists(backupDir)) { - try { Directory.Delete(backupDir, recursive: true); } catch { /* successful install; stale backup is recoverable */ } + try { Directory.Delete(backupDir, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Successful install; a stale backup is recoverable. + } } return (true, null); @@ -644,12 +648,14 @@ or NotSupportedException { if (Directory.Exists(stagingDir)) { - try { Directory.Delete(stagingDir, recursive: true); } catch { } + try { Directory.Delete(stagingDir, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } if (Directory.Exists(backupDir) && Directory.Exists(targetDir)) { - try { Directory.Delete(backupDir, recursive: true); } catch { } + try { Directory.Delete(backupDir, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } } @@ -704,7 +710,7 @@ internal static async Task InspectRuntimeAsync( { process.Start(); } - catch (Exception ex) + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) { return PluginRuntimeInspection.Failure($"Unable to start Node.js for runtime inspection: {ex.Message}"); } @@ -765,7 +771,11 @@ internal static async Task InspectRuntimeAsync( { return PluginRuntimeInspection.Failure("Plugin runtime inspection timed out after 20 seconds."); } - catch (Exception ex) + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException or NotSupportedException) { return PluginRuntimeInspection.Failure($"Plugin runtime inspection failed: {ex.Message}"); } @@ -776,7 +786,7 @@ internal static async Task InspectRuntimeAsync( if (!process.HasExited) process.Kill(entireProcessTree: true); } - catch + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or NotSupportedException) { } } @@ -1148,7 +1158,7 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest, Disco } } } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { return null; } @@ -1170,7 +1180,7 @@ private static void InspectUnsupportedRuntimeSurfaces( { source = File.ReadAllText(file); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { continue; } diff --git a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs index 08727de8..322ac0db 100644 --- a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs +++ b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs @@ -54,7 +54,7 @@ public static bool TryDetect( { manifestDocument = JsonDocument.Parse(File.ReadAllText(manifestPath)); } - catch (Exception ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { diagnostic = new PluginCompatibilityDiagnostic { diff --git a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs index ec83697a..d1a1364b 100644 --- a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs +++ b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs @@ -605,7 +605,7 @@ private static PluginPackageMetadata ReadPackageMetadata(string pluginRoot) return new PluginPackageMetadata(pluginApiRange, minHostVersion, expectedIntegrity); } - catch + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { return new PluginPackageMetadata(); } diff --git a/src/OpenClaw.Dashboard/Pages/Ops.razor b/src/OpenClaw.Dashboard/Pages/Ops.razor index c3eb0d28..0d2916b9 100644 --- a/src/OpenClaw.Dashboard/Pages/Ops.razor +++ b/src/OpenClaw.Dashboard/Pages/Ops.razor @@ -89,7 +89,7 @@ else Size="Size.Small" Color="@(context.Item.Loaded && context.Item.Enabled ? Color.Success : context.Item.Quarantined ? Color.Error : Color.Default)" Variant="@(context.Item.Loaded && context.Item.Enabled ? Variant.Filled : Variant.Outlined)"> - @context.Item.Status + @PluginStatusLabel(context.Item) @@ -474,6 +474,15 @@ else private static string PluginAccent(PluginInfo p) => p.Enabled ? "#10b981" : "#475569"; + private string PluginStatusLabel(PluginInfo plugin) + => plugin.Quarantined + ? L["ops.quarantined"] + : plugin.Disabled + ? L["common.disabled"] + : plugin.Loaded + ? L["ops.loaded"] + : L["ops.notLoaded"]; + private static Color PolicyColor(string? policy) => policy?.ToLowerInvariant() switch { diff --git a/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json b/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json index 0942ae67..1b84cf06 100644 --- a/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json +++ b/src/OpenClaw.Dashboard/wwwroot/locales/en-US.json @@ -360,6 +360,9 @@ "clearQuarantine": "Clear quarantine", "clearReview": "Clear review", "markReviewed": "Mark reviewed", + "quarantined": "Quarantined", + "loaded": "Loaded", + "notLoaded": "Not loaded", "approvalPolicies": "Approval Policies", "toolPattern": "Tool Pattern", "policy": "Policy", diff --git a/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json b/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json index f0e46216..287ba4ca 100644 --- a/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json +++ b/src/OpenClaw.Dashboard/wwwroot/locales/zh-CN.json @@ -360,6 +360,9 @@ "clearQuarantine": "解除隔离", "clearReview": "撤销审查", "markReviewed": "标记为已审查", + "quarantined": "已隔离", + "loaded": "已加载", + "notLoaded": "未加载", "approvalPolicies": "审批策略", "toolPattern": "工具匹配模式", "policy": "策略", diff --git a/src/OpenClaw.Tests/PluginCommandsTests.cs b/src/OpenClaw.Tests/PluginCommandsTests.cs index 9c7269e0..8ca4387c 100644 --- a/src/OpenClaw.Tests/PluginCommandsTests.cs +++ b/src/OpenClaw.Tests/PluginCommandsTests.cs @@ -154,13 +154,16 @@ public async Task PluginCliCommands_ExecutesNestedCommandWithArgumentsAndOptions Path.Combine(root, "index.js"), $$""" const fs = require("node:fs"); + console.log("plugin registration noise"); module.exports = api => api.registerCli(({ program }) => { const root = program.command("fixture").description("Fixture commands"); root.command("write") .argument("", "Value to write") .option("--upper", "Uppercase the value") + .requiredOption("--threshold ", "Threshold") .action(async (value, options) => { - fs.writeFileSync({{JsonSerializer.Serialize(markerPath)}}, options.upper ? value.toUpperCase() : value); + const output = options.upper ? value.toUpperCase() : value; + fs.writeFileSync({{JsonSerializer.Serialize(markerPath)}}, `${output}:${options.threshold}`); }); }, { commands: ["fixture"] }); """); @@ -169,7 +172,7 @@ public async Task PluginCliCommands_ExecutesNestedCommandWithArgumentsAndOptions Assert.NotNull(bridgeScript); var result = await PluginCliCommands.TryRunAsync( "fixture", - ["write", "hello", "--upper"], + ["write", "hello", "--upper", "--threshold", "-1"], new PluginsConfig { Load = new PluginLoadConfig { Paths = [root] } }, workspacePath: null, new HashSet(StringComparer.Ordinal), @@ -177,7 +180,29 @@ public async Task PluginCliCommands_ExecutesNestedCommandWithArgumentsAndOptions TestContext.Current.CancellationToken); Assert.Equal(0, result); - Assert.Equal("HELLO", File.ReadAllText(markerPath)); + Assert.Equal("HELLO:-1", File.ReadAllText(markerPath)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void PluginCliCommands_LoadBlockedPluginIds_ReadsQuarantineState() + { + var root = CreateTempRoot(); + try + { + var adminDir = Path.Combine(root, "admin"); + Directory.CreateDirectory(adminDir); + File.WriteAllText( + Path.Combine(adminDir, "plugin-state.json"), + """[{"pluginId":"blocked-plugin","quarantined":true}]"""); + + var blocked = PluginCliCommands.LoadBlockedPluginIds(root); + + Assert.Contains("blocked-plugin", blocked); } finally { From c1a2398aeffefc3309b84c65ead5a458aeec1f1a Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 23:03:06 -0700 Subject: [PATCH 6/9] Disable plugin install lifecycle scripts --- docs/COMPATIBILITY.md | 2 +- src/OpenClaw.Agent/Plugins/plugin-bridge.mjs | 6 ++-- src/OpenClaw.Cli/PluginCommands.cs | 4 +-- src/OpenClaw.Tests/PluginCommandsTests.cs | 38 ++++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index b1914025..3bc1fa38 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -62,7 +62,7 @@ OpenClaw.NET keeps plugin compatibility explicit by runtime mode. The goal is to | Manifest/package discovery via `Plugins:Load:Paths` | Supported | Includes `openclaw.plugin.json`; prefers `package.json` `openclaw.runtimeExtensions` and retains `openclaw.extensions` compatibility. Built JavaScript entries are preferred over TypeScript source. | | Package compatibility metadata | Supported | `openclaw.compat.pluginApi`, `compat.minGatewayVersion`, `install.minHostVersion`, and `install.expectedIntegrity` are discovered. Unsupported version floors fail before load. | | `openclaw plugins install --dry-run` trust inspection | Supported | Prints manifest/package/static compatibility rather than claiming runtime verification. Known unsupported registration APIs block installation. | -| Staged plugin installation | Supported | Dependencies and any required `jiti` runtime are installed in a sibling staging directory, the bridge initializes the staged plugin, and only then is the existing install replaced atomically. A failed update preserves the previous plugin. | +| Staged plugin installation | Supported | Dependencies and any required `jiti` runtime are installed in a sibling staging directory with npm lifecycle scripts disabled, the bridge initializes the staged plugin, and only then is the existing install replaced atomically. A failed update preserves the previous plugin. | | Codex compatible bundles | Supported with caveats | Detects `.codex-plugin/plugin.json`; maps `skills/` into plugin skills. Hook packs, MCP metadata, and app metadata are reported as detected-only because OpenClaw.NET does not yet execute those bundle surfaces. | | Claude compatible bundles | Supported with caveats | Detects `.claude-plugin/plugin.json` and manifestless Claude layouts. Maps `skills/` and Markdown `commands/`; agents, hook automation, MCP, LSP, settings, and output styles are reported as detected-only. | | Cursor compatible bundles | Supported with caveats | Detects `.cursor-plugin/plugin.json` and `.cursor/` layouts. Maps `skills/` and `.cursor/commands/`; agents, rules, hooks, and MCP metadata are reported as detected-only. | diff --git a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs index cf3def02..ea4849d9 100644 --- a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs +++ b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs @@ -78,6 +78,9 @@ let resolveSocketReady = () => {}; /** @type {(reason?: any) => void} */ let rejectSocketReady = () => {}; +let pluginId = "unknown"; +let logger = createLogger(pluginId); + if (transportMode === "socket" || transportMode === "hybrid") { socketReadyPromise = new Promise((resolve, reject) => { resolveSocketReady = resolve; @@ -760,9 +763,6 @@ function findJiti(entryPath) { return null; } -let pluginId = "unknown"; -let logger = createLogger(pluginId); - async function handleRequest(req) { switch (req.method) { case "init": { diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index 1767b82d..1bdb7ccb 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -567,7 +567,7 @@ private static void CopyDirectory(string source, string destination) if (File.Exists(packageJson) && stagedInspection.Format != PluginFormats.Bundle) { Console.WriteLine("Installing dependencies in staging..."); - var npmInstall = await RunNpmAsync("install --omit=dev --omit=optional", stagingDir); + var npmInstall = await RunNpmAsync("install --ignore-scripts --omit=dev --omit=optional", stagingDir); if (npmInstall.ExitCode != 0) return (false, $"Dependency installation failed; the existing plugin was preserved: {npmInstall.Stderr}"); @@ -586,7 +586,7 @@ private static void CopyDirectory(string source, string destination) !HasLocalJiti(stagedInspection.EntryPath)) { Console.WriteLine("Installing the TypeScript runtime dependency jiti in staging..."); - var jitiInstall = await RunNpmAsync("install --no-save --omit=dev --omit=optional jiti", stagingDir); + var jitiInstall = await RunNpmAsync("install --ignore-scripts --no-save --omit=dev --omit=optional jiti", stagingDir); if (jitiInstall.ExitCode != 0) return (false, $"TypeScript runtime dependency installation failed; the existing plugin was preserved: {jitiInstall.Stderr}"); } diff --git a/src/OpenClaw.Tests/PluginCommandsTests.cs b/src/OpenClaw.Tests/PluginCommandsTests.cs index 8ca4387c..554b1a82 100644 --- a/src/OpenClaw.Tests/PluginCommandsTests.cs +++ b/src/OpenClaw.Tests/PluginCommandsTests.cs @@ -297,6 +297,44 @@ public async Task InstallPreparedDirectoryAsync_BundleDoesNotRunNpmLifecycleScri } } + [Fact] + public async Task InstallPreparedDirectoryAsync_NativePluginDoesNotRunNpmLifecycleScripts() + { + if (!HasNode()) + return; + + var root = CreateTempRoot(); + var targetParent = CreateTempRoot(); + try + { + File.WriteAllText( + Path.Combine(root, "openclaw.plugin.json"), + """{"id":"safe-native-plugin","configSchema":{"type":"object"}}"""); + File.WriteAllText( + Path.Combine(root, "index.js"), + "module.exports = () => {};"); + File.WriteAllText( + Path.Combine(root, "package.json"), + """{"name":"safe-native-plugin","scripts":{"install":"node -e \"require('fs').writeFileSync('lifecycle-ran','yes')\""}}"""); + var target = Path.Combine(targetParent, "safe-native-plugin"); + + var result = await PluginCommands.InstallPreparedDirectoryAsync( + root, + target, + "./safe-native-plugin", + sourceIsNpm: false); + + Assert.True(result.Success, result.Error); + Assert.True(Directory.Exists(target)); + Assert.False(File.Exists(Path.Combine(target, "lifecycle-ran"))); + } + finally + { + Directory.Delete(root, recursive: true); + Directory.Delete(targetParent, recursive: true); + } + } + [Fact] public async Task InstallPreparedDirectoryAsync_InvalidBundlePreservesExistingInstall() { From 239887a4970ac12fcc364d8cb425586103128f94 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 23:22:53 -0700 Subject: [PATCH 7/9] Address final plugin compatibility review --- .github/workflows/ci.yml | 9 +-- compat/public-smoke.json | 3 +- docs/COMPATIBILITY.md | 2 +- docs/zh-CN/COMPATIBILITY.md | 2 +- src/OpenClaw.Agent/OpenClawToolExecutor.cs | 14 ++-- .../Plugins/PluginBridgeProcess.cs | 3 +- src/OpenClaw.Agent/Plugins/PluginHost.cs | 1 + src/OpenClaw.Agent/Plugins/plugin-bridge.mjs | 4 +- src/OpenClaw.Cli/PluginCliCommands.cs | 53 +++++++++++++-- src/OpenClaw.Cli/PluginCommands.cs | 68 +++++++++++++------ .../PublicCompatibilityCatalog.cs | 5 ++ .../Models/CompatibilityCatalogModels.cs | 1 + .../Plugins/PluginBundleDetector.cs | 20 ++++-- src/OpenClaw.Core/Plugins/PluginDiscovery.cs | 41 +++++++++-- src/OpenClaw.Core/Plugins/PluginModels.cs | 1 + .../Plugins/PluginPackageCompatibility.cs | 37 ++++++++-- src/OpenClaw.Core/Skills/SkillLoader.cs | 2 +- .../OpenClawToolExecutorTests.cs | 19 ++++++ src/OpenClaw.Tests/PluginCommandsTests.cs | 57 +++++++++++++++- src/OpenClaw.Tests/PluginTests.cs | 41 ++++++++++- .../PublicCompatibilitySmokeTests.cs | 6 +- src/OpenClaw.Tests/SkillTests.cs | 35 ++++++++++ 22 files changed, 365 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28fadb6b..f857c33a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,7 +335,7 @@ jobs: OPENCLAW_LATEST_CANARY: "1" run: dotnet test --no-build -c Release --filter "Category=LatestCanary" --verbosity normal --logger "trx;LogFileName=latest-plugin-canary.trx" src/OpenClaw.Tests - - name: Report latest-package compatibility drift + - name: Report latest-package compatibility canary if: always() shell: bash run: | @@ -343,9 +343,10 @@ jobs: echo "### Latest plugin compatibility canary: passing" >> "$GITHUB_STEP_SUMMARY" echo "Current npm releases still match the pinned compatibility expectations." >> "$GITHUB_STEP_SUMMARY" else - echo "::warning title=Latest plugin compatibility drift::One or more current npm releases no longer match the pinned compatibility expectations. Review the latest-plugin-canary artifact; the pinned release gate remains authoritative." - echo "### Latest plugin compatibility canary: drift detected" >> "$GITHUB_STEP_SUMMARY" - echo "Review the latest-plugin-canary test artifact. This signal is intentionally non-blocking; pinned public-smoke scenarios remain the release gate." >> "$GITHUB_STEP_SUMMARY" + result="${{ steps.latest_plugin_canary.outcome }}" + echo "::warning title=Latest plugin compatibility canary failed::The canary result was '${result}'. This may indicate package compatibility drift or a Node, npm, process, or test-infrastructure failure. Review the latest-plugin-canary artifact; the pinned release gate remains authoritative." + echo "### Latest plugin compatibility canary: ${result}" >> "$GITHUB_STEP_SUMMARY" + echo "Review the latest-plugin-canary test artifact to classify the failure. This signal is intentionally non-blocking; pinned public-smoke scenarios remain the release gate." >> "$GITHUB_STEP_SUMMARY" fi - name: Upload smoke results diff --git a/compat/public-smoke.json b/compat/public-smoke.json index 27b5cf05..27e71cfc 100644 --- a/compat/public-smoke.json +++ b/compat/public-smoke.json @@ -69,7 +69,8 @@ "pluginId": "openclaw-supermemory", "installExtraPackages": ["jiti"], "expectedStatus": "compatible", - "configJson": "{}" + "configJson": "{}", + "expectedCliCommandNames": ["supermemory"] } ] } diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 3bc1fa38..28dea912 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -60,7 +60,7 @@ OpenClaw.NET keeps plugin compatibility explicit by runtime mode. The goal is to | `OpenClaw.Providers.MicrosoftExtensionsAI` | Supported with caveats | `jit` only through native dynamic plugins. Use this to bring an arbitrary `IChatClient`; AOT users should use built-in providers or OpenAI-compatible endpoints. | | Standalone `.js`, `.mjs`, `.cjs`, `.ts` in `.openclaw/extensions` | Supported with caveats | CLI installs add local `jiti` when a TypeScript entry needs it; manually configured TypeScript paths still require `jiti` in their dependency tree. | | Manifest/package discovery via `Plugins:Load:Paths` | Supported | Includes `openclaw.plugin.json`; prefers `package.json` `openclaw.runtimeExtensions` and retains `openclaw.extensions` compatibility. Built JavaScript entries are preferred over TypeScript source. | -| Package compatibility metadata | Supported | `openclaw.compat.pluginApi`, `compat.minGatewayVersion`, `install.minHostVersion`, and `install.expectedIntegrity` are discovered. Unsupported version floors fail before load. | +| Package compatibility metadata | Supported with caveats | `openclaw.compat.pluginApi`, `compat.minGatewayVersion`, and `install.minHostVersion` are enforced before load. `install.expectedIntegrity` is discovered but fails closed because an extracted directory cannot yet be verified against package-manager integrity metadata. | | `openclaw plugins install --dry-run` trust inspection | Supported | Prints manifest/package/static compatibility rather than claiming runtime verification. Known unsupported registration APIs block installation. | | Staged plugin installation | Supported | Dependencies and any required `jiti` runtime are installed in a sibling staging directory with npm lifecycle scripts disabled, the bridge initializes the staged plugin, and only then is the existing install replaced atomically. A failed update preserves the previous plugin. | | Codex compatible bundles | Supported with caveats | Detects `.codex-plugin/plugin.json`; maps `skills/` into plugin skills. Hook packs, MCP metadata, and app metadata are reported as detected-only because OpenClaw.NET does not yet execute those bundle surfaces. | diff --git a/docs/zh-CN/COMPATIBILITY.md b/docs/zh-CN/COMPATIBILITY.md index 4b7d8430..fc102b7d 100644 --- a/docs/zh-CN/COMPATIBILITY.md +++ b/docs/zh-CN/COMPATIBILITY.md @@ -33,7 +33,7 @@ | `api.registerCommand()` | jit only | | `api.on(...)` | jit only | | `api.registerProvider()` | jit only | -| `api.registerCli()` | Supported(惰性发现根命令;通过一次性 Node 桥接进程执行) | +| `api.registerCli()` | Supported with caveats(内置根命令优先;重复插件根命令会失败关闭;仅启用且通过配置验证的插件参与惰性发现;通过一次性 Node 桥接进程执行,并支持上游常用的 Commander 子集) | | 独立 `.js`/`.mjs`/`.ts` | `.ts` 需 `jiti` | | 原生动态 .NET 插件 | jit only | | 上游 TypeScript `payment` 插件 | Not supported(使用原生支付运行时) | diff --git a/src/OpenClaw.Agent/OpenClawToolExecutor.cs b/src/OpenClaw.Agent/OpenClawToolExecutor.cs index 831a4029..f2d6f622 100644 --- a/src/OpenClaw.Agent/OpenClawToolExecutor.cs +++ b/src/OpenClaw.Agent/OpenClawToolExecutor.cs @@ -1243,14 +1243,20 @@ internal static AIFunctionDeclaration CreateDeclaration(ITool tool) { using var doc = JsonDocument.Parse(tool.ParameterSchema); JsonElement? returnSchema = null; - JsonDocument? returnSchemaDocument = null; if (tool is IToolOutputSchema { OutputSchema: { Length: > 0 } outputSchema }) { - returnSchemaDocument = JsonDocument.Parse(outputSchema); - returnSchema = returnSchemaDocument.RootElement.Clone(); + try + { + using var returnSchemaDocument = JsonDocument.Parse(outputSchema); + returnSchema = returnSchemaDocument.RootElement.Clone(); + } + catch (JsonException) + { + // A malformed optional return schema must not hide an otherwise valid tool. + returnSchema = null; + } } - using (returnSchemaDocument) return AIFunctionFactory.CreateDeclaration( tool.Name, tool.Description, diff --git a/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs b/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs index 1f7353e2..047b64d2 100644 --- a/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs +++ b/src/OpenClaw.Agent/Plugins/PluginBridgeProcess.cs @@ -139,7 +139,8 @@ public async Task ExecuteToolAsync(string toolName, string argumentsJson return details.GetRawText(); } - if (!result.TryGetProperty("content", out var contentArray)) + if (!result.TryGetProperty("content", out var contentArray) || + contentArray.ValueKind != JsonValueKind.Array) return result.GetRawText(); var sb = new StringBuilder(); diff --git a/src/OpenClaw.Agent/Plugins/PluginHost.cs b/src/OpenClaw.Agent/Plugins/PluginHost.cs index 09b5631e..eb3bca20 100644 --- a/src/OpenClaw.Agent/Plugins/PluginHost.cs +++ b/src/OpenClaw.Agent/Plugins/PluginHost.cs @@ -400,6 +400,7 @@ private async Task LoadPluginAsync(DiscoveredPlugin plugin, CancellationToken ct ChannelCount = initResult.Channels.Length, CommandCount = initResult.Commands.Length, CliCommandCount = initResult.CliCommands.Length, + CliCommandNames = initResult.CliCommands.Select(static command => command.Name).ToArray(), EventSubscriptionCount = initResult.EventSubscriptions.Length, ProviderCount = initResult.Providers.Length, SkillDirectories = skillDirs, diff --git a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs index ea4849d9..57ade509 100644 --- a/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs +++ b/src/OpenClaw.Agent/Plugins/plugin-bridge.mjs @@ -1146,10 +1146,10 @@ async function runStandaloneCli() { await initializeStandaloneCli("full"); const exitCode = await executeCli(process.argv.slice(3)); - process.exit(exitCode); + process.exitCode = exitCode; } catch (error) { console.error(`Plugin CLI error: ${error?.message ?? error}`); - process.exit(1); + process.exitCode = 1; } } diff --git a/src/OpenClaw.Cli/PluginCliCommands.cs b/src/OpenClaw.Cli/PluginCliCommands.cs index ad4b5a28..0294b837 100644 --- a/src/OpenClaw.Cli/PluginCliCommands.cs +++ b/src/OpenClaw.Cli/PluginCliCommands.cs @@ -17,6 +17,7 @@ internal static class PluginCliCommands { private const string ConfigPathEnvironment = "OPENCLAW_CONFIG_PATH"; private const string WorkspaceEnvironment = "OPENCLAW_WORKSPACE"; + private const int MaxDescribeBytes = 1024 * 1024; private static readonly TimeSpan DescribeTimeout = TimeSpan.FromSeconds(20); private static readonly TimeSpan ExecuteTimeout = TimeSpan.FromMinutes(10); @@ -25,6 +26,9 @@ internal static class PluginCliCommands string[] args, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(command) || command.StartsWith('-')) + return null; + var configPathValue = Environment.GetEnvironmentVariable(ConfigPathEnvironment); var configPath = Path.GetFullPath(GatewayConfigFile.ExpandPath( string.IsNullOrWhiteSpace(configPathValue) @@ -155,15 +159,38 @@ internal static async Task DescribeAsync( return PluginCliDescribeResult.Failure($"Unable to start Node.js for plugin CLI discovery: {ex.Message}"); } - var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(DescribeTimeout); - await process.WaitForExitAsync(timeout.Token); + var stdoutTask = ReadCappedAsync(process.StandardOutput.BaseStream, timeout.Token); + var stderrTask = ReadCappedAsync(process.StandardError.BaseStream, timeout.Token); + var exitTask = process.WaitForExitAsync(timeout.Token); + var pendingReads = new List> { stdoutTask, stderrTask }; + while (!exitTask.IsCompleted && pendingReads.Count > 0) + { + var completed = await Task.WhenAny(pendingReads.Cast().Prepend(exitTask)); + if (ReferenceEquals(completed, exitTask)) + break; + + var completedRead = (Task)completed; + if (await completedRead is null) + { + TryKill(process); + await timeout.CancelAsync(); + return PluginCliDescribeResult.Failure("Plugin CLI discovery output exceeded the 1 MiB limit."); + } + + pendingReads.Remove(completedRead); + } + + await exitTask; + await Task.WhenAll(stdoutTask, stderrTask); var stdout = await stdoutTask; var stderr = await stderrTask; + if (stdout is null || stderr is null) + return PluginCliDescribeResult.Failure("Plugin CLI discovery output exceeded the 1 MiB limit."); + if (process.ExitCode != 0) { return PluginCliDescribeResult.Failure( @@ -226,7 +253,8 @@ private static async Task ExecuteAsync( try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeout.CancelAfter(ExecuteTimeout); + if (Console.IsInputRedirected) + timeout.CancelAfter(ExecuteTimeout); await process.WaitForExitAsync(timeout.Token); return process.ExitCode; } @@ -271,6 +299,22 @@ private static Process CreateProcess( return process; } + private static async Task ReadCappedAsync(Stream stream, CancellationToken cancellationToken) + { + using var output = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var read = await stream.ReadAsync(buffer.AsMemory(), cancellationToken); + if (read == 0) + return Encoding.UTF8.GetString(output.GetBuffer(), 0, checked((int)output.Length)); + if (output.Length + read > MaxDescribeBytes) + return null; + + output.Write(buffer, 0, read); + } + } + internal static HashSet LoadBlockedPluginIds(string storagePath) { var result = new HashSet(StringComparer.Ordinal); @@ -317,6 +361,7 @@ private static void TryKill(Process process) } catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or NotSupportedException) { + Debug.WriteLine($"Unable to terminate plugin CLI process: {ex.Message}"); } } diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index 1bdb7ccb..e15ffaac 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -335,7 +335,9 @@ private static async Task InspectAsync(string[] args) } var candidatePath = target; - if (!Directory.Exists(candidatePath) && !File.Exists(candidatePath)) + if (!Directory.Exists(candidatePath) && + !File.Exists(candidatePath) && + !Path.IsPathRooted(candidatePath)) { var extensionsDir = ResolveExtensionsDir(args.Contains("--global") || args.Contains("-g")); candidatePath = Path.Combine(extensionsDir, target); @@ -511,9 +513,12 @@ private static string SanitizePackageName(string name) }; process.Start(); - var stdout = await process.StandardOutput.ReadToEndAsync(); - var stderr = await process.StandardError.ReadToEndAsync(); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); + await Task.WhenAll(stdoutTask, stderrTask); + var stdout = await stdoutTask; + var stderr = await stderrTask; return (process.ExitCode, stdout, stderr); } catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 2) @@ -583,7 +588,7 @@ private static void CopyDirectory(string source, string destination) if (Path.GetExtension(stagedInspection.EntryPath).Equals(".ts", StringComparison.OrdinalIgnoreCase) && stagedInspection.Format != PluginFormats.Bundle && - !HasLocalJiti(stagedInspection.EntryPath)) + !HasLocalJiti(stagedInspection.EntryPath, stagingDir)) { Console.WriteLine("Installing the TypeScript runtime dependency jiti in staging..."); var jitiInstall = await RunNpmAsync("install --ignore-scripts --no-save --omit=dev --omit=optional jiti", stagingDir); @@ -629,6 +634,7 @@ private static void CopyDirectory(string source, string destination) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // Successful install; a stale backup is recoverable. + Debug.WriteLine($"Unable to delete stale plugin backup '{backupDir}': {ex.Message}"); } } @@ -649,24 +655,38 @@ or NotSupportedException if (Directory.Exists(stagingDir)) { try { Directory.Delete(stagingDir, recursive: true); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Debug.WriteLine($"Unable to delete plugin staging directory '{stagingDir}': {ex.Message}"); + } } if (Directory.Exists(backupDir) && Directory.Exists(targetDir)) { try { Directory.Delete(backupDir, recursive: true); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Debug.WriteLine($"Unable to delete plugin backup directory '{backupDir}': {ex.Message}"); + } } } } - private static bool HasLocalJiti(string entryPath) + private static bool HasLocalJiti(string entryPath, string rootDir) { - var current = Path.GetDirectoryName(entryPath); - while (!string.IsNullOrWhiteSpace(current)) + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootDir)); + var current = Path.GetDirectoryName(Path.GetFullPath(entryPath)); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + while (!string.IsNullOrWhiteSpace(current) && + (string.Equals(current, root, comparison) || + current.StartsWith(root + Path.DirectorySeparatorChar, comparison))) { if (Directory.Exists(Path.Combine(current, "node_modules", "jiti"))) return true; + if (string.Equals(current, root, comparison)) + break; var parent = Path.GetDirectoryName(current); if (string.Equals(parent, current, StringComparison.Ordinal)) break; @@ -788,6 +808,7 @@ internal static async Task InspectRuntimeAsync( } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or NotSupportedException) { + Debug.WriteLine($"Unable to terminate plugin inspection process: {ex.Message}"); } } } @@ -798,6 +819,7 @@ internal static async Task InspectRuntimeAsync( if (File.Exists(packaged)) return packaged; +#if DEBUG var source = Path.GetFullPath(Path.Combine( Directory.GetCurrentDirectory(), "src", @@ -805,6 +827,9 @@ internal static async Task InspectRuntimeAsync( "Plugins", "plugin-bridge.mjs")); return File.Exists(source) ? source : null; +#else + return null; +#endif } internal sealed class PluginRuntimeInspection @@ -930,6 +955,7 @@ internal static PluginInstallInspection InspectCandidate(string rootPath, string var warnings = new List(); var diagnostics = new List(); + diagnostics.AddRange(discovery.Reports.SelectMany(static report => report.Diagnostics)); if (!hasManifest && !isBundle) warnings.Add("No openclaw.plugin.json manifest was found. Install is allowed, but declared capabilities and config validation metadata are limited."); if (!hasExtensionsConfig && !hasManifest && !isBundle) @@ -1141,19 +1167,19 @@ private static string BuildDeclaredSurfaceSummary(PluginManifest manifest, Disco sourceExtensions.ValueKind == JsonValueKind.Array ? sourceExtensions : default; - if (extensions.ValueKind != JsonValueKind.Array) - return null; - - foreach (var extension in extensions.EnumerateArray()) + if (extensions.ValueKind == JsonValueKind.Array) { - var relPath = extension.GetString(); - if (string.IsNullOrWhiteSpace(relPath)) - continue; - - if (PluginDiscovery.TryResolveContainedPath(rootPath, relPath, out var resolvedPath) && - File.Exists(resolvedPath)) + foreach (var extension in extensions.EnumerateArray()) { - return resolvedPath; + var relPath = extension.GetString(); + if (string.IsNullOrWhiteSpace(relPath)) + continue; + + if (PluginDiscovery.TryResolveContainedPath(rootPath, relPath, out var resolvedPath) && + File.Exists(resolvedPath)) + { + return resolvedPath; + } } } } @@ -1227,7 +1253,7 @@ private static void AddUnsupportedSurfaceDiagnostic( { Severity = "error", Code = code, - Message = $"Plugin source references api.{apiName}(), which is not supported by OpenClaw.NET.", + Message = $"Plugin source references {apiName}(), which is not supported by OpenClaw.NET.", Surface = apiName, Path = file }); diff --git a/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs b/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs index 5f0429eb..00cd8766 100644 --- a/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs +++ b/src/OpenClaw.Core/Compatibility/PublicCompatibilityCatalog.cs @@ -92,6 +92,7 @@ private static CompatibilityCatalogEntry MapEntry(CompatibilityCatalogManifestEn InstallExtraPackages = entry.InstallExtraPackages ?? [], ExpectedToolNames = entry.ExpectedToolNames ?? [], ExpectedSkillNames = entry.ExpectedSkillNames ?? [], + ExpectedCliCommandNames = entry.ExpectedCliCommandNames ?? [], ExpectedDiagnosticCodes = entry.ExpectedDiagnosticCodes ?? [], Guidance = BuildGuidance(entry, compatibilityStatus).ToArray() }; @@ -158,6 +159,9 @@ private static IEnumerable BuildGuidance(CompatibilityCatalogManifestEnt if (entry.ExpectedSkillNames is { Length: > 0 }) yield return $"Expected bundled skills: {string.Join(", ", entry.ExpectedSkillNames)}."; + if (entry.ExpectedCliCommandNames is { Length: > 0 }) + yield return $"Expected root CLI commands: {string.Join(", ", entry.ExpectedCliCommandNames)}."; + if (compatibilityStatus.Equals("incompatible", StringComparison.OrdinalIgnoreCase) && entry.ExpectedDiagnosticCodes is { Length: > 0 }) { @@ -213,6 +217,7 @@ internal sealed class CompatibilityCatalogManifestEntry public string[]? InstallExtraPackages { get; set; } public string[]? ExpectedToolNames { get; set; } public string[]? ExpectedSkillNames { get; set; } + public string[]? ExpectedCliCommandNames { get; set; } public string[]? ExpectedDiagnosticCodes { get; set; } } diff --git a/src/OpenClaw.Core/Models/CompatibilityCatalogModels.cs b/src/OpenClaw.Core/Models/CompatibilityCatalogModels.cs index 777df5d7..b6076c69 100644 --- a/src/OpenClaw.Core/Models/CompatibilityCatalogModels.cs +++ b/src/OpenClaw.Core/Models/CompatibilityCatalogModels.cs @@ -29,6 +29,7 @@ public sealed class CompatibilityCatalogEntry public string[] InstallExtraPackages { get; init; } = []; public string[] ExpectedToolNames { get; init; } = []; public string[] ExpectedSkillNames { get; init; } = []; + public string[] ExpectedCliCommandNames { get; init; } = []; public string[] ExpectedDiagnosticCodes { get; init; } = []; public string[] Guidance { get; init; } = []; } diff --git a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs index 322ac0db..3e0a4acb 100644 --- a/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs +++ b/src/OpenClaw.Core/Plugins/PluginBundleDetector.cs @@ -26,12 +26,7 @@ internal static bool HasExplicitOrStrongMarker(string rootPath) (Directory.Exists(Path.Combine(cursorRoot, "commands")) || Directory.Exists(Path.Combine(cursorRoot, "agents")) || Directory.Exists(Path.Combine(cursorRoot, "rules")) || - File.Exists(Path.Combine(cursorRoot, "hooks.json")))) || - Directory.Exists(Path.Combine(rootPath, "agents")) || - Directory.Exists(Path.Combine(rootPath, "hooks")) || - File.Exists(Path.Combine(rootPath, ".mcp.json")) || - File.Exists(Path.Combine(rootPath, ".lsp.json")) || - File.Exists(Path.Combine(rootPath, "settings.json")); + File.Exists(Path.Combine(cursorRoot, "hooks.json")))); } public static bool TryDetect( @@ -74,6 +69,19 @@ public static bool TryDetect( using (manifestDocument) { var manifestRoot = manifestDocument?.RootElement; + if (manifestRoot is { ValueKind: not JsonValueKind.Object }) + { + var manifestPath = Path.Combine(rootPath, manifestRelativePath!.Replace('/', Path.DirectorySeparatorChar)); + diagnostic = new PluginCompatibilityDiagnostic + { + Code = "invalid_bundle_manifest", + Message = $"The {bundleFormat} bundle manifest '{manifestPath}' must contain a JSON object.", + Surface = "bundle_manifest", + Path = manifestPath + }; + return true; + } + var rawId = GetString(manifestRoot, "id") ?? GetString(manifestRoot, "name") ?? Path.GetFileName(Path.TrimEndingDirectorySeparator(rootPath)); diff --git a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs index d1a1364b..4c115372 100644 --- a/src/OpenClaw.Core/Plugins/PluginDiscovery.cs +++ b/src/OpenClaw.Core/Plugins/PluginDiscovery.cs @@ -10,6 +10,7 @@ namespace OpenClaw.Core.Plugins; ///
public static class PluginDiscovery { + private const int MaxPluginScanDepth = 8; private const int MaxSymlinkResolutionDepth = 64; private const string ManifestFileName = "openclaw.plugin.json"; @@ -118,11 +119,11 @@ private static void ScanExtensionsDirectory(string extensionsDir, HashSet seen, PluginDiscoveryResult result) + private static void ScanDirectory(string dir, HashSet seen, PluginDiscoveryResult result, int depth = 0) { // Check if this directory is itself a plugin (has manifest) var manifestPath = Path.Combine(dir, ManifestFileName); @@ -196,16 +197,26 @@ private static void ScanDirectory(string dir, HashSet seen, PluginDiscov return; } - // Scan subdirectories - foreach (var subDir in Directory.EnumerateDirectories(dir)) + // Scan subdirectories without following links or traversing arbitrary depth. + if (depth >= MaxPluginScanDepth) + return; + + foreach (var subDir in Directory.EnumerateDirectories(dir, "*", PluginDirectoryEnumerationOptions())) { var name = Path.GetFileName(subDir); if (name is "node_modules" or ".git") continue; - ScanDirectory(subDir, seen, result); + ScanDirectory(subDir, seen, result, depth + 1); } } + private static EnumerationOptions PluginDirectoryEnumerationOptions() + => new() + { + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; + private static void TryAddPluginFromFile(string filePath, HashSet seen, PluginDiscoveryResult result) { var dir = Path.GetDirectoryName(filePath); @@ -268,7 +279,25 @@ private static void TryAddPluginFromManifest(string pluginRoot, string manifestP } if (manifest is null) + { + result.Reports.Add(new PluginLoadReport + { + PluginId = Path.GetFileName(pluginRoot), + SourcePath = Path.GetFullPath(pluginRoot), + EntryPath = null, + Loaded = false, + Diagnostics = + [ + new PluginCompatibilityDiagnostic + { + Code = "invalid_manifest", + Message = $"Manifest '{manifestPath}' must contain a JSON object.", + Path = Path.GetFullPath(manifestPath) + } + ] + }); return; + } if (!seen.Add(manifest.Id)) { @@ -495,7 +524,7 @@ private static bool TryAddPluginPack(string dir, string packageJsonPath, HashSet } ] }); - return true; + return false; } } diff --git a/src/OpenClaw.Core/Plugins/PluginModels.cs b/src/OpenClaw.Core/Plugins/PluginModels.cs index a6b46be6..a46b381d 100644 --- a/src/OpenClaw.Core/Plugins/PluginModels.cs +++ b/src/OpenClaw.Core/Plugins/PluginModels.cs @@ -748,6 +748,7 @@ public sealed class PluginLoadReport public int ChannelCount { get; init; } public int CommandCount { get; init; } public int CliCommandCount { get; init; } + public string[] CliCommandNames { get; init; } = []; public int EventSubscriptionCount { get; init; } public int ProviderCount { get; init; } public string[] SkillDirectories { get; init; } = []; diff --git a/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs b/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs index a3bb1f6c..deaeb6c2 100644 --- a/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs +++ b/src/OpenClaw.Core/Plugins/PluginPackageCompatibility.cs @@ -11,7 +11,26 @@ public static class PluginPackageCompatibility public static readonly Version HostCompatibilityVersion = new(2026, 5, 4); public static IReadOnlyList Validate(DiscoveredPlugin plugin) - => Validate(plugin.PluginApiRange, plugin.MinHostVersion, plugin.Manifest.Id, plugin.RootPath); + { + var diagnostics = Validate( + plugin.PluginApiRange, + plugin.MinHostVersion, + plugin.Manifest.Id, + plugin.RootPath).ToList(); + if (!string.IsNullOrWhiteSpace(plugin.ExpectedIntegrity)) + { + diagnostics.Add(new PluginCompatibilityDiagnostic + { + Severity = "error", + Code = "package_integrity_unverified", + Message = $"Plugin '{plugin.Manifest.Id}' declares package integrity, but extracted plugin directories cannot currently be verified against package-manager integrity metadata.", + Surface = "package_metadata", + Path = plugin.RootPath + }); + } + + return diagnostics; + } public static IReadOnlyList Validate( string? pluginApiRange, @@ -52,15 +71,25 @@ private static void ValidateFloor( return; var normalized = declaredRange.Trim(); - if (normalized.StartsWith(">=", StringComparison.Ordinal)) - normalized = normalized[2..].Trim(); - else if (normalized.StartsWith('v')) + foreach (var rangeOperator in new[] { ">=", "<=", "==", ">", "=", "^", "~" }) + { + if (!normalized.StartsWith(rangeOperator, StringComparison.Ordinal)) + continue; + + normalized = normalized[rangeOperator.Length..].Trim(); + break; + } + + if (normalized.StartsWith('v') || normalized.StartsWith('V')) normalized = normalized[1..]; var suffixIndex = normalized.IndexOfAny(['-', '+', ' ', '<', '>', '|']); if (suffixIndex >= 0) normalized = normalized[..suffixIndex]; + if (!normalized.Contains('.', StringComparison.Ordinal) && normalized.Length > 0) + normalized += ".0"; + if (!Version.TryParse(normalized, out var requiredVersion)) { diagnostics.Add(new PluginCompatibilityDiagnostic diff --git a/src/OpenClaw.Core/Skills/SkillLoader.cs b/src/OpenClaw.Core/Skills/SkillLoader.cs index 34381b5d..4a794f85 100644 --- a/src/OpenClaw.Core/Skills/SkillLoader.cs +++ b/src/OpenClaw.Core/Skills/SkillLoader.cs @@ -302,7 +302,7 @@ private static string NormalizeBundleCommandContent(string content, string comma var frontmatterEnd = content.IndexOf("\n---", 3, StringComparison.Ordinal); var frontmatter = content[3..frontmatterEnd]; if (!frontmatter.Split('\n').Any(static line => - line.TrimStart().StartsWith("name:", StringComparison.OrdinalIgnoreCase))) + line.TrimEnd('\r').StartsWith("name:", StringComparison.OrdinalIgnoreCase))) { var firstNewline = content.IndexOf('\n'); return firstNewline >= 0 diff --git a/src/OpenClaw.Tests/OpenClawToolExecutorTests.cs b/src/OpenClaw.Tests/OpenClawToolExecutorTests.cs index 0e89b6dd..4ab16f8e 100644 --- a/src/OpenClaw.Tests/OpenClawToolExecutorTests.cs +++ b/src/OpenClaw.Tests/OpenClawToolExecutorTests.cs @@ -11,6 +11,14 @@ namespace OpenClaw.Tests; public sealed class OpenClawToolExecutorTests { + [Fact] + public void CreateDeclaration_MalformedOptionalOutputSchema_KeepsToolAvailable() + { + var declaration = OpenClawToolExecutor.CreateDeclaration(new MalformedOutputSchemaTool()); + + Assert.Equal("malformed_output", declaration.Name); + } + [Fact] public async Task ExecuteAsync_ApprovalRequiredWithoutCallback_DeniesExecution() { @@ -625,4 +633,15 @@ public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct return ValueTask.FromResult(result); } } + + private sealed class MalformedOutputSchemaTool : ITool, IToolOutputSchema + { + public string Name => "malformed_output"; + public string Description => "Tool with an invalid optional output schema"; + public string ParameterSchema => """{"type":"object"}"""; + public string? OutputSchema => "{not-json"; + + public ValueTask ExecuteAsync(string argumentsJson, CancellationToken ct) + => ValueTask.FromResult("ok"); + } } diff --git a/src/OpenClaw.Tests/PluginCommandsTests.cs b/src/OpenClaw.Tests/PluginCommandsTests.cs index 554b1a82..e7754690 100644 --- a/src/OpenClaw.Tests/PluginCommandsTests.cs +++ b/src/OpenClaw.Tests/PluginCommandsTests.cs @@ -188,6 +188,38 @@ public async Task PluginCliCommands_ExecutesNestedCommandWithArgumentsAndOptions } } + [Fact] + public async Task PluginCliCommands_DescribeRejectsOversizedOutput() + { + if (!HasNode()) + return; + + var root = CreateTempRoot(); + try + { + var entryPath = Path.Combine(root, "index.js"); + File.WriteAllText( + entryPath, + "process.stdout.write('x'.repeat(1100000)); module.exports = api => api.registerCli(({ program }) => program.command('fixture')); "); + var bridgeScript = PluginCommands.ResolveBridgeScriptPath(); + Assert.NotNull(bridgeScript); + + var description = await PluginCliCommands.DescribeAsync( + entryPath, + "oversized-cli-plugin", + pluginConfig: null, + bridgeScript, + TestContext.Current.CancellationToken); + + Assert.False(description.Success); + Assert.Contains("1 MiB", description.Error, StringComparison.Ordinal); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void PluginCliCommands_LoadBlockedPluginIds_ReadsQuarantineState() { @@ -232,6 +264,29 @@ public void InspectCandidate_WithStandaloneEntry_ReturnsUntrustedWarning() } } + [Fact] + public void InspectCandidate_WithCompatOnlyOpenClawMetadata_UsesConventionalEntry() + { + var root = CreateTempRoot(); + try + { + File.WriteAllText( + Path.Combine(root, "package.json"), + """{"name":"compat-only","openclaw":{"compat":{"pluginApi":"^2026.5.0"}}}"""); + File.WriteAllText(Path.Combine(root, "index.js"), "module.exports = () => {};"); + + var inspection = PluginCommands.InspectCandidate(root, "./compat-only", sourceIsNpm: false); + + Assert.True(inspection.Success); + Assert.True(inspection.CanInstall); + Assert.EndsWith("index.js", inspection.EntryPath, StringComparison.Ordinal); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + [Fact] public void InspectCandidate_WithCompatibleBundle_ReportsMappedAndDetectedCapabilities() { @@ -423,7 +478,7 @@ private static bool HasNode() process?.WaitForExit(3000); return process is { ExitCode: 0 }; } - catch + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) { return false; } diff --git a/src/OpenClaw.Tests/PluginTests.cs b/src/OpenClaw.Tests/PluginTests.cs index 8c07b94a..70d32f99 100644 --- a/src/OpenClaw.Tests/PluginTests.cs +++ b/src/OpenClaw.Tests/PluginTests.cs @@ -79,7 +79,9 @@ public void Discover_PrefersBuiltRuntimeExtensionAndReadsCompatibilityMetadata() Assert.Equal(">=2026.5.4", plugin.PluginApiRange); Assert.Equal(">=2026.5.4", plugin.MinHostVersion); Assert.Equal("sha512-example", plugin.ExpectedIntegrity); - Assert.Empty(PluginPackageCompatibility.Validate(plugin)); + Assert.Contains( + PluginPackageCompatibility.Validate(plugin), + diagnostic => diagnostic.Code == "package_integrity_unverified"); } [Theory] @@ -153,6 +155,10 @@ public void Discover_StandaloneEntryWithWeakBundleFolders_RemainsNative() var pluginDir = Path.Combine(_tempDir, "standalone-with-content"); Directory.CreateDirectory(Path.Combine(pluginDir, "skills")); Directory.CreateDirectory(Path.Combine(pluginDir, "commands")); + Directory.CreateDirectory(Path.Combine(pluginDir, "agents")); + Directory.CreateDirectory(Path.Combine(pluginDir, "hooks")); + File.WriteAllText(Path.Combine(pluginDir, ".mcp.json"), "{}"); + File.WriteAllText(Path.Combine(pluginDir, "settings.json"), "{}"); File.WriteAllText(Path.Combine(pluginDir, "index.js"), "module.exports = () => {};"); var plugin = Assert.Single(PluginDiscovery.Discover(new PluginsConfig @@ -164,6 +170,39 @@ public void Discover_StandaloneEntryWithWeakBundleFolders_RemainsNative() Assert.EndsWith("index.js", plugin.EntryPath, StringComparison.Ordinal); } + [Theory] + [InlineData("[]")] + [InlineData("null")] + public void Discover_BundleManifestMustBeJsonObject(string manifestJson) + { + var bundleDir = Path.Combine(_tempDir, $"invalid-bundle-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(bundleDir, ".codex-plugin")); + File.WriteAllText(Path.Combine(bundleDir, ".codex-plugin", "plugin.json"), manifestJson); + + var result = PluginDiscovery.DiscoverWithDiagnostics(new PluginsConfig + { + Load = new PluginLoadConfig { Paths = [bundleDir] } + }); + + Assert.Empty(result.Plugins); + Assert.Contains( + result.Reports.SelectMany(static report => report.Diagnostics), + diagnostic => diagnostic.Code == "invalid_bundle_manifest"); + } + + [Theory] + [InlineData("^2026.5.0")] + [InlineData("~2026.5.0")] + [InlineData("2026")] + public void PackageCompatibility_NormalizesCommonNpmVersionRanges(string range) + { + Assert.Empty(PluginPackageCompatibility.Validate( + range, + null, + "range-plugin", + _tempDir)); + } + [Fact] public void Discover_SkipsBrokenManifestJson() { diff --git a/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs b/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs index 5c07b10a..731774e1 100644 --- a/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs +++ b/src/OpenClaw.Tests/PublicCompatibilitySmokeTests.cs @@ -66,8 +66,9 @@ public static IEnumerable LatestNpmScenarioIds() [Trait("Category", "LatestCanary")] public async Task LatestNpmPackage_ReportsCompatibilityDrift(string scenarioId) { - if (!HasNode() || !IsLatestCanaryEnabled()) + if (!IsLatestCanaryEnabled()) return; + Assert.True(HasNode(), "OPENCLAW_LATEST_CANARY is enabled, but Node.js is unavailable."); var entry = PublicCompatibilityCatalog.GetCatalog().Items.Single(item => item.Id == scenarioId); Assert.False(string.IsNullOrWhiteSpace(entry.PackageName)); @@ -153,6 +154,9 @@ private async Task VerifyNpmPluginAsync( foreach (var toolName in entry.ExpectedToolNames ?? []) Assert.Contains(tools, tool => string.Equals(tool.Name, toolName, StringComparison.Ordinal)); + foreach (var commandName in entry.ExpectedCliCommandNames ?? []) + Assert.Contains(report.CliCommandNames, name => string.Equals(name, commandName, StringComparison.Ordinal)); + if (entry.ExpectedSkillNames is { Length: > 0 }) { var skills = SkillLoader.LoadAll( diff --git a/src/OpenClaw.Tests/SkillTests.cs b/src/OpenClaw.Tests/SkillTests.cs index 173aa341..3f55f465 100644 --- a/src/OpenClaw.Tests/SkillTests.cs +++ b/src/OpenClaw.Tests/SkillTests.cs @@ -3982,6 +3982,41 @@ public void LoadAll_PluginCommandRoot_MapsMarkdownCommandsIntoSkills() Directory.Delete(root, recursive: true); } } + + [Fact] + public void LoadAll_PluginCommandRoot_IgnoresNestedFrontmatterName() + { + var root = Path.Combine(Path.GetTempPath(), $"openclaw-bundle-command-name-{Guid.NewGuid():N}"); + var commands = Path.Combine(root, "commands"); + Directory.CreateDirectory(commands); + File.WriteAllText( + Path.Combine(commands, "review.md"), + "---\nmetadata:\n name: nested-value\ndescription: Review a proposed change\n---\nReview the change."); + + try + { + var skills = SkillLoader.LoadAll( + new SkillsConfig + { + Enabled = true, + Load = new SkillLoadConfig + { + IncludeBundled = false, + IncludeManaged = false, + IncludeWorkspace = false + } + }, + null, + NullLogger.Instance, + [commands]); + + Assert.Equal("review", Assert.Single(skills).Name); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } /// Minimal ILogger for tests. From ffc6152b55227ea384c0c939e8717bbd068eeeb1 Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 23:25:59 -0700 Subject: [PATCH 8/9] Polish plugin CLI diagnostics and tests --- src/OpenClaw.Cli/PluginCliCommands.cs | 4 +++ src/OpenClaw.Cli/PluginCommands.cs | 7 ++-- src/OpenClaw.Cli/Program.cs | 4 +++ src/OpenClaw.Core/Skills/SkillLoader.cs | 17 +++++++-- .../PluginBridgeIntegrationTests.cs | 27 +++++++++++--- src/OpenClaw.Tests/PluginCommandsTests.cs | 35 +++++++++++++++++-- 6 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.Cli/PluginCliCommands.cs b/src/OpenClaw.Cli/PluginCliCommands.cs index 0294b837..ec2a2ce3 100644 --- a/src/OpenClaw.Cli/PluginCliCommands.cs +++ b/src/OpenClaw.Cli/PluginCliCommands.cs @@ -109,7 +109,11 @@ internal static class PluginCliCommands bridgeScript, cancellationToken); if (!description.Success) + { + Console.Error.WriteLine( + $"Plugin '{plugin.Manifest.Id}' did not report CLI commands: {description.Error}"); continue; + } if (description.Commands.Any(item => string.Equals(item.Name, command, StringComparison.Ordinal))) diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index e15ffaac..28d75cfe 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -740,11 +740,12 @@ internal static async Task InspectRuntimeAsync( { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(20)); + using var emptyConfig = JsonDocument.Parse("{}"); var initRequest = new BridgeInitRequest { EntryPath = Path.GetFullPath(entryPath), PluginId = pluginId, - Config = JsonDocument.Parse("{}").RootElement.Clone(), + Config = emptyConfig.RootElement.Clone(), Transport = new BridgeTransportRuntimeConfig { Mode = "stdio" } }; var request = new BridgeRequest @@ -1244,7 +1245,7 @@ private static void AddUnsupportedSurfaceDiagnostic( { if (!System.Text.RegularExpressions.Regex.IsMatch( source, - $@"\b{System.Text.RegularExpressions.Regex.Escape(apiName)}\s*\(", + $@"\bapi\s*\.\s*{System.Text.RegularExpressions.Regex.Escape(apiName)}\s*\(", System.Text.RegularExpressions.RegexOptions.CultureInvariant) || diagnostics.Any(item => string.Equals(item.Code, code, StringComparison.Ordinal))) return; @@ -1253,7 +1254,7 @@ private static void AddUnsupportedSurfaceDiagnostic( { Severity = "error", Code = code, - Message = $"Plugin source references {apiName}(), which is not supported by OpenClaw.NET.", + Message = $"Plugin source references api.{apiName}(), which is not supported by OpenClaw.NET.", Surface = apiName, Path = file }); diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs index 88e55f8d..f5def9ca 100644 --- a/src/OpenClaw.Cli/Program.cs +++ b/src/OpenClaw.Cli/Program.cs @@ -234,6 +234,10 @@ openclaw plugins remove Remove a plugin openclaw plugins list List installed plugins openclaw plugins search Search npm for plugins + Installed plugins may also register root commands. Built-in commands + take precedence; disabled or quarantined plugins are not dispatched. + openclaw --help Show plugin command help + Skill management: openclaw skill new "Community Research Insight Extractor" --category research openclaw skill validate community.research_insight diff --git a/src/OpenClaw.Core/Skills/SkillLoader.cs b/src/OpenClaw.Core/Skills/SkillLoader.cs index 4a794f85..da358f5e 100644 --- a/src/OpenClaw.Core/Skills/SkillLoader.cs +++ b/src/OpenClaw.Core/Skills/SkillLoader.cs @@ -282,10 +282,21 @@ private static void ScanBundleCommandFiles( var normalized = NormalizeBundleCommandContent(content, commandName); var commandDir = Path.GetDirectoryName(commandFile) ?? commandRoot; var skill = ParseSkillContent(normalized, commandDir, SkillSource.Plugin); - if (skill is not null) - results[skill.Name] = skill; - else + if (skill is null) + { logger.LogWarning("Failed to map bundle command at {Path} into a skill", commandFile); + continue; + } + + if (results.ContainsKey(skill.Name)) + { + logger.LogWarning( + "Bundle command at {Path} overwrites existing skill '{Name}'", + commandFile, + skill.Name); + } + + results[skill.Name] = skill; } catch (Exception ex) when (IsPathException(ex)) { diff --git a/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs b/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs index ae44194d..b70d86ab 100644 --- a/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs +++ b/src/OpenClaw.Tests/PluginBridgeIntegrationTests.cs @@ -1515,10 +1515,29 @@ public async Task BridgeTransportModes_RestartAfterChildExit(string transportMod for (var attempt = 1; attempt <= 5; attempt++) { Assert.Equal("restarting", await tool.ExecuteAsync("""{"kill":true}""", TestContext.Current.CancellationToken)); - await Task.Delay(150, TestContext.Current.CancellationToken); - Assert.Equal( - $"echo:after-{attempt}", - await tool.ExecuteAsync($$"""{"text":"after-{{attempt}}"}""", TestContext.Current.CancellationToken)); + var expected = $"echo:after-{attempt}"; + string? actual = null; + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline) + { + try + { + actual = await tool.ExecuteAsync( + $$"""{"text":"after-{{attempt}}"}""", + TestContext.Current.CancellationToken); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException) + { + actual = ex.Message; + } + + if (string.Equals(actual, expected, StringComparison.Ordinal)) + break; + + await Task.Delay(50, TestContext.Current.CancellationToken); + } + + Assert.Equal(expected, actual); } } diff --git a/src/OpenClaw.Tests/PluginCommandsTests.cs b/src/OpenClaw.Tests/PluginCommandsTests.cs index e7754690..b3bbf7d9 100644 --- a/src/OpenClaw.Tests/PluginCommandsTests.cs +++ b/src/OpenClaw.Tests/PluginCommandsTests.cs @@ -67,7 +67,29 @@ public void InspectCandidate_WithRegisterCli_AllowsInstall() Assert.True(inspection.Success); Assert.True(inspection.CanInstall); Assert.Equal("manifest-valid", inspection.CompatibilityStatus); - Assert.DoesNotContain(inspection.Diagnostics, item => item.Code == "unsupported_cli_registration"); + Assert.DoesNotContain( + inspection.Diagnostics, + item => string.Equals(item.Severity, "error", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void InspectCandidate_WithRegisterGatewayMethod_BlocksInstall() + { + var root = CreateTempRoot(); + try + { + File.WriteAllText(Path.Combine(root, "index.js"), "module.exports = api => api.registerGatewayMethod('unsafe', () => {});"); + + var inspection = PluginCommands.InspectCandidate(root, "./gateway-method-plugin", sourceIsNpm: false); + + Assert.True(inspection.Success); + Assert.False(inspection.CanInstall); + Assert.Contains(inspection.Diagnostics, item => item.Code == "unsupported_gateway_method"); } finally { @@ -475,8 +497,15 @@ private static bool HasNode() UseShellExecute = false, CreateNoWindow = true }); - process?.WaitForExit(3000); - return process is { ExitCode: 0 }; + if (process is null) + return false; + if (!process.WaitForExit(3000)) + { + process.Kill(entireProcessTree: true); + return false; + } + + return process.ExitCode == 0; } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) { From 2f8708e3fc2877ad97c04920604229e51608d32b Mon Sep 17 00:00:00 2001 From: telli Date: Mon, 3 Aug 2026 23:35:09 -0700 Subject: [PATCH 9/9] Harden static plugin source scanning --- src/OpenClaw.Cli/PluginCommands.cs | 25 ++++++++++++------- src/OpenClaw.Tests/PluginCommandsTests.cs | 29 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/OpenClaw.Cli/PluginCommands.cs b/src/OpenClaw.Cli/PluginCommands.cs index 28d75cfe..bca532f2 100644 --- a/src/OpenClaw.Cli/PluginCommands.cs +++ b/src/OpenClaw.Cli/PluginCommands.cs @@ -1218,19 +1218,28 @@ private static void InspectUnsupportedRuntimeSurfaces( private static IEnumerable EnumeratePluginSourceFiles(string rootPath) { - var pending = new Stack(); - pending.Push(rootPath); + const int maxDepth = 16; + var enumerationOptions = new EnumerationOptions + { + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; + var pending = new Stack<(string Directory, int Depth)>(); + pending.Push((rootPath, 0)); while (pending.Count > 0) { - var directory = pending.Pop(); - foreach (var child in Directory.EnumerateDirectories(directory)) + var (directory, depth) = pending.Pop(); + if (depth < maxDepth) { - var name = Path.GetFileName(child); - if (name is not "node_modules" and not ".git") - pending.Push(child); + foreach (var child in Directory.EnumerateDirectories(directory, "*", enumerationOptions)) + { + var name = Path.GetFileName(child); + if (name is not "node_modules" and not ".git") + pending.Push((child, depth + 1)); + } } - foreach (var file in Directory.EnumerateFiles(directory) + foreach (var file in Directory.EnumerateFiles(directory, "*", enumerationOptions) .Where(file => Path.GetExtension(file) is ".js" or ".mjs" or ".cjs" or ".ts")) yield return file; } diff --git a/src/OpenClaw.Tests/PluginCommandsTests.cs b/src/OpenClaw.Tests/PluginCommandsTests.cs index b3bbf7d9..2b03e39a 100644 --- a/src/OpenClaw.Tests/PluginCommandsTests.cs +++ b/src/OpenClaw.Tests/PluginCommandsTests.cs @@ -97,6 +97,35 @@ public void InspectCandidate_WithRegisterGatewayMethod_BlocksInstall() } } + [Fact] + public void InspectCandidate_DoesNotFollowSourceDirectorySymlinks() + { + if (OperatingSystem.IsWindows()) + return; + + var root = CreateTempRoot(); + var outside = CreateTempRoot(); + try + { + File.WriteAllText(Path.Combine(root, "index.js"), "module.exports = () => {};"); + File.WriteAllText( + Path.Combine(outside, "outside.js"), + "module.exports = api => api.registerGatewayMethod('outside', () => {});"); + Directory.CreateSymbolicLink(Path.Combine(root, "linked-source"), outside); + + var inspection = PluginCommands.InspectCandidate(root, "./symlink-safe-plugin", sourceIsNpm: false); + + Assert.True(inspection.Success); + Assert.True(inspection.CanInstall); + Assert.DoesNotContain(inspection.Diagnostics, item => item.Code == "unsupported_gateway_method"); + } + finally + { + Directory.Delete(root, recursive: true); + Directory.Delete(outside, recursive: true); + } + } + [Fact] public void InspectCandidate_WithNewerPluginApiFloor_BlocksInstall() {