fix(onboarding): surface official plugin installs

This commit is contained in:
Vincent Koc
2026-05-02 15:11:18 -07:00
parent 7a54076770
commit c8fa0fd1c9
8 changed files with 397 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
- Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try `@beta` first and fall back to default/latest when no plugin beta release exists.
- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.

View File

@@ -300,6 +300,24 @@ describe("codex provider", () => {
});
});
it("exposes a setup auth choice for installing Codex as an external provider", async () => {
const provider = buildCodexProvider();
expect(provider.auth[0]).toMatchObject({
id: "app-server",
kind: "custom",
wizard: {
choiceId: "codex",
choiceLabel: "Codex app-server",
onboardingScopes: ["text-inference"],
},
});
await expect(provider.auth[0].run({} as never)).resolves.toMatchObject({
profiles: [],
defaultModel: "codex/gpt-5.5",
});
});
it("exposes a lightweight provider-discovery entry for model list/status", async () => {
expect(codexProviderDiscovery.id).toBe("codex");
expect(codexProviderDiscovery.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({

View File

@@ -28,6 +28,8 @@ import type {
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
const MODEL_DISCOVERY_PAGE_LIMIT = 100;
const CODEX_APP_SERVER_SETUP_METHOD_ID = "app-server";
const CODEX_DEFAULT_MODEL_REF = `${CODEX_PROVIDER_ID}/${FALLBACK_CODEX_MODELS[0].id}`;
const codexCatalogLog = createSubsystemLogger("codex/catalog");
type CodexModelLister = (options: {
@@ -55,7 +57,25 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
id: CODEX_PROVIDER_ID,
label: "Codex",
docsPath: "/providers/models",
auth: [],
auth: [
{
id: CODEX_APP_SERVER_SETUP_METHOD_ID,
label: "Codex app-server",
hint: "Use the Codex app-server runtime and managed model catalog.",
kind: "custom",
wizard: {
choiceId: CODEX_PROVIDER_ID,
choiceLabel: "Codex app-server",
choiceHint: "Use the Codex app-server runtime and managed model catalog.",
assistantPriority: -40,
groupId: CODEX_PROVIDER_ID,
groupLabel: "Codex",
groupHint: "Codex app-server model provider",
onboardingScopes: ["text-inference"],
},
run: async () => ({ profiles: [], defaultModel: CODEX_DEFAULT_MODEL_REF }),
},
],
catalog: {
order: "late",
run: async (ctx) => {

View File

@@ -16,7 +16,19 @@
"name": "Codex",
"docs": "/providers/models",
"categories": ["cloud", "llm"],
"authChoices": []
"authChoices": [
{
"method": "app-server",
"choiceId": "codex",
"choiceLabel": "Codex app-server",
"choiceHint": "Use the Codex app-server runtime and managed model catalog.",
"assistantPriority": -40,
"groupId": "codex",
"groupLabel": "Codex",
"groupHint": "Codex app-server model provider",
"onboardingScopes": ["text-inference"]
}
]
}
],
"install": {

View File

@@ -5,6 +5,8 @@ type LoadOpenClawProviderIndex =
type LoadPluginRegistrySnapshot = typeof import("./plugin-registry.js").loadPluginRegistrySnapshot;
type ResolveManifestProviderAuthChoices =
typeof import("./provider-auth-choices.js").resolveManifestProviderAuthChoices;
type ListOfficialExternalProviderCatalogEntries =
typeof import("./official-external-plugin-catalog.js").listOfficialExternalProviderCatalogEntries;
const loadOpenClawProviderIndex = vi.hoisted(() =>
vi.fn<LoadOpenClawProviderIndex>(() => ({ version: 1, providers: {} })),
@@ -43,6 +45,19 @@ vi.mock("./provider-auth-choices.js", () => ({
resolveManifestProviderAuthChoices,
}));
const listOfficialExternalProviderCatalogEntries = vi.hoisted(() =>
vi.fn<ListOfficialExternalProviderCatalogEntries>(() => []),
);
vi.mock("./official-external-plugin-catalog.js", async () => {
const actual = await vi.importActual<typeof import("./official-external-plugin-catalog.js")>(
"./official-external-plugin-catalog.js",
);
return {
...actual,
listOfficialExternalProviderCatalogEntries,
};
});
import {
resolveProviderInstallCatalogEntries,
resolveProviderInstallCatalogEntry,
@@ -64,6 +79,7 @@ describe("provider install catalog", () => {
diagnostics: [],
});
resolveManifestProviderAuthChoices.mockReturnValue([]);
listOfficialExternalProviderCatalogEntries.mockReturnValue([]);
});
it("merges manifest auth-choice metadata with registry install metadata", () => {
@@ -498,6 +514,57 @@ describe("provider install catalog", () => {
});
});
it("surfaces official external provider install metadata when the provider plugin is not installed", () => {
listOfficialExternalProviderCatalogEntries.mockReturnValue([
{
name: "@openclaw/codex",
source: "official",
kind: "provider",
openclaw: {
plugin: { id: "codex", label: "Codex" },
providers: [
{
id: "codex",
name: "Codex",
authChoices: [
{
method: "app-server",
choiceId: "codex",
choiceLabel: "Codex app-server",
choiceHint: "Use the Codex app-server runtime.",
groupId: "codex",
groupLabel: "Codex",
onboardingScopes: ["text-inference"],
},
],
},
],
install: {
npmSpec: "@openclaw/codex",
defaultChoice: "npm",
},
},
},
]);
expect(resolveProviderInstallCatalogEntry("codex")).toMatchObject({
pluginId: "codex",
providerId: "codex",
methodId: "app-server",
choiceId: "codex",
choiceLabel: "Codex app-server",
choiceHint: "Use the Codex app-server runtime.",
groupId: "codex",
groupLabel: "Codex",
onboardingScopes: ["text-inference"],
label: "Codex",
install: {
npmSpec: "@openclaw/codex",
defaultChoice: "npm",
},
});
});
it("surfaces provider-index ClawHub install metadata as the preferred source", () => {
loadOpenClawProviderIndex.mockReturnValue({
version: 1,

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { createNonExitingRuntime } from "../runtime.js";
import type { WizardPrompter } from "./prompts.js";
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: Record<string, unknown> }) => ({
cfg,
installed: true,
status: "installed",
})),
);
vi.mock("../commands/onboarding-plugin-install.js", () => ({
ensureOnboardingPluginInstalled,
}));
import {
__testing,
resolveOfficialPluginOnboardingInstallEntries,
setupOfficialPluginInstalls,
} from "./setup.official-plugins.js";
describe("resolveOfficialPluginOnboardingInstallEntries", () => {
it("lists optional generic official plugins without channel, provider, or search-owned entries", () => {
const entries = resolveOfficialPluginOnboardingInstallEntries({ config: {} });
const pluginIds = entries.map((entry) => entry.pluginId);
expect(pluginIds).toContain("diagnostics-otel");
expect(pluginIds).toContain("diagnostics-prometheus");
expect(pluginIds).toContain("acpx");
expect(pluginIds).not.toContain("brave");
expect(pluginIds).not.toContain("codex");
expect(pluginIds).not.toContain("discord");
});
it("hides already configured official plugins", () => {
const entries = resolveOfficialPluginOnboardingInstallEntries({
config: {
plugins: {
entries: {
acpx: { enabled: true },
},
installs: {
"diagnostics-otel": {
source: "npm",
spec: "@openclaw/diagnostics-otel",
installPath: "/tmp/diagnostics-otel",
},
},
},
},
});
const pluginIds = entries.map((entry) => entry.pluginId);
expect(pluginIds).not.toContain("acpx");
expect(pluginIds).not.toContain("diagnostics-otel");
expect(pluginIds).toContain("diagnostics-prometheus");
});
});
describe("formatInstallHint", () => {
it("describes dual-source npm-default installs as npm first", () => {
expect(
__testing.formatInstallHint({
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
}),
).toBe("npm, with ClawHub fallback");
});
it("keeps dual-source clawhub-default installs ClawHub first", () => {
expect(
__testing.formatInstallHint({
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "clawhub",
}),
).toBe("ClawHub, with npm fallback");
});
});
describe("setupOfficialPluginInstalls", () => {
beforeEach(() => {
vi.clearAllMocks();
ensureOnboardingPluginInstalled.mockImplementation(async ({ cfg }) => ({
cfg,
installed: true,
status: "installed",
}));
});
it("installs selected optional official plugins through the shared onboarding installer", async () => {
const multiselect = vi.fn(async () => ["diagnostics-otel"]);
const prompter = createWizardPrompter({
multiselect: multiselect as WizardPrompter["multiselect"],
});
await setupOfficialPluginInstalls({
config: {},
prompter,
runtime: createNonExitingRuntime(),
workspaceDir: "/tmp/workspace",
});
expect(multiselect).toHaveBeenCalledWith(
expect.objectContaining({
message: "Install optional plugins",
}),
);
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({
pluginId: "diagnostics-otel",
install: expect.objectContaining({
clawhubSpec: "clawhub:@openclaw/diagnostics-otel",
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
}),
}),
promptInstall: false,
workspaceDir: "/tmp/workspace",
}),
);
});
it("does not install when the user skips optional plugins", async () => {
const prompter = createWizardPrompter({
multiselect: vi.fn(async () => ["__skip__"]) as WizardPrompter["multiselect"],
});
await setupOfficialPluginInstalls({
config: {},
prompter,
runtime: createNonExitingRuntime(),
});
expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,130 @@
import { ensureOnboardingPluginInstalled } from "../commands/onboarding-plugin-install.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginPackageInstall } from "../plugins/manifest.js";
import {
getOfficialExternalPluginCatalogManifest,
listOfficialExternalPluginCatalogEntries,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
resolveOfficialExternalPluginLabel,
} from "../plugins/official-external-plugin-catalog.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "./prompts.js";
const SKIP_VALUE = "__skip__";
export type OfficialPluginOnboardingInstallEntry = {
pluginId: string;
label: string;
description?: string;
install: PluginPackageInstall;
};
function isInstalledOrConfigured(config: OpenClawConfig, pluginId: string): boolean {
return Boolean(config.plugins?.entries?.[pluginId] || config.plugins?.installs?.[pluginId]);
}
function isGenericOfficialPluginEntry(entry: { source?: string; kind?: string }): boolean {
const manifest = getOfficialExternalPluginCatalogManifest(entry);
return (
entry.source === "official" &&
entry.kind === "plugin" &&
Boolean(manifest?.plugin?.id) &&
!manifest?.channel &&
(manifest?.providers?.length ?? 0) === 0 &&
(manifest?.webSearchProviders?.length ?? 0) === 0
);
}
function formatInstallHint(install: PluginPackageInstall): string {
if (install.clawhubSpec && install.npmSpec) {
return install.defaultChoice === "clawhub"
? "ClawHub, with npm fallback"
: "npm, with ClawHub fallback";
}
if (install.clawhubSpec) {
return "ClawHub";
}
if (install.npmSpec) {
return "npm";
}
if (install.localPath) {
return "local path";
}
return "install source";
}
export const __testing = {
formatInstallHint,
};
export function resolveOfficialPluginOnboardingInstallEntries(params: {
config: OpenClawConfig;
}): OfficialPluginOnboardingInstallEntry[] {
const entries: OfficialPluginOnboardingInstallEntry[] = [];
for (const entry of listOfficialExternalPluginCatalogEntries()) {
if (!isGenericOfficialPluginEntry(entry)) {
continue;
}
const pluginId = resolveOfficialExternalPluginId(entry);
const install = resolveOfficialExternalPluginInstall(entry);
if (!pluginId || !install || isInstalledOrConfigured(params.config, pluginId)) {
continue;
}
entries.push({
pluginId,
label: resolveOfficialExternalPluginLabel(entry),
...(entry.description ? { description: entry.description } : {}),
install,
});
}
return entries.toSorted((left, right) => left.label.localeCompare(right.label));
}
export async function setupOfficialPluginInstalls(params: {
config: OpenClawConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir?: string;
}): Promise<OpenClawConfig> {
const installEntries = resolveOfficialPluginOnboardingInstallEntries({
config: params.config,
});
if (installEntries.length === 0) {
return params.config;
}
const selected = await params.prompter.multiselect({
message: "Install optional plugins",
options: [
{
value: SKIP_VALUE,
label: "Skip for now",
hint: "Continue without installing optional plugins",
},
...installEntries.map((entry) => ({
value: entry.pluginId,
label: entry.label,
hint: entry.description ?? formatInstallHint(entry.install),
})),
],
});
let next = params.config;
for (const pluginId of selected.filter((value) => value !== SKIP_VALUE)) {
const entry = installEntries.find((candidate) => candidate.pluginId === pluginId);
if (!entry) {
continue;
}
const result = await ensureOnboardingPluginInstalled({
cfg: next,
entry,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
promptInstall: false,
});
next = result.cfg;
}
return next;
}

View File

@@ -762,6 +762,13 @@ export async function runSetupWizard(
// Plugin configuration (sandbox backends, tool plugins, etc.)
if (flow !== "quickstart") {
const { setupOfficialPluginInstalls } = await import("./setup.official-plugins.js");
nextConfig = await setupOfficialPluginInstalls({
config: nextConfig,
prompter,
runtime,
workspaceDir,
});
const { setupPluginConfig } = await import("./setup.plugin-config.js");
nextConfig = await setupPluginConfig({
config: nextConfig,