mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
[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 headc29cda6005. - Required merge gates passed before the squash merge. Prepared head SHA:c29cda6005Review: 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:
committed by
GitHub
parent
47f76c563f
commit
eee3aeae00
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
110
src/cli/plugins-search-command.test.ts
Normal file
110
src/cli/plugins-search-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
src/cli/plugins-search-command.ts
Normal file
91
src/cli/plugins-search-command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
196
src/cli/plugins-uninstall-command.ts
Normal file
196
src/cli/plugins-uninstall-command.ts
Normal 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.");
|
||||
}
|
||||
@@ -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>",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user