From c8fa0fd1c9d588d7000380fd561c377cc2897952 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 15:11:18 -0700 Subject: [PATCH] fix(onboarding): surface official plugin installs --- CHANGELOG.md | 1 + extensions/codex/provider.test.ts | 18 +++ extensions/codex/provider.ts | 22 ++- .../official-external-provider-catalog.json | 14 +- src/plugins/provider-install-catalog.test.ts | 67 +++++++++ src/wizard/setup.official-plugins.test.ts | 140 ++++++++++++++++++ src/wizard/setup.official-plugins.ts | 130 ++++++++++++++++ src/wizard/setup.ts | 7 + 8 files changed, 397 insertions(+), 2 deletions(-) create mode 100644 src/wizard/setup.official-plugins.test.ts create mode 100644 src/wizard/setup.official-plugins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 635f5d4384a..eb16f9c0076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/codex/provider.test.ts b/extensions/codex/provider.test.ts index 1b4698fb8a6..2e9aa22a634 100644 --- a/extensions/codex/provider.test.ts +++ b/extensions/codex/provider.test.ts @@ -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({ diff --git a/extensions/codex/provider.ts b/extensions/codex/provider.ts index 20f47402758..231a35f5d7b 100644 --- a/extensions/codex/provider.ts +++ b/extensions/codex/provider.ts @@ -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) => { diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 86124eb8c57..92a809ca3d8 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -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": { diff --git a/src/plugins/provider-install-catalog.test.ts b/src/plugins/provider-install-catalog.test.ts index 5a5b8b1286e..7e96726fed0 100644 --- a/src/plugins/provider-install-catalog.test.ts +++ b/src/plugins/provider-install-catalog.test.ts @@ -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(() => ({ version: 1, providers: {} })), @@ -43,6 +45,19 @@ vi.mock("./provider-auth-choices.js", () => ({ resolveManifestProviderAuthChoices, })); +const listOfficialExternalProviderCatalogEntries = vi.hoisted(() => + vi.fn(() => []), +); +vi.mock("./official-external-plugin-catalog.js", async () => { + const actual = await vi.importActual( + "./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, diff --git a/src/wizard/setup.official-plugins.test.ts b/src/wizard/setup.official-plugins.test.ts new file mode 100644 index 00000000000..bdb8e682c0e --- /dev/null +++ b/src/wizard/setup.official-plugins.test.ts @@ -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 }) => ({ + 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(); + }); +}); diff --git a/src/wizard/setup.official-plugins.ts b/src/wizard/setup.official-plugins.ts new file mode 100644 index 00000000000..248c8e2ab6a --- /dev/null +++ b/src/wizard/setup.official-plugins.ts @@ -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 { + 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; +} diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 62a78558831..c65613c4060 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -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,