[codex] add Crestodian plugin management (#75869)

Summary:
- The branch adds ClawHub plugin search and Crestodian plugin list/search/install/uninstall flows, with docs, changelog, tests, runtime injection, and regenerated config baseline hashes.
- Reproducibility: not applicable. as a bug reproduction request. The high-confidence verification path is cur ... surface search plus exact-head diff/source inspection against the PR's targeted tests and queued CI checks.

ClawSweeper fixups:
- Included follow-up commit: Repair Crestodian plugin management config schema drift

Validation:
- ClawSweeper review passed for head c29cda6005.
- Required merge gates passed before the squash merge.

Prepared head SHA: c29cda6005
Review: https://github.com/openclaw/openclaw/pull/75869#issuecomment-4362360704

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-02 04:12:38 +01:00
committed by GitHub
parent 47f76c563f
commit eee3aeae00
20 changed files with 920 additions and 270 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc.
- Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc.
- Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc.
- Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.
- Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R.
- Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft.
- Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:<name>` across channel auth paths. (#75813)

View File

@@ -1,4 +1,4 @@
35224e7970e71225a51482432f1618ae3b54be9615956d8554a0e2df3d263bc8 config-baseline.json
80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json
eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json
af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json
ae25cb1d397f1ea9642047ef13d35300c807cb1cd67f681c0b5af83b572b3638 config-baseline.json
0a1907d595765b8bb7a41348d14323920ab50e402be49a19a45a4e2499306407 config-baseline.core.json
c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json
7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json

View File

@@ -71,6 +71,10 @@ agents
create agent work workspace ~/Projects/work
models
set default model openai/gpt-5.5
plugins list
plugins search slack
plugin install clawhub:openclaw-codex-app-server
plugin uninstall openclaw-codex-app-server
talk to work agent
talk to agent for ~/Projects/work
audit
@@ -99,6 +103,8 @@ Read-only operations can run immediately:
- show overview
- list agents
- list installed plugins
- search ClawHub plugins
- show model/backend status
- run status or health checks
- check Gateway reachability
@@ -116,6 +122,8 @@ you pass `--yes` for a direct command:
- change the default model
- start, stop, or restart the Gateway
- create agents
- install plugins from ClawHub or npm
- uninstall plugins
- run doctor repairs that rewrite config or state
Applied writes are recorded in:
@@ -240,6 +248,9 @@ Security contract for remote rescue:
- Require an explicit owner identity. Rescue must not accept wildcard sender
rules, open group policy, unauthenticated webhooks, or anonymous channels.
- Owner DMs only by default. Group/channel rescue requires explicit opt-in.
- Plugin search and list are read-only. Plugin install is local-only by default
because it downloads executable code. Plugin uninstall can be allowed as an
approved repair operation when rescue policy permits persistent writes.
- Remote rescue cannot open the local TUI or switch into an interactive agent
session. Use local `openclaw` for agent handoff.
- Persistent writes still require approval, even in rescue mode.

View File

@@ -31,6 +31,9 @@ openclaw plugins list
openclaw plugins list --enabled
openclaw plugins list --verbose
openclaw plugins list --json
openclaw plugins search <query>
openclaw plugins search <query> --limit 20
openclaw plugins search <query> --json
openclaw plugins install <path-or-spec>
openclaw plugins inspect <id>
openclaw plugins inspect <id> --runtime
@@ -64,6 +67,7 @@ Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Sch
### Install
```bash
openclaw plugins search "calendar" # search ClawHub plugins
openclaw plugins install <package> # ClawHub first, then npm
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install npm:<package> # npm only
@@ -82,6 +86,10 @@ openclaw plugins install <plugin> --marketplace https://github.com/<owner>/<repo
Bare package names are checked against ClawHub first, then npm. Treat plugin installs like running code. Prefer pinned versions.
</Warning>
`plugins search` queries ClawHub for installable plugin packages and prints
install-ready package names. It searches code-plugin and bundle-plugin packages,
not skills. Use `openclaw skills search` for ClawHub skills.
<Note>
ClawHub is the primary distribution and discovery surface for most plugins. Npm
remains a supported fallback and direct-install path. During the migration to
@@ -217,6 +225,9 @@ openclaw plugins list
openclaw plugins list --enabled
openclaw plugins list --verbose
openclaw plugins list --json
openclaw plugins search <query>
openclaw plugins search <query> --limit 20
openclaw plugins search <query> --json
```
<ParamField path="--enabled" type="boolean">
@@ -233,6 +244,11 @@ openclaw plugins list --json
`plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process.
</Note>
`plugins search` is a remote ClawHub catalog lookup. It does not inspect local
state, mutate config, install packages, or load plugin runtime code. Search
results include the ClawHub package name, family, channel, version, summary, and
an install hint such as `openclaw plugins install clawhub:<package>`.
For bundled plugin work inside a packaged Docker image, bind-mount the plugin
source directory over the matching packaged source path, such as
`/app/extensions/synology-chat`. OpenClaw will discover that mounted source

View File

@@ -60,11 +60,14 @@ Site: [clawhub.ai](https://clawhub.ai)
</Tab>
<Tab title="Plugins">
```bash
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>
openclaw plugins update --all
```
Bare npm-safe plugin specs are also tried against ClawHub before npm:
`plugins search` queries the ClawHub plugin catalog and prints install-ready
package names. Bare npm-safe plugin specs are also tried against ClawHub
before npm:
```bash
openclaw plugins install openclaw-codex-app-server

View File

@@ -27,6 +27,12 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes.
<Step title="Install a plugin">
```bash
# Search ClawHub plugins
openclaw plugins search "calendar"
# From ClawHub
openclaw plugins install clawhub:openclaw-codex-app-server
# From npm
openclaw plugins install npm:@acme/openclaw-plugin
@@ -433,6 +439,7 @@ openclaw plugins list # compact inventory
openclaw plugins list --enabled # only enabled plugins
openclaw plugins list --verbose # per-plugin detail lines
openclaw plugins list --json # machine-readable inventory
openclaw plugins search <query> # search ClawHub plugin catalog
openclaw plugins inspect <id> # static detail
openclaw plugins inspect <id> --runtime # registered hooks/tools/CLI/gateway methods
openclaw plugins inspect <id> --json # machine-readable

View File

@@ -151,6 +151,8 @@ function restoreRuntimeCaptureMocks() {
vi.mock("../runtime.js", () => ({
defaultRuntime,
writeRuntimeJson: (runtime: CliMockOutputRuntime, value: unknown, space = 2) =>
runtime.writeJson(value, space),
}));
vi.mock("../config/config.js", () => ({

View File

@@ -1,17 +1,10 @@
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
tracePluginLifecyclePhase,
tracePluginLifecyclePhaseAsync,
} from "../plugins/plugin-lifecycle-trace.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import type { PluginInspectOptions } from "./plugins-inspect-command.js";
import type { PluginsListOptions } from "./plugins-list-command.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
@@ -26,6 +19,11 @@ export type PluginMarketplaceListOptions = {
json?: boolean;
};
export type PluginSearchOptions = {
json?: boolean;
limit?: number;
};
export type PluginUninstallOptions = {
keepFiles?: boolean;
/** @deprecated Use keepFiles. */
@@ -74,6 +72,17 @@ export function registerPluginsCli(program: Command) {
await runPluginsListCommand(opts);
});
plugins
.command("search")
.description("Search ClawHub plugin packages")
.argument("[query...]", "Search query")
.option("--limit <n>", "Max results", (value) => Number.parseInt(value, 10))
.option("--json", "Print JSON", false)
.action(async (queryParts: string[], opts: PluginSearchOptions) => {
const { runPluginsSearchCommand } = await import("./plugins-search-command.js");
await runPluginsSearchCommand(queryParts, opts);
});
plugins
.command("inspect")
.alias("info")
@@ -162,172 +171,8 @@ export function registerPluginsCli(program: Command) {
.option("--force", "Skip confirmation prompt", false)
.option("--dry-run", "Show what would be removed without making changes", false)
.action(async (id: string, opts: PluginUninstallOptions) => {
const {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} = await import("../plugins/installed-plugin-index-records.js");
const { buildPluginSnapshotReport } = await import("../plugins/status.js");
const {
applyPluginUninstallDirectoryRemoval,
formatUninstallActionLabels,
formatUninstallSlotResetPreview,
planPluginUninstall,
resolveUninstallChannelConfigKeys,
UNINSTALL_ACTION_LABELS,
} = await import("../plugins/uninstall.js");
const { commitPluginInstallRecordsWithConfig } =
await import("./plugins-install-record-commit.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
const { promptYesNo } = await import("./prompt.js");
const snapshot = await tracePluginLifecyclePhaseAsync(
"config read",
() => readConfigFileSnapshot(),
{ command: "uninstall" },
);
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const installRecords = await tracePluginLifecyclePhaseAsync(
"install records load",
() => loadInstalledPluginIndexInstallRecords(),
{ command: "uninstall" },
);
const cfg = withPluginInstallRecords(sourceConfig, installRecords);
const report = tracePluginLifecyclePhase(
"plugin registry snapshot",
() => buildPluginSnapshotReport({ config: cfg }),
{ command: "uninstall" },
);
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
if (opts.keepConfig) {
defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`."));
}
const { plugin, pluginId } = resolvePluginUninstallId({
rawId: id,
config: cfg,
plugins: report.plugins,
});
const hasEntry = pluginId in (cfg.plugins?.entries ?? {});
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
if (!hasEntry && !hasInstall) {
if (plugin) {
defaultRuntime.error(
`Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`,
);
} else {
defaultRuntime.error(`Plugin not found: ${id}`);
}
return defaultRuntime.exit(1);
}
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
const plan = planPluginUninstall({
config: cfg,
pluginId,
channelIds,
deleteFiles: !keepFiles,
extensionsDir,
});
if (!plan.ok) {
defaultRuntime.error(plan.error);
return defaultRuntime.exit(1);
}
const preview: string[] = [];
if (plan.actions.entry) {
preview.push(UNINSTALL_ACTION_LABELS.entry);
}
if (plan.actions.install) {
preview.push(UNINSTALL_ACTION_LABELS.install);
}
if (plan.actions.allowlist) {
preview.push(UNINSTALL_ACTION_LABELS.allowlist);
}
if (plan.actions.denylist) {
preview.push(UNINSTALL_ACTION_LABELS.denylist);
}
if (plan.actions.loadPath) {
preview.push(UNINSTALL_ACTION_LABELS.loadPath);
}
if (plan.actions.memorySlot) {
preview.push(formatUninstallSlotResetPreview("memory"));
}
if (plan.actions.contextEngineSlot) {
preview.push(formatUninstallSlotResetPreview("contextEngine"));
}
const channels = cfg.channels as Record<string, unknown> | undefined;
if (plan.actions.channelConfig && hasInstall && channels) {
for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) {
if (Object.hasOwn(channels, key)) {
preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`);
}
}
}
if (plan.directoryRemoval) {
preview.push(`directory: ${shortenHomePath(plan.directoryRemoval.target)}`);
}
const pluginName = plugin?.name || pluginId;
defaultRuntime.log(
`Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`,
);
defaultRuntime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`);
if (opts.dryRun) {
defaultRuntime.log(theme.muted("Dry run, no changes made."));
return;
}
if (!opts.force) {
const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
if (!confirmed) {
defaultRuntime.log("Cancelled.");
return;
}
}
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
const nextConfig = withoutPluginInstallRecords(plan.config);
await tracePluginLifecyclePhaseAsync(
"config mutation",
() =>
commitPluginInstallRecordsWithConfig({
previousInstallRecords: installRecords,
nextInstallRecords,
nextConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
}),
{ command: "uninstall" },
);
const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
for (const warning of directoryResult.warnings) {
defaultRuntime.log(theme.warn(warning));
}
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
reason: "source-changed",
installRecords: nextInstallRecords,
traceCommand: "uninstall",
logger: {
warn: (message) => defaultRuntime.log(theme.warn(message)),
},
});
const removed = formatUninstallActionLabels({
...plan.actions,
directory: directoryResult.directoryRemoved,
});
defaultRuntime.log(
`Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`,
);
defaultRuntime.log("Restart the gateway to apply changes.");
const { runPluginUninstallCommand } = await import("./plugins-uninstall-command.js");
await runPluginUninstallCommand(id, opts);
});
plugins

View File

@@ -6,7 +6,7 @@ import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-r
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import type { PluginLogger } from "../plugins/types.js";
import { defaultRuntime } from "../runtime.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { theme } from "../terminal/theme.js";
@@ -129,23 +129,23 @@ export function applySlotSelectionForPlugin(
return { config: result.config, warnings: result.warnings };
}
export function createPluginInstallLogger(): {
export function createPluginInstallLogger(runtime: RuntimeEnv = defaultRuntime): {
info: (msg: string) => void;
warn: (msg: string) => void;
} {
return {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
info: (msg) => runtime.log(msg),
warn: (msg) => runtime.log(theme.warn(msg)),
};
}
export function createHookPackInstallLogger(): {
export function createHookPackInstallLogger(runtime: RuntimeEnv = defaultRuntime): {
info: (msg: string) => void;
warn: (msg: string) => void;
} {
return {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
info: (msg) => runtime.log(msg),
warn: (msg) => runtime.log(theme.warn(msg)),
};
}
@@ -191,16 +191,16 @@ export function formatPluginInstallWithHookFallbackError(
return `${pluginError}\nAlso not a valid hook pack: ${hookError}`;
}
export function logHookPackRestartHint() {
defaultRuntime.log("Restart the gateway to load hooks.");
export function logHookPackRestartHint(runtime: RuntimeEnv = defaultRuntime) {
runtime.log("Restart the gateway to load hooks.");
}
export function logSlotWarnings(warnings: string[]) {
export function logSlotWarnings(warnings: string[], runtime: RuntimeEnv = defaultRuntime) {
if (warnings.length === 0) {
return;
}
for (const warning of warnings) {
defaultRuntime.log(theme.warn(warning));
runtime.log(theme.warn(warning));
}
}

View File

@@ -22,7 +22,7 @@ import {
} from "../plugins/marketplace.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { defaultRuntime } from "../runtime.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
@@ -110,6 +110,7 @@ async function installBundledPluginSource(params: {
rawSpec: string;
bundledSource: BundledPluginSource;
warning: string;
runtime?: RuntimeEnv;
}) {
const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId];
const shouldEnable = hasValidBundledPluginConfig({
@@ -136,6 +137,7 @@ async function installBundledPluginSource(params: {
},
enable: shouldEnable,
warningMessage: [params.warning, configWarning].filter(Boolean).join("\n"),
runtime: params.runtime,
});
}
@@ -145,6 +147,7 @@ async function tryInstallHookPackFromLocalPath(params: {
installMode: "install" | "update";
safetyOverrides?: InstallSafetyOverrides;
link?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (params.link) {
const stat = fs.statSync(params.resolvedPath);
@@ -193,6 +196,7 @@ async function tryInstallHookPackFromLocalPath(params: {
version: probe.version,
},
successMessage: `Linked hook pack path: ${shortenHomePath(params.resolvedPath)}`,
runtime: params.runtime,
});
return { ok: true };
}
@@ -201,7 +205,7 @@ async function tryInstallHookPackFromLocalPath(params: {
...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}),
path: params.resolvedPath,
mode: params.installMode,
logger: createHookPackInstallLogger(),
logger: createHookPackInstallLogger(params.runtime),
});
if (!result.ok) {
return result;
@@ -218,6 +222,7 @@ async function tryInstallHookPackFromLocalPath(params: {
installPath: result.targetDir,
version: result.version,
},
runtime: params.runtime,
});
return { ok: true };
}
@@ -227,11 +232,12 @@ async function tryInstallHookPackFromNpmSpec(params: {
installMode: "install" | "update";
spec: string;
pin?: boolean;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await installHooksFromNpmSpec({
spec: params.spec,
mode: params.installMode,
logger: createHookPackInstallLogger(),
logger: createHookPackInstallLogger(params.runtime),
});
if (!result.ok) {
return result;
@@ -243,7 +249,7 @@ async function tryInstallHookPackFromNpmSpec(params: {
result.targetDir,
result.version,
result.npmResolution,
defaultRuntime.log,
params.runtime?.log ?? defaultRuntime.log,
theme.warn,
);
await persistHookPackInstall({
@@ -251,6 +257,7 @@ async function tryInstallHookPackFromNpmSpec(params: {
hookPackId: result.hookPackId,
hooks: result.hooks,
install: installRecord,
runtime: params.runtime,
});
return { ok: true };
}
@@ -263,17 +270,18 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
safetyOverrides: InstallSafetyOverrides;
allowBundledFallback: boolean;
extensionsDir: string;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const result = await installPluginFromNpmSpec({
...params.safetyOverrides,
mode: params.installMode,
spec: params.spec,
extensionsDir: params.extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(params.runtime),
});
if (!result.ok) {
if (isTerminalPluginInstallSecurityFailure(result.code)) {
defaultRuntime.error(result.error);
(params.runtime ?? defaultRuntime).error(result.error);
return { ok: false };
}
if (params.allowBundledFallback) {
@@ -288,6 +296,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
rawSpec: params.spec,
bundledSource: bundledFallbackPlan.bundledSource,
warning: bundledFallbackPlan.warning,
runtime: params.runtime,
});
return { ok: true };
}
@@ -297,11 +306,12 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
installMode: params.installMode,
spec: params.spec,
pin: params.pin,
runtime: params.runtime,
});
if (hookFallback.ok) {
return { ok: true };
}
defaultRuntime.error(
(params.runtime ?? defaultRuntime).error(
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
);
return { ok: false };
@@ -313,13 +323,14 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
result.targetDir,
result.version,
result.npmResolution,
defaultRuntime.log,
params.runtime?.log ?? defaultRuntime.log,
theme.warn,
);
await persistPluginInstall({
snapshot: params.snapshot,
pluginId: result.pluginId,
install: installRecord,
runtime: params.runtime,
});
return { ok: true };
}
@@ -330,16 +341,17 @@ async function tryInstallPluginFromGitSpec(params: {
spec: string;
safetyOverrides: InstallSafetyOverrides;
extensionsDir: string;
runtime?: RuntimeEnv;
}): Promise<{ ok: true } | { ok: false }> {
const result = await installPluginFromGitSpec({
...params.safetyOverrides,
mode: params.installMode,
spec: params.spec,
extensionsDir: params.extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(params.runtime),
});
if (!result.ok) {
defaultRuntime.error(result.error);
(params.runtime ?? defaultRuntime).error(result.error);
return { ok: false };
}
@@ -356,6 +368,7 @@ async function tryInstallPluginFromGitSpec(params: {
gitRef: result.git.ref,
gitCommit: result.git.commit,
},
runtime: params.runtime,
});
return { ok: true };
}
@@ -452,7 +465,9 @@ export async function runPluginInstallCommand(params: {
pin?: boolean;
marketplace?: string;
};
runtime?: RuntimeEnv;
}) {
const runtime = params.runtime ?? defaultRuntime;
const shorthand = !params.opts.marketplace
? await tracePluginLifecyclePhaseAsync(
"marketplace shortcut resolution",
@@ -461,8 +476,8 @@ export async function runPluginInstallCommand(params: {
)
: null;
if (shorthand?.ok === false) {
defaultRuntime.error(shorthand.error);
return defaultRuntime.exit(1);
runtime.error(shorthand.error);
return runtime.exit(1);
}
const raw = shorthand?.ok ? shorthand.plugin : params.raw;
@@ -473,47 +488,47 @@ export async function runPluginInstallCommand(params: {
};
if (opts.marketplace) {
if (opts.link) {
defaultRuntime.error("`--link` is not supported with `--marketplace`.");
return defaultRuntime.exit(1);
runtime.error("`--link` is not supported with `--marketplace`.");
return runtime.exit(1);
}
if (opts.pin) {
defaultRuntime.error("`--pin` is not supported with `--marketplace`.");
return defaultRuntime.exit(1);
runtime.error("`--pin` is not supported with `--marketplace`.");
return runtime.exit(1);
}
}
const gitPrefix = raw.trim().toLowerCase().startsWith("git:");
const gitSpec = parseGitPluginSpec(raw);
if (gitPrefix && !gitSpec) {
defaultRuntime.error(`unsupported git: plugin spec: ${raw}`);
return defaultRuntime.exit(1);
runtime.error(`unsupported git: plugin spec: ${raw}`);
return runtime.exit(1);
}
if (gitSpec && opts.link) {
defaultRuntime.error("`--link` is not supported with `git:` installs.");
return defaultRuntime.exit(1);
runtime.error("`--link` is not supported with `git:` installs.");
return runtime.exit(1);
}
if (gitSpec && opts.pin) {
defaultRuntime.error("`--pin` is not supported with `git:` installs; use `git:<repo>@<ref>`.");
return defaultRuntime.exit(1);
runtime.error("`--pin` is not supported with `git:` installs; use `git:<repo>@<ref>`.");
return runtime.exit(1);
}
if (opts.link && opts.force) {
defaultRuntime.error("`--force` is not supported with `--link`.");
return defaultRuntime.exit(1);
runtime.error("`--force` is not supported with `--link`.");
return runtime.exit(1);
}
const requestResolution = resolvePluginInstallRequestContext({
rawSpec: raw,
marketplace: opts.marketplace,
});
if (!requestResolution.ok) {
defaultRuntime.error(requestResolution.error);
return defaultRuntime.exit(1);
runtime.error(requestResolution.error);
return runtime.exit(1);
}
const request = requestResolution.request;
const snapshot = await loadConfigForInstall(request).catch((error: unknown) => {
defaultRuntime.error(formatErrorMessage(error));
runtime.error(formatErrorMessage(error));
return null;
});
if (!snapshot) {
return defaultRuntime.exit(1);
return runtime.exit(1);
}
const cfg = snapshot.config;
const installMode = resolveInstallMode(opts.force);
@@ -527,11 +542,11 @@ export async function runPluginInstallCommand(params: {
mode: installMode,
plugin: raw,
extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(runtime),
});
if (!result.ok) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
runtime.error(result.error);
return runtime.exit(1);
}
await persistPluginInstall({
@@ -545,6 +560,7 @@ export async function runPluginInstallCommand(params: {
marketplaceSource: result.marketplaceSource,
marketplacePlugin: result.marketplacePlugin,
},
runtime,
});
return;
}
@@ -560,12 +576,12 @@ export async function runPluginInstallCommand(params: {
path: resolved,
dryRun: true,
extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(runtime),
});
if (!probe.ok) {
if (isTerminalPluginInstallSecurityFailure(probe.code)) {
defaultRuntime.error(probe.error);
return defaultRuntime.exit(1);
runtime.error(probe.error);
return runtime.exit(1);
}
const hookFallback = await tryInstallHookPackFromLocalPath({
snapshot,
@@ -573,14 +589,13 @@ export async function runPluginInstallCommand(params: {
resolvedPath: resolved,
safetyOverrides,
link: true,
runtime,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(probe.error, hookFallback.error),
);
return defaultRuntime.exit(1);
runtime.error(formatPluginInstallWithHookFallbackError(probe.error, hookFallback.error));
return runtime.exit(1);
}
await persistPluginInstall({
@@ -605,6 +620,7 @@ export async function runPluginInstallCommand(params: {
version: probe.version,
},
successMessage: `Linked plugin path: ${shortenHomePath(resolved)}`,
runtime,
});
return;
}
@@ -614,26 +630,25 @@ export async function runPluginInstallCommand(params: {
mode: installMode,
path: resolved,
extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(runtime),
});
if (!result.ok) {
if (isTerminalPluginInstallSecurityFailure(result.code)) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
runtime.error(result.error);
return runtime.exit(1);
}
const hookFallback = await tryInstallHookPackFromLocalPath({
snapshot,
installMode,
resolvedPath: resolved,
safetyOverrides,
runtime,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
);
return defaultRuntime.exit(1);
runtime.error(formatPluginInstallWithHookFallbackError(result.error, hookFallback.error));
return runtime.exit(1);
}
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
@@ -646,20 +661,21 @@ export async function runPluginInstallCommand(params: {
installPath: result.targetDir,
version: result.version,
},
runtime,
});
return;
}
if (opts.link) {
defaultRuntime.error("`--link` requires a local path.");
return defaultRuntime.exit(1);
runtime.error("`--link` requires a local path.");
return runtime.exit(1);
}
const npmPrefixSpec = parseNpmPrefixSpec(raw);
if (npmPrefixSpec !== null) {
if (!npmPrefixSpec) {
defaultRuntime.error("unsupported npm: spec: missing package");
return defaultRuntime.exit(1);
runtime.error("unsupported npm: spec: missing package");
return runtime.exit(1);
}
const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({
snapshot,
@@ -669,9 +685,10 @@ export async function runPluginInstallCommand(params: {
safetyOverrides,
allowBundledFallback: false,
extensionsDir,
runtime,
});
if (!npmPrefixResult.ok) {
return defaultRuntime.exit(1);
return runtime.exit(1);
}
return;
}
@@ -683,9 +700,10 @@ export async function runPluginInstallCommand(params: {
spec: raw,
safetyOverrides,
extensionsDir,
runtime,
});
if (!gitResult.ok) {
return defaultRuntime.exit(1);
return runtime.exit(1);
}
return;
}
@@ -702,8 +720,8 @@ export async function runPluginInstallCommand(params: {
".zip",
])
) {
defaultRuntime.error(`Path not found: ${resolved}`);
return defaultRuntime.exit(1);
runtime.error(`Path not found: ${resolved}`);
return runtime.exit(1);
}
const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({
@@ -719,6 +737,7 @@ export async function runPluginInstallCommand(params: {
rawSpec: raw,
bundledSource: bundledPreNpmPlan.bundledSource,
warning: bundledPreNpmPlan.warning,
runtime,
}),
{
command: "install",
@@ -736,11 +755,11 @@ export async function runPluginInstallCommand(params: {
mode: installMode,
spec: raw,
extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(runtime),
});
if (!result.ok) {
defaultRuntime.error(result.error);
return defaultRuntime.exit(1);
runtime.error(result.error);
return runtime.exit(1);
}
await persistPluginInstall({
@@ -762,6 +781,7 @@ export async function runPluginInstallCommand(params: {
clawpackManifestSha256: result.clawhub.clawpackManifestSha256,
clawpackSize: result.clawhub.clawpackSize,
},
runtime,
});
return;
}
@@ -773,7 +793,7 @@ export async function runPluginInstallCommand(params: {
mode: installMode,
spec: preferredClawHubSpec,
extensionsDir,
logger: createPluginInstallLogger(),
logger: createPluginInstallLogger(runtime),
});
if (clawhubResult.ok) {
await persistPluginInstall({
@@ -795,12 +815,13 @@ export async function runPluginInstallCommand(params: {
clawpackManifestSha256: clawhubResult.clawhub.clawpackManifestSha256,
clawpackSize: clawhubResult.clawhub.clawpackSize,
},
runtime,
});
return;
}
if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") {
defaultRuntime.error(clawhubResult.error);
return defaultRuntime.exit(1);
runtime.error(clawhubResult.error);
return runtime.exit(1);
}
}
@@ -812,8 +833,9 @@ export async function runPluginInstallCommand(params: {
safetyOverrides,
allowBundledFallback: true,
extensionsDir,
runtime,
});
if (!npmResult.ok) {
return defaultRuntime.exit(1);
return runtime.exit(1);
}
}

View File

@@ -9,7 +9,7 @@ import {
} from "../plugins/installed-plugin-index-records.js";
import type { PluginInstallUpdate } from "../plugins/installs.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime } from "../runtime.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import {
applySlotSelectionForPlugin,
@@ -65,7 +65,9 @@ export async function persistPluginInstall(params: {
enable?: boolean;
successMessage?: string;
warningMessage?: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const runtime = params.runtime ?? defaultRuntime;
const installConfig =
params.enable === false
? params.snapshot.config
@@ -114,15 +116,15 @@ export async function persistPluginInstall(params: {
installRecords: nextInstallRecords,
traceCommand: "install",
logger: {
warn: (message) => defaultRuntime.log(theme.warn(message)),
warn: (message) => runtime.log(theme.warn(message)),
},
});
logSlotWarnings(slotResult.warnings);
logSlotWarnings(slotResult.warnings, runtime);
if (params.warningMessage) {
defaultRuntime.log(theme.warn(params.warningMessage));
runtime.log(theme.warn(params.warningMessage));
}
defaultRuntime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`);
defaultRuntime.log("Restart the gateway to load plugins.");
runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`);
runtime.log("Restart the gateway to load plugins.");
return next;
}
@@ -132,7 +134,9 @@ export async function persistHookPackInstall(params: {
hooks: string[];
install: Omit<HookInstallUpdate, "hookId" | "hooks">;
successMessage?: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
const runtime = params.runtime ?? defaultRuntime;
let next = enableInternalHookEntries(params.snapshot.config, params.hooks);
next = recordHookInstall(next, {
hookId: params.hookPackId,
@@ -143,7 +147,7 @@ export async function persistHookPackInstall(params: {
nextConfig: next,
baseHash: params.snapshot.baseHash,
});
defaultRuntime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`);
logHookPackRestartHint();
runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`);
logHookPackRestartHint(runtime);
return next;
}

View File

@@ -1,6 +1,6 @@
import { getRuntimeConfig } from "../config/config.js";
import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js";
import { defaultRuntime } from "../runtime.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { quietPluginJsonLogger } from "./plugins-command-helpers.js";
@@ -12,7 +12,10 @@ export type PluginsListOptions = {
verbose?: boolean;
};
export async function runPluginsListCommand(opts: PluginsListOptions): Promise<void> {
export async function runPluginsListCommand(
opts: PluginsListOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const cfg = getRuntimeConfig();
const report = buildPluginRegistrySnapshotReport({
@@ -31,19 +34,17 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise<v
plugins: list,
diagnostics: report.diagnostics,
};
defaultRuntime.writeJson(payload);
writeRuntimeJson(runtime, payload);
return;
}
if (list.length === 0) {
defaultRuntime.log(theme.muted("No plugins found."));
runtime.log(theme.muted("No plugins found."));
return;
}
const enabled = list.filter((p) => p.enabled).length;
defaultRuntime.log(
`${theme.heading("Plugins")} ${theme.muted(`(${enabled}/${list.length} enabled)`)}`,
);
runtime.log(`${theme.heading("Plugins")} ${theme.muted(`(${enabled}/${list.length} enabled)`)}`);
if (!opts.verbose) {
const tableWidth = getTerminalTableWidth();
@@ -74,7 +75,7 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise<v
});
if (usedRoots.size > 0) {
defaultRuntime.log(theme.muted("Source roots:"));
runtime.log(theme.muted("Source roots:"));
for (const key of ["stock", "workspace", "global"] as const) {
if (!usedRoots.has(key)) {
continue;
@@ -83,12 +84,12 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise<v
if (!dir) {
continue;
}
defaultRuntime.log(` ${theme.command(`${key}:`)} ${theme.muted(dir)}`);
runtime.log(` ${theme.command(`${key}:`)} ${theme.muted(dir)}`);
}
defaultRuntime.log("");
runtime.log("");
}
defaultRuntime.log(
runtime.log(
renderTable({
width: tableWidth,
columns: [
@@ -110,5 +111,5 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise<v
lines.push(formatPluginLine(plugin, true));
lines.push("");
}
defaultRuntime.log(lines.join("\n").trim());
runtime.log(lines.join("\n").trim());
}

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const logs: string[] = [];
const errors: string[] = [];
const runtime = {
log: vi.fn((value: unknown) => logs.push(String(value))),
error: vi.fn((value: unknown) => errors.push(String(value))),
writeJson: vi.fn((value: unknown, space = 2) =>
logs.push(JSON.stringify(value, null, space > 0 ? space : undefined)),
),
writeStdout: vi.fn((value: string) =>
logs.push(value.endsWith("\n") ? value.slice(0, -1) : value),
),
exit: vi.fn((code: number) => {
throw new Error(`__exit__:${code}`);
}),
};
return {
logs,
errors,
runtime,
searchClawHubPackages: vi.fn(),
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.runtime,
writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown, space = 2) =>
runtime.writeJson(value, space),
}));
vi.mock("../infra/clawhub.js", () => ({
searchClawHubPackages: mocks.searchClawHubPackages,
}));
const { runPluginsSearchCommand } = await import("./plugins-search-command.js");
describe("plugins search command", () => {
beforeEach(() => {
mocks.logs.length = 0;
mocks.errors.length = 0;
mocks.runtime.log.mockClear();
mocks.runtime.error.mockClear();
mocks.runtime.writeJson.mockClear();
mocks.runtime.exit.mockClear();
mocks.searchClawHubPackages.mockReset();
});
it("searches ClawHub code and bundle plugin families", async () => {
mocks.searchClawHubPackages
.mockResolvedValueOnce([
{
score: 12,
package: {
name: "openclaw-calendar",
displayName: "Calendar",
family: "code-plugin",
channel: "community",
isOfficial: false,
summary: "Calendar sync",
createdAt: 1,
updatedAt: 1,
latestVersion: "1.2.3",
},
},
])
.mockResolvedValueOnce([
{
score: 10,
package: {
name: "openclaw-calendar-bundle",
displayName: "Calendar Bundle",
family: "bundle-plugin",
channel: "official",
isOfficial: true,
summary: "Calendar bundle",
createdAt: 1,
updatedAt: 1,
latestVersion: "2.0.0",
},
},
]);
await runPluginsSearchCommand(["calendar"], { limit: 5 }, mocks.runtime);
expect(mocks.searchClawHubPackages).toHaveBeenCalledWith({
query: "calendar",
family: "code-plugin",
limit: 5,
});
expect(mocks.searchClawHubPackages).toHaveBeenCalledWith({
query: "calendar",
family: "bundle-plugin",
limit: 5,
});
expect(mocks.logs.join("\n")).toContain("openclaw-calendar");
expect(mocks.logs.join("\n")).toContain(
"Install: openclaw plugins install clawhub:openclaw-calendar",
);
});
it("writes JSON results when requested", async () => {
mocks.searchClawHubPackages.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await runPluginsSearchCommand("calendar", { json: true }, mocks.runtime);
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ results: [] }, 2);
});
});

View File

@@ -0,0 +1,91 @@
import {
searchClawHubPackages,
type ClawHubPackageFamily,
type ClawHubPackageSearchResult,
} from "../infra/clawhub.js";
import { formatErrorMessage } from "../infra/errors.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { theme } from "../terminal/theme.js";
export type PluginsSearchOptions = {
json?: boolean;
limit?: number;
};
const INSTALLABLE_PLUGIN_FAMILIES: ClawHubPackageFamily[] = ["code-plugin", "bundle-plugin"];
function clampSearchLimit(limit: number | undefined): number {
if (!Number.isFinite(limit) || !limit || limit <= 0) {
return 20;
}
return Math.min(Math.max(Math.trunc(limit), 1), 100);
}
function mergePackageSearchResults(
groups: readonly ClawHubPackageSearchResult[][],
limit: number,
): ClawHubPackageSearchResult[] {
const byName = new Map<string, ClawHubPackageSearchResult>();
for (const entry of groups.flat()) {
const existing = byName.get(entry.package.name);
if (!existing || entry.score > existing.score) {
byName.set(entry.package.name, entry);
}
}
return [...byName.values()].toSorted((a, b) => b.score - a.score).slice(0, limit);
}
function formatPackageSearchLine(entry: ClawHubPackageSearchResult): string {
const pkg = entry.package;
const flags = [
pkg.family,
pkg.channel,
pkg.isOfficial ? "official" : undefined,
pkg.latestVersion ? `v${pkg.latestVersion}` : undefined,
].filter(Boolean);
const summary = pkg.summary ? ` ${theme.muted(pkg.summary)}` : "";
return `${pkg.name} ${theme.muted(flags.join(" | "))}${summary}\n ${theme.muted(`Install: openclaw plugins install clawhub:${pkg.name}`)}`;
}
export async function runPluginsSearchCommand(
queryParts: string[] | string,
opts: PluginsSearchOptions = {},
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const query = normalizeOptionalString(
Array.isArray(queryParts) ? queryParts.join(" ") : queryParts,
);
if (!query) {
runtime.error("Usage: openclaw plugins search <query>");
return runtime.exit(1);
}
const limit = clampSearchLimit(opts.limit);
try {
const groups = await Promise.all(
INSTALLABLE_PLUGIN_FAMILIES.map((family) =>
searchClawHubPackages({
query,
family,
limit,
}),
),
);
const results = mergePackageSearchResults(groups, limit);
if (opts.json) {
writeRuntimeJson(runtime, { results });
return;
}
if (results.length === 0) {
runtime.log("No ClawHub plugins found.");
return;
}
runtime.log(`${theme.heading("ClawHub plugins")} ${theme.muted(`(${results.length})`)}`);
runtime.log(results.map(formatPackageSearchLine).join("\n"));
} catch (error) {
runtime.error(formatErrorMessage(error));
runtime.exit(1);
}
}

View File

@@ -0,0 +1,196 @@
import os from "node:os";
import path from "node:path";
import { readConfigFileSnapshot } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
tracePluginLifecyclePhase,
tracePluginLifecyclePhaseAsync,
} from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
export type PluginUninstallOptions = {
keepFiles?: boolean;
/** @deprecated Use keepFiles. */
keepConfig?: boolean;
force?: boolean;
dryRun?: boolean;
};
export async function runPluginUninstallCommand(
id: string,
opts: PluginUninstallOptions = {},
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} = await import("../plugins/installed-plugin-index-records.js");
const { buildPluginSnapshotReport } = await import("../plugins/status.js");
const {
applyPluginUninstallDirectoryRemoval,
formatUninstallActionLabels,
formatUninstallSlotResetPreview,
planPluginUninstall,
resolveUninstallChannelConfigKeys,
UNINSTALL_ACTION_LABELS,
} = await import("../plugins/uninstall.js");
const { commitPluginInstallRecordsWithConfig } =
await import("./plugins-install-record-commit.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js");
const { promptYesNo } = await import("./prompt.js");
const snapshot = await tracePluginLifecyclePhaseAsync(
"config read",
() => readConfigFileSnapshot(),
{ command: "uninstall" },
);
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const installRecords = await tracePluginLifecyclePhaseAsync(
"install records load",
() => loadInstalledPluginIndexInstallRecords(),
{ command: "uninstall" },
);
const cfg = withPluginInstallRecords(sourceConfig, installRecords);
const report = tracePluginLifecyclePhase(
"plugin registry snapshot",
() => buildPluginSnapshotReport({ config: cfg }),
{ command: "uninstall" },
);
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
if (opts.keepConfig) {
runtime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`."));
}
const { plugin, pluginId } = resolvePluginUninstallId({
rawId: id,
config: cfg,
plugins: report.plugins,
});
const hasEntry = pluginId in (cfg.plugins?.entries ?? {});
const hasInstall = pluginId in (cfg.plugins?.installs ?? {});
if (!hasEntry && !hasInstall) {
if (plugin) {
runtime.error(
`Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`,
);
} else {
runtime.error(`Plugin not found: ${id}`);
}
runtime.exit(1);
return;
}
const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined;
const plan = planPluginUninstall({
config: cfg,
pluginId,
channelIds,
deleteFiles: !keepFiles,
extensionsDir,
});
if (!plan.ok) {
runtime.error(plan.error);
runtime.exit(1);
return;
}
const preview: string[] = [];
if (plan.actions.entry) {
preview.push(UNINSTALL_ACTION_LABELS.entry);
}
if (plan.actions.install) {
preview.push(UNINSTALL_ACTION_LABELS.install);
}
if (plan.actions.allowlist) {
preview.push(UNINSTALL_ACTION_LABELS.allowlist);
}
if (plan.actions.denylist) {
preview.push(UNINSTALL_ACTION_LABELS.denylist);
}
if (plan.actions.loadPath) {
preview.push(UNINSTALL_ACTION_LABELS.loadPath);
}
if (plan.actions.memorySlot) {
preview.push(formatUninstallSlotResetPreview("memory"));
}
if (plan.actions.contextEngineSlot) {
preview.push(formatUninstallSlotResetPreview("contextEngine"));
}
const channels = cfg.channels as Record<string, unknown> | undefined;
if (plan.actions.channelConfig && hasInstall && channels) {
for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) {
if (Object.hasOwn(channels, key)) {
preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`);
}
}
}
if (plan.directoryRemoval) {
preview.push(`directory: ${shortenHomePath(plan.directoryRemoval.target)}`);
}
const pluginName = plugin?.name || pluginId;
runtime.log(
`Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`,
);
runtime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`);
const nextConfig = withoutPluginInstallRecords(plan.config);
if (opts.dryRun) {
runtime.log(theme.muted("Dry run, no changes made."));
return;
}
if (!opts.force) {
const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`);
if (!confirmed) {
runtime.log("Cancelled.");
return;
}
}
const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId);
await tracePluginLifecyclePhaseAsync(
"config mutation",
() =>
commitPluginInstallRecordsWithConfig({
previousInstallRecords: installRecords,
nextInstallRecords,
nextConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
}),
{ command: "uninstall" },
);
const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
for (const warning of directoryResult.warnings) {
runtime.log(theme.warn(warning));
}
await refreshPluginRegistryAfterConfigMutation({
config: nextConfig,
reason: "source-changed",
installRecords: nextInstallRecords,
traceCommand: "uninstall",
logger: {
warn: (message) => runtime.log(theme.warn(message)),
},
});
const removed = formatUninstallActionLabels({
...plan.actions,
directory: directoryResult.directoryRemoved,
});
runtime.log(
`Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`,
);
runtime.log("Restart the gateway to apply changes.");
}

View File

@@ -23,6 +23,10 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [
"- stop gateway",
"- agents",
"- models",
"- plugins list",
"- plugins search <query>",
"- plugin install <npm-or-clawhub-spec>",
"- plugin uninstall <id>",
"- audit",
"- validate config",
"- set default model <provider/model>",

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js";
import {
executeCrestodianOperation,
@@ -205,6 +206,27 @@ describe("parseCrestodianOperation", () => {
expect(parseCrestodianOperation("doctor fix")).toEqual({ kind: "doctor-fix" });
});
it("parses plugin management operations", () => {
expect(parseCrestodianOperation("plugins list")).toEqual({ kind: "plugin-list" });
expect(parseCrestodianOperation("list plugin")).toEqual({ kind: "plugin-list" });
expect(parseCrestodianOperation("plugins search calendar sync")).toEqual({
kind: "plugin-search",
query: "calendar sync",
});
expect(parseCrestodianOperation("install npm plugin @openclaw/demo")).toEqual({
kind: "plugin-install",
spec: "npm:@openclaw/demo",
});
expect(parseCrestodianOperation("plugin install clawhub:openclaw-demo")).toEqual({
kind: "plugin-install",
spec: "clawhub:openclaw-demo",
});
expect(parseCrestodianOperation("plugin uninstall openclaw-demo")).toEqual({
kind: "plugin-uninstall",
pluginId: "openclaw-demo",
});
});
it("parses agent creation requests", () => {
expect(
parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"),
@@ -340,6 +362,119 @@ describe("parseCrestodianOperation", () => {
});
});
it("runs plugin list and search as read-only operations", async () => {
const { runtime, lines } = createCrestodianTestRuntime();
const runPluginsList = vi.fn(async (pluginRuntime: RuntimeEnv) => {
pluginRuntime.log("plugin rows");
});
const runPluginsSearch = vi.fn(async (query: string, pluginRuntime: RuntimeEnv) => {
pluginRuntime.log(`search rows: ${query}`);
});
await expect(
executeCrestodianOperation({ kind: "plugin-list" }, runtime, {
deps: { runPluginsList, runPluginsSearch },
}),
).resolves.toMatchObject({ applied: false });
await expect(
executeCrestodianOperation({ kind: "plugin-search", query: "calendar" }, runtime, {
deps: { runPluginsList, runPluginsSearch },
}),
).resolves.toMatchObject({ applied: false });
expect(runPluginsList).toHaveBeenCalledWith(runtime);
expect(runPluginsSearch).toHaveBeenCalledWith("calendar", runtime);
expect(lines.join("\n")).toContain("plugin rows");
expect(lines.join("\n")).toContain("search rows: calendar");
expect(lines.join("\n")).toContain("[crestodian] done: plugins.search");
});
it("installs plugins only after approval and audits the write", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-plugin-install-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
const { runtime, lines } = createCrestodianTestRuntime();
const runPluginInstall = vi.fn(async (spec: string, pluginRuntime: RuntimeEnv) => {
pluginRuntime.log(`installed ${spec}`);
});
const plan = await executeCrestodianOperation(
{ kind: "plugin-install", spec: "clawhub:openclaw-demo" },
runtime,
{ deps: { runPluginInstall } },
);
expect(plan).toMatchObject({
applied: false,
message: "Plan: install plugin clawhub:openclaw-demo. Say yes to apply.",
});
expect(runPluginInstall).not.toHaveBeenCalled();
await expect(
executeCrestodianOperation(
{ kind: "plugin-install", spec: "clawhub:openclaw-demo" },
runtime,
{
approved: true,
deps: { runPluginInstall },
auditDetails: { rescue: true },
},
),
).resolves.toMatchObject({ applied: true });
expect(runPluginInstall).toHaveBeenCalledWith("clawhub:openclaw-demo", expect.any(Object));
expect(lines.join("\n")).toContain("[crestodian] done: plugin.install");
const auditPath = path.join(tempDir, "audit", "crestodian.jsonl");
const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim());
expect(audit).toMatchObject({
operation: "plugin.install",
summary: "Installed plugin clawhub:openclaw-demo",
details: {
rescue: true,
spec: "clawhub:openclaw-demo",
},
});
});
it("uninstalls plugins only after approval and audits the write", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-plugin-uninstall-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
const { runtime, lines } = createCrestodianTestRuntime();
const runPluginUninstall = vi.fn(async (pluginId: string, pluginRuntime: RuntimeEnv) => {
pluginRuntime.log(`uninstalled ${pluginId}`);
});
const plan = await executeCrestodianOperation(
{ kind: "plugin-uninstall", pluginId: "openclaw-demo" },
runtime,
{ deps: { runPluginUninstall } },
);
expect(plan).toMatchObject({
applied: false,
message: "Plan: uninstall plugin openclaw-demo. Say yes to apply.",
});
expect(runPluginUninstall).not.toHaveBeenCalled();
await expect(
executeCrestodianOperation({ kind: "plugin-uninstall", pluginId: "openclaw-demo" }, runtime, {
approved: true,
deps: { runPluginUninstall },
auditDetails: { rescue: true },
}),
).resolves.toMatchObject({ applied: true });
expect(runPluginUninstall).toHaveBeenCalledWith("openclaw-demo", expect.any(Object));
expect(lines.join("\n")).toContain("[crestodian] done: plugin.uninstall");
const auditPath = path.join(tempDir, "audit", "crestodian.jsonl");
const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim());
expect(audit).toMatchObject({
operation: "plugin.uninstall",
summary: "Uninstalled plugin openclaw-demo",
details: {
rescue: true,
pluginId: "openclaw-demo",
},
});
});
it("runs setup bootstrap only after approval and audits it", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-setup-"));
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);

View File

@@ -36,6 +36,10 @@ export type CrestodianOperation =
| { kind: "gateway-restart" }
| { kind: "agents" }
| { kind: "models" }
| { kind: "plugin-list" }
| { kind: "plugin-search"; query: string }
| { kind: "plugin-install"; spec: string }
| { kind: "plugin-uninstall"; pluginId: string }
| { kind: "audit" }
| { kind: "create-agent"; agentId: string; workspace?: string; model?: string }
| { kind: "open-tui"; agentId?: string; workspace?: string }
@@ -71,6 +75,10 @@ export type CrestodianCommandDeps = {
runGatewayRestart?: () => Promise<void>;
runGatewayStart?: () => Promise<void>;
runGatewayStop?: () => Promise<void>;
runPluginInstall?: (spec: string, runtime: RuntimeEnv) => Promise<void>;
runPluginUninstall?: (pluginId: string, runtime: RuntimeEnv) => Promise<void>;
runPluginsList?: (runtime: RuntimeEnv) => Promise<void>;
runPluginsSearch?: (query: string, runtime: RuntimeEnv) => Promise<void>;
runTui?: (opts: {
local: boolean;
session?: string;
@@ -93,6 +101,13 @@ const CONFIG_SET_REF_RE =
/^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?<path>[A-Za-z0-9_.[\]-]+)\s+(?:(?<source>env|file|exec)\s+)?(?<id>\S+)(?:\s+provider\s+(?<provider>[A-Za-z0-9_-]+))?$/i;
const SETUP_RE =
/^(?:setup(?!\s+agent\b)|set\s+me\s+up|set\s+up\s+openclaw|onboard|onboard\s+me|bootstrap|first\s+run)(?:\b|$)/i;
const PLUGIN_LIST_RE = /^(?:plugins?|clawhub)\s+list$|^list\s+plugins?$/i;
const PLUGIN_SEARCH_RE =
/^(?:(?:plugins?|clawhub)\s+search|search\s+plugins?(?:\s+for)?)\s+(?<query>.+)$/i;
const PLUGIN_INSTALL_RE =
/^(?:(?:plugins?)\s+install|install\s+(?:(?<source>npm|clawhub)\s+)?plugins?)\s+(?<spec>\S+)$/i;
const PLUGIN_UNINSTALL_RE =
/^(?:(?:plugins?)\s+(?:uninstall|remove)|(?:uninstall|remove)\s+plugins?)\s+(?<pluginId>[A-Za-z0-9_.@/-]+)$/i;
const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`;
const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-7";
@@ -140,6 +155,27 @@ export function parseCrestodianOperation(input: string): CrestodianOperation {
) {
return { kind: "config-validate" };
}
if (PLUGIN_LIST_RE.test(trimmed)) {
return { kind: "plugin-list" };
}
const pluginSearchMatch = trimmed.match(PLUGIN_SEARCH_RE);
if (pluginSearchMatch?.groups?.query?.trim()) {
return { kind: "plugin-search", query: pluginSearchMatch.groups.query.trim() };
}
const pluginInstallMatch = trimmed.match(PLUGIN_INSTALL_RE);
if (pluginInstallMatch?.groups?.spec?.trim()) {
return {
kind: "plugin-install",
spec: normalizePluginInstallSpec(
pluginInstallMatch.groups.spec.trim(),
pluginInstallMatch.groups.source,
),
};
}
const pluginUninstallMatch = trimmed.match(PLUGIN_UNINSTALL_RE);
if (pluginUninstallMatch?.groups?.pluginId?.trim()) {
return { kind: "plugin-uninstall", pluginId: pluginUninstallMatch.groups.pluginId.trim() };
}
if (SETUP_RE.test(lower)) {
const workspace = trimShellishToken(trimmed.match(WORKSPACE_RE)?.groups?.workspace);
const model = trimmed.match(MODEL_RE)?.groups?.model;
@@ -232,6 +268,32 @@ function trimShellishToken(value: string | undefined): string | undefined {
return trimmed;
}
function normalizePluginInstallSpec(spec: string, source: string | undefined): string {
const trimmed = spec.trim();
const normalizedSource = source?.toLowerCase();
if (normalizedSource === "npm" && !trimmed.toLowerCase().startsWith("npm:")) {
return `npm:${trimmed}`;
}
if (normalizedSource === "clawhub" && !trimmed.toLowerCase().startsWith("clawhub:")) {
return `clawhub:${trimmed}`;
}
return trimmed;
}
function validateCrestodianPluginInstallSpec(spec: string): string | null {
const trimmed = spec.trim();
if (!trimmed) {
return "Plugin install spec is required.";
}
if (/\s/.test(trimmed)) {
return "Crestodian plugin install accepts one npm or ClawHub package spec.";
}
if (/^(?:\.{1,2}\/|\/|~\/|file:|git(?:\+ssh|\+https)?:|https?:)/i.test(trimmed)) {
return "Crestodian plugin install accepts npm or ClawHub package specs only.";
}
return null;
}
export function isPersistentCrestodianOperation(operation: CrestodianOperation): boolean {
return (
operation.kind === "set-default-model" ||
@@ -239,6 +301,8 @@ export function isPersistentCrestodianOperation(operation: CrestodianOperation):
operation.kind === "config-set-ref" ||
operation.kind === "setup" ||
operation.kind === "doctor-fix" ||
operation.kind === "plugin-install" ||
operation.kind === "plugin-uninstall" ||
operation.kind === "create-agent" ||
operation.kind === "gateway-start" ||
operation.kind === "gateway-stop" ||
@@ -258,6 +322,10 @@ export function describeCrestodianPersistentOperation(operation: CrestodianOpera
return formatSetupPlanDescription(operation);
case "doctor-fix":
return "run doctor repairs";
case "plugin-install":
return `install plugin ${operation.spec}`;
case "plugin-uninstall":
return `uninstall plugin ${operation.pluginId}`;
case "create-agent":
return `create agent ${operation.agentId} with workspace ${formatCreateAgentWorkspace(operation.workspace)}`;
case "gateway-start":
@@ -491,6 +559,30 @@ export async function executeCrestodianOperation(
);
return { applied: false };
}
if (operation.kind === "plugin-list") {
logQueued(runtime, "plugins.list");
const runPluginsList =
opts.deps?.runPluginsList ??
(async (pluginRuntime: RuntimeEnv) => {
const { runPluginsListCommand } = await import("../cli/plugins-list-command.js");
await runPluginsListCommand({}, pluginRuntime);
});
await runPluginsList(runtime);
runtime.log("[crestodian] done: plugins.list");
return { applied: false };
}
if (operation.kind === "plugin-search") {
logQueued(runtime, "plugins.search");
const runPluginsSearch =
opts.deps?.runPluginsSearch ??
(async (query: string, pluginRuntime: RuntimeEnv) => {
const { runPluginsSearchCommand } = await import("../cli/plugins-search-command.js");
await runPluginsSearchCommand(query, {}, pluginRuntime);
});
await runPluginsSearch(operation.query, runtime);
runtime.log("[crestodian] done: plugins.search");
return { applied: false };
}
if (operation.kind === "audit") {
runtime.log(`Audit log: ${resolveCrestodianAuditPath()}`);
runtime.log("Only applied writes/actions are recorded; discovery stays quiet.");
@@ -656,6 +748,74 @@ export async function executeCrestodianOperation(
runtime.log("[crestodian] done: config.setRef");
return { applied: true };
}
if (operation.kind === "plugin-install") {
if (!opts.approved) {
const message = formatCrestodianPersistentPlan(operation);
runtime.log(message);
return { applied: false, message };
}
const validationError = validateCrestodianPluginInstallSpec(operation.spec);
if (validationError) {
runtime.error(validationError);
runtime.exit(1);
return { applied: false };
}
logQueued(runtime, "plugin.install");
const before = await readConfigFileSnapshotLazy();
const runPluginInstall =
opts.deps?.runPluginInstall ??
(async (spec: string, pluginRuntime: RuntimeEnv) => {
const { runPluginInstallCommand } = await import("../cli/plugins-install-command.js");
await runPluginInstallCommand({ raw: spec, opts: {}, runtime: pluginRuntime });
});
await runPluginInstall(operation.spec, createNoExitRuntime(runtime));
const after = await readConfigFileSnapshotLazy();
await appendCrestodianAuditEntry({
operation: "plugin.install",
summary: `Installed plugin ${operation.spec}`,
configPath: after.path || before.path || undefined,
configHashBefore: before.hash ?? null,
configHashAfter: after.hash ?? null,
details: {
...opts.auditDetails,
spec: operation.spec,
},
});
runtime.log("[crestodian] done: plugin.install");
runtime.log("Restart the Gateway to apply installed plugin changes.");
return { applied: true };
}
if (operation.kind === "plugin-uninstall") {
if (!opts.approved) {
const message = formatCrestodianPersistentPlan(operation);
runtime.log(message);
return { applied: false, message };
}
logQueued(runtime, "plugin.uninstall");
const before = await readConfigFileSnapshotLazy();
const runPluginUninstall =
opts.deps?.runPluginUninstall ??
(async (pluginId: string, pluginRuntime: RuntimeEnv) => {
const { runPluginUninstallCommand } = await import("../cli/plugins-uninstall-command.js");
await runPluginUninstallCommand(pluginId, { force: true }, pluginRuntime);
});
await runPluginUninstall(operation.pluginId, createNoExitRuntime(runtime));
const after = await readConfigFileSnapshotLazy();
await appendCrestodianAuditEntry({
operation: "plugin.uninstall",
summary: `Uninstalled plugin ${operation.pluginId}`,
configPath: after.path || before.path || undefined,
configHashBefore: before.hash ?? null,
configHashAfter: after.hash ?? null,
details: {
...opts.auditDetails,
pluginId: operation.pluginId,
},
});
runtime.log("[crestodian] done: plugin.uninstall");
runtime.log("Restart the Gateway to apply plugin changes.");
return { applied: true };
}
if (operation.kind === "create-agent") {
if (!opts.approved) {
const message = formatCrestodianPersistentPlan(operation);

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommandContext } from "../auto-reply/reply/commands-types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { extractCrestodianRescueMessage, runCrestodianRescueMessage } from "./rescue-message.js";
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
@@ -196,6 +197,41 @@ describe("Crestodian rescue message", () => {
expect(deps.runTui).not.toHaveBeenCalled();
});
it("refuses plugin install from remote rescue", async () => {
const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } };
const deps = {
runPluginInstall: vi.fn(async () => {
throw new Error("remote rescue must not install plugins");
}),
};
await expect(
runRescue("/crestodian plugin install clawhub:openclaw-demo", cfg, commandContext(), deps),
).resolves.toContain("cannot install plugins from a message channel");
expect(deps.runPluginInstall).not.toHaveBeenCalled();
});
it("allows plugin list and search from remote rescue", async () => {
const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } };
const deps = {
runPluginsList: vi.fn(async (runtime: RuntimeEnv) => {
runtime.log("plugin rows");
}),
runPluginsSearch: vi.fn(async (query: string, runtime: RuntimeEnv) => {
runtime.log(`search rows: ${query}`);
}),
};
await expect(
runRescue("/crestodian plugins list", cfg, commandContext(), deps),
).resolves.toContain("plugin rows");
await expect(
runRescue("/crestodian plugins search calendar", cfg, commandContext(), deps),
).resolves.toContain("search rows: calendar");
expect(deps.runPluginsList).toHaveBeenCalledTimes(1);
expect(deps.runPluginsSearch).toHaveBeenCalledWith("calendar", expect.any(Object));
});
it("queues and applies persistent writes through conversational approval", async () => {
const tempDir = await makeStateDir("models-");
vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);

View File

@@ -127,6 +127,12 @@ function formatUnsupportedRemoteOperation(operation: CrestodianOperation): strin
"Use local `openclaw` for agent handoff, or ask for status, doctor, config, gateway, agents, or models.",
].join(" ");
}
if (operation.kind === "plugin-install") {
return [
"Crestodian rescue cannot install plugins from a message channel by default because plugin install downloads executable code.",
"Use local `openclaw crestodian` or `openclaw plugins install` instead.",
].join(" ");
}
return null;
}