fix(channels): clarify remote install hints

Clarify remote channel install hints and align onboarding install source labels with progress-bar coverage.
This commit is contained in:
Peter Steinberger
2026-05-02 23:57:41 +01:00
committed by GitHub
parent 9a899a29b8
commit 15bbf4f2f3
8 changed files with 332 additions and 34 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates.
- Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc.
- Bonjour: disable LAN mDNS advertising after a repeated stuck-announcing recovery instead of repeatedly restarting ciao and saturating the Gateway event loop.
- Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw.
- CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries.
- Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`.
- Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits.

View File

@@ -518,7 +518,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
options: [
expect.objectContaining({
value: "npm",
label: `Download from npm (${bundledChatForkNpmSpec})`,
label: `Remote install from npm (${bundledChatForkNpmSpec})`,
}),
expect.objectContaining({
value: "skip",
@@ -562,7 +562,7 @@ describe("ensureChannelSetupPluginInstalled", () => {
options: [
expect.objectContaining({
value: "clawhub",
label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)",
label: "Remote install from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)",
}),
expect.objectContaining({
value: "skip",

View File

@@ -72,6 +72,23 @@ vi.mock("../utils/with-timeout.js", () => ({
import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js";
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
async function waitForMockCall(mock: { mock: { calls: unknown[][] } }) {
for (let i = 0; i < 20; i += 1) {
if (mock.mock.calls.length > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("ensureOnboardingPluginInstalled", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -241,6 +258,114 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled();
});
it("animates ClawHub install progress while the remote install is running", async () => {
const deferred = createDeferred<Awaited<ReturnType<typeof installPluginFromClawHub>>>();
installPluginFromClawHub.mockImplementation(async (params) => {
params.logger?.info?.("Downloading demo-plugin from ClawHub…");
return await deferred.promise;
});
const stop = vi.fn();
const update = vi.fn();
const install = ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
label: "Demo Provider",
install: {
clawhubSpec: "clawhub:demo-plugin@2026.5.2",
defaultChoice: "clawhub",
},
},
prompter: {
select: vi.fn(async () => "clawhub"),
progress: vi.fn(() => ({ update, stop })),
} as never,
runtime: {} as never,
});
await waitForMockCall(installPluginFromClawHub);
expect(installPluginFromClawHub).toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(update).toHaveBeenCalledWith("Downloading");
expect(
update.mock.calls.some(
([message]) =>
typeof message === "string" && /^Downloading {2}\[[]{16}\] \d+%$/u.test(message),
),
).toBe(true);
deferred.resolve({
ok: true,
pluginId: "demo-plugin",
targetDir: "/tmp/demo-plugin",
version: "2026.5.2",
packageName: "demo-plugin",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo-plugin",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
version: "2026.5.2",
integrity: "sha256-clawpack",
resolvedAt: "2026-05-02T00:00:00.000Z",
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
clawpackSpecVersion: 1,
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
clawpackSize: 4096,
},
});
await install;
});
it("animates npm install progress while the remote install is running", async () => {
const deferred = createDeferred<Awaited<ReturnType<typeof installPluginFromNpmSpec>>>();
installPluginFromNpmSpec.mockImplementation(async (params) => {
params.logger?.info?.("Resolving npm package…");
return await deferred.promise;
});
const stop = vi.fn();
const update = vi.fn();
const install = ensureOnboardingPluginInstalled({
cfg: {},
entry: {
pluginId: "demo-plugin",
label: "Demo Plugin",
install: {
npmSpec: "@demo/plugin@1.2.3",
},
},
prompter: {
select: vi.fn(async () => "npm"),
progress: vi.fn(() => ({ update, stop })),
} as never,
runtime: {} as never,
});
await waitForMockCall(installPluginFromNpmSpec);
expect(installPluginFromNpmSpec).toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(update).toHaveBeenCalledWith("Resolving");
expect(
update.mock.calls.some(
([message]) =>
typeof message === "string" && /^Resolving {2}\[[]{16}\] \d+%$/u.test(message),
),
).toBe(true);
deferred.resolve({
ok: true,
pluginId: "demo-plugin",
targetDir: "/tmp/demo-plugin",
version: "1.2.3",
});
await install;
});
it("returns a timed out status and notes the retry path when npm install hangs", async () => {
const note = vi.fn(async () => {});
const stop = vi.fn();
@@ -310,7 +435,7 @@ describe("ensureOnboardingPluginInstalled", () => {
});
expect(captured?.options).toEqual([
{ value: "npm", label: "Download from npm (@demo/plugin)" },
{ value: "npm", label: "Remote install from npm (@demo/plugin)" },
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("npm");
@@ -349,8 +474,11 @@ describe("ensureOnboardingPluginInstalled", () => {
});
expect(captured?.options).toEqual([
{ value: "clawhub", label: "Download from ClawHub (clawhub:demo-plugin@2026.5.2)" },
{ value: "npm", label: "Download from npm (@openclaw/demo-plugin@2026.5.2)" },
{
value: "clawhub",
label: "Remote install from ClawHub (clawhub:demo-plugin@2026.5.2)",
},
{ value: "npm", label: "Remote install from npm (@openclaw/demo-plugin@2026.5.2)" },
{ value: "skip", label: "Skip for now" },
]);
expect(captured?.initialValue).toBe("clawhub");
@@ -460,7 +588,7 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(captured).toBeDefined();
expect(captured?.message).toBe("Install Demo Plugin\\n plugin?");
expect(captured?.options).toEqual([
{ value: "npm", label: "Download from npm (@demo/plugin@1.2.3)" },
{ value: "npm", label: "Remote install from npm (@demo/plugin@1.2.3)" },
{
value: "local",
label: "Use local plugin path",
@@ -674,7 +802,7 @@ describe("ensureOnboardingPluginInstalled", () => {
});
expect(captured).toBeDefined();
// "Download from npm (@openclaw/tlon)" must NOT appear: the bundled
// "Remote install from npm (@openclaw/tlon)" must NOT appear: the bundled
// copy is what gets enabled, so the npm hint would only confuse
// users into thinking the plugin is missing.
expect(captured?.options).toEqual([

View File

@@ -320,7 +320,7 @@ async function promptInstallChoice(params: {
// `extensions/<id>` and is discovered via `resolveBundledPluginSources`),
// the bundled copy is the source of truth: it is version-locked to the
// current host build and is what `defaultChoice` will pick anyway (see
// `resolveInstallDefaultChoice`). Surfacing remote download options in that
// `resolveInstallDefaultChoice`). Surfacing remote install options in that
// case is misleading; those catalog specs only exist as fallback metadata for
// non-bundled builds. Hide them so bundled channels like Tlon look identical
// to Twitch / Slack in the menu.
@@ -334,13 +334,13 @@ async function promptInstallChoice(params: {
if (safeClawHubSpec) {
options.push({
value: "clawhub",
label: `Download from ClawHub (${safeClawHubSpec})`,
label: formatRemoteInstallChoiceLabel("clawhub", safeClawHubSpec),
});
}
if (safeNpmSpec) {
options.push({
value: "npm",
label: `Download from npm (${safeNpmSpec})`,
label: formatRemoteInstallChoiceLabel("npm", safeNpmSpec),
});
}
if (params.localPath) {
@@ -420,6 +420,11 @@ function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message === "timeout";
}
function formatRemoteInstallChoiceLabel(source: "clawhub" | "npm", spec: string): string {
const sourceLabel = source === "clawhub" ? "ClawHub" : "npm";
return `Remote install from ${sourceLabel} (${spec})`;
}
async function applyPluginEnablement(params: {
cfg: OpenClawConfig;
pluginId: string;

View File

@@ -12,6 +12,10 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh
type FormatChannelSelectionLine =
typeof import("../channels/registry.js").formatChannelSelectionLine;
type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured;
type ResolveBundledPluginSources =
typeof import("../plugins/bundled-sources.js").resolveBundledPluginSources;
type FindBundledPluginSourceInMap =
typeof import("../plugins/bundled-sources.js").findBundledPluginSourceInMap;
type NoteChannelPrimerChannels = Parameters<
typeof import("./channel-setup.status.js").noteChannelPrimer
>[1];
@@ -33,6 +37,21 @@ const formatChannelSelectionLine = vi.hoisted(() =>
vi.fn<FormatChannelSelectionLine>((meta) => `${meta.label}${meta.blurb}`),
);
const isChannelConfigured = vi.hoisted(() => vi.fn<IsChannelConfigured>(() => false));
const resolveBundledPluginSources = vi.hoisted(() =>
vi.fn<ResolveBundledPluginSources>(() => new Map()),
);
const findBundledPluginSourceInMap = vi.hoisted(() =>
vi.fn<FindBundledPluginSourceInMap>(({ bundled, lookup }) => {
const value = lookup.value.trim();
if (!value) {
return undefined;
}
if (lookup.kind === "pluginId") {
return bundled.get(value);
}
return Array.from(bundled.values()).find((source) => source.npmSpec === value);
}),
);
vi.mock("../channels/chat-meta.js", () => ({
listChatChannels: () => listChatChannels(),
@@ -62,20 +81,20 @@ vi.mock("../config/channel-configured.js", () => ({
) => isChannelConfigured(cfg, channelId),
}));
// Avoid touching the real `extensions/<id>` tree from unit tests. Status
// rendering for installable catalog entries asks `bundled-sources` whether
// a plugin already lives in-tree to decide between
// "install plugin to enable" vs "bundled · enable to use". For these tests
// we want the installable-catalog branch unconditionally, so we stub the
// bundled lookup to "nothing is bundled".
// Avoid touching the real `extensions/<id>` tree from unit tests. Tests opt
// into bundled-source entries explicitly when they cover bundled catalog
// rendering; the default fixture behaves as if nothing is bundled.
vi.mock("../plugins/bundled-sources.js", () => ({
resolveBundledPluginSources: () => new Map(),
findBundledPluginSourceInMap: () => undefined,
resolveBundledPluginSources: (params: Parameters<ResolveBundledPluginSources>[0]) =>
resolveBundledPluginSources(params),
findBundledPluginSourceInMap: (params: Parameters<FindBundledPluginSourceInMap>[0]) =>
findBundledPluginSourceInMap(params),
}));
import {
collectChannelStatus,
noteChannelPrimer,
resolveCatalogChannelSelectionHint,
resolveChannelSelectionNoteLines,
resolveChannelSetupSelectionContributions,
} from "./channel-setup.status.js";
@@ -93,6 +112,17 @@ describe("resolveChannelSetupSelectionContributions", () => {
);
formatChannelSelectionLine.mockImplementation((meta) => `${meta.label}${meta.blurb}`);
isChannelConfigured.mockReturnValue(false);
resolveBundledPluginSources.mockReturnValue(new Map());
findBundledPluginSourceInMap.mockImplementation(({ bundled, lookup }) => {
const value = lookup.value.trim();
if (!value) {
return undefined;
}
if (lookup.kind === "pluginId") {
return bundled.get(value);
}
return Array.from(bundled.values()).find((source) => source.npmSpec === value);
});
});
it("sorts channels alphabetically by picker label", () => {
@@ -158,6 +188,67 @@ describe("resolveChannelSetupSelectionContributions", () => {
]);
});
it("describes installable catalog choices as remote npm installs", () => {
expect(
resolveCatalogChannelSelectionHint({
install: { npmSpec: "@openclaw/googlechat" },
}),
).toBe("remote install from npm: @openclaw/googlechat");
});
it("sanitizes remote npm install hints", () => {
expect(
resolveCatalogChannelSelectionHint({
install: { npmSpec: "@openclaw/googlechat\u001B[31m\nbeta" },
}),
).toBe("remote install from npm: @openclaw/googlechat\\nbeta");
});
it("suppresses remote install hints for bundled channels", () => {
expect(
resolveCatalogChannelSelectionHint(
{
install: { npmSpec: "@openclaw/googlechat" },
},
{ bundledLocalPath: "extensions/googlechat" },
),
).toBe("");
});
it("renders bundled catalog statuses without remote install hints", async () => {
const entry = makeCatalogEntry("slack", "Slack", {
pluginId: "@openclaw/slack",
install: { npmSpec: "@openclaw/slack" },
});
listChatChannels.mockReturnValue([]);
resolveBundledPluginSources.mockReturnValue(
new Map([
[
"@openclaw/slack",
{
pluginId: "@openclaw/slack",
localPath: "extensions/slack",
npmSpec: "@openclaw/slack",
},
],
]),
);
resolveChannelSetupEntries.mockReturnValue(
makeChannelSetupEntries({
installableCatalogEntries: [entry],
}),
);
const summary = await collectChannelStatus({
cfg: {} as never,
accountOverrides: {},
installedPlugins: [],
});
expect(summary.statusLines).toEqual(["Slack: bundled · enable to use"]);
expect(summary.statusByChannel.get("slack")?.selectionHint).toBe("");
});
it("combines real status and disabled hints when available", () => {
const contributions = resolveChannelSetupSelectionContributions({
entries: [

View File

@@ -135,17 +135,17 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta {
/**
* Hint shown next to an installable channel option in the selection menu when
* we don't yet have a runtime-collected status. Mirrors the "configured" /
* "installed" affordance other channels get so users can see "download from
* <npm-spec>" before committing to install.
* "installed" affordance other channels get so users can see "remote install
* from npm: <npm-spec>" before committing to install.
*
* Bundled channels (the plugin lives under `extensions/<id>` in the host
* repo, e.g. Signal / Tlon / Twitch / Slack) are NOT downloaded from npm —
* they ship with the host. Even when their `package.json` declares an
* `npmSpec` (or the catalog falls back to the package name), surfacing
* "download from <npm-spec>" misleads users into believing the plugin is
* missing. For bundled channels we suppress the npm hint entirely so the
* menu shows the same neutral "plugin · install" affordance used when no
* npm source is known.
* "remote install from npm: <npm-spec>" misleads users into believing the
* plugin is missing. For bundled channels we suppress the npm hint entirely
* so the menu shows the same neutral "plugin · install" affordance used when
* no npm source is known.
*/
export function resolveCatalogChannelSelectionHint(
entry: { install?: { npmSpec?: string } },
@@ -153,7 +153,7 @@ export function resolveCatalogChannelSelectionHint(
): string {
const npmSpec = entry.install?.npmSpec?.trim();
if (npmSpec && !options?.bundledLocalPath) {
return `download from ${formatSetupSelectionLabel(npmSpec, npmSpec)}`;
return `remote install from npm: ${formatSetupSelectionLabel(npmSpec, npmSpec)}`;
}
return "";
}
@@ -162,8 +162,8 @@ export function resolveCatalogChannelSelectionHint(
* Look up the bundled-source entry for a catalog channel, regardless of
* whether the catalog refers to it by `pluginId` or `npmSpec`. We use this
* to detect bundled channels in the selection menu so we can suppress the
* misleading "download from <npm-spec>" hint for plugins that already ship
* with the host (Signal / Tlon / Twitch / Slack ...).
* misleading "remote install from npm: <npm-spec>" hint for plugins that
* already ship with the host (Signal / Tlon / Twitch / Slack ...).
*/
export function findBundledSourceForCatalogChannel(params: {
bundled: ReadonlyMap<string, BundledPluginSource>;

View File

@@ -9,6 +9,12 @@ type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").Ch
type ResolveChannelSetupEntries =
typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries;
type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus;
type FindBundledSourceForCatalogChannel =
typeof import("./channel-setup.status.js").findBundledSourceForCatalogChannel;
type ResolveCatalogChannelSelectionHint =
typeof import("./channel-setup.status.js").resolveCatalogChannelSelectionHint;
type ResolveChannelSetupSelectionContributions =
typeof import("./channel-setup.status.js").resolveChannelSetupSelectionContributions;
type EnsureChannelSetupPluginInstalled =
typeof import("../commands/channel-setup/plugin-install.js").ensureChannelSetupPluginInstalled;
type LoadChannelSetupPluginRegistrySnapshotForChannel =
@@ -117,6 +123,18 @@ const collectChannelStatus = vi.hoisted(() =>
statusLines: [],
})),
);
const findBundledSourceForCatalogChannel = vi.hoisted(() =>
vi.fn<FindBundledSourceForCatalogChannel>(() => undefined),
);
const resolveCatalogChannelSelectionHint = vi.hoisted(() =>
vi.fn<ResolveCatalogChannelSelectionHint>((entry, options) => {
const npmSpec = entry.install?.npmSpec?.trim();
return npmSpec && !options?.bundledLocalPath ? `remote install from npm: ${npmSpec}` : "";
}),
);
const resolveChannelSetupSelectionContributions = vi.hoisted(() =>
vi.fn<ResolveChannelSetupSelectionContributions>(() => []),
);
const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true));
vi.mock("../agents/agent-scope.js", () => ({
@@ -178,12 +196,18 @@ vi.mock("./channel-setup.prompts.js", () => ({
vi.mock("./channel-setup.status.js", () => ({
collectChannelStatus: (params: Parameters<CollectChannelStatus>[0]) =>
collectChannelStatus(params),
findBundledSourceForCatalogChannel: vi.fn(() => undefined),
findBundledSourceForCatalogChannel: (params: Parameters<FindBundledSourceForCatalogChannel>[0]) =>
findBundledSourceForCatalogChannel(params),
noteChannelPrimer: vi.fn(),
noteChannelStatus: vi.fn(),
resolveCatalogChannelSelectionHint: vi.fn(() => "download from <npm>"),
resolveCatalogChannelSelectionHint: (
entry: Parameters<ResolveCatalogChannelSelectionHint>[0],
options: Parameters<ResolveCatalogChannelSelectionHint>[1],
) => resolveCatalogChannelSelectionHint(entry, options),
resolveChannelSelectionNoteLines: vi.fn(() => []),
resolveChannelSetupSelectionContributions: vi.fn(() => []),
resolveChannelSetupSelectionContributions: (
params: Parameters<ResolveChannelSetupSelectionContributions>[0],
) => resolveChannelSetupSelectionContributions(params),
resolveQuickstartDefault: vi.fn(() => undefined),
}));
@@ -219,6 +243,12 @@ describe("setupChannels workspace shadow exclusion", () => {
statusByChannel: new Map(),
statusLines: [],
});
findBundledSourceForCatalogChannel.mockReturnValue(undefined);
resolveCatalogChannelSelectionHint.mockImplementation((entry, options) => {
const npmSpec = entry.install?.npmSpec?.trim();
return npmSpec && !options?.bundledLocalPath ? `remote install from npm: ${npmSpec}` : "";
});
resolveChannelSetupSelectionContributions.mockReturnValue([]);
isChannelConfigured.mockReturnValue(true);
});
@@ -338,6 +368,48 @@ describe("setupChannels workspace shadow exclusion", () => {
expect(collectChannelStatus).not.toHaveBeenCalled();
});
it("suppresses deferred picker remote install hints for bundled catalog choices", async () => {
const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", {
pluginId: "@openclaw/external-chat",
install: { npmSpec: "@openclaw/external-chat" },
});
resolveChannelSetupEntries.mockReturnValue(
externalChatSetupEntries({
installableCatalogEntries: [installableCatalogEntry],
installableCatalogById: new Map([["external-chat", installableCatalogEntry]]),
}),
);
findBundledSourceForCatalogChannel.mockReturnValue({
pluginId: "@openclaw/external-chat",
localPath: "extensions/external-chat",
npmSpec: "@openclaw/external-chat",
});
const select = vi.fn(async () => "__done__");
await setupChannels(
{} as never,
{} as never,
{
confirm: vi.fn(async () => true),
note: vi.fn(async () => undefined),
select,
} as never,
{
deferStatusUntilSelection: true,
skipConfirm: true,
},
);
expect(resolveCatalogChannelSelectionHint).toHaveBeenCalledWith(installableCatalogEntry, {
bundledLocalPath: "extensions/external-chat",
});
expect(
resolveChannelSetupSelectionContributions.mock.calls[0]?.[0].statusByChannel.get(
"external-chat",
)?.selectionHint,
).toBe("");
});
it("uses an active deferred setup plugin without enabling config on selection", async () => {
const setupWizard = {
channel: "custom-chat",

View File

@@ -320,13 +320,14 @@ export async function setupChannels(
// installable catalog channels (e.g. WeCom shipped via npm). In QuickStart we
// run with `deferStatusUntilSelection`, which leaves `statusByChannel` empty
// until the user picks a channel — without this overlay the selection menu
// would render those options without any "download from <npm-spec>" hint.
// would render those options without any "remote install from npm:
// <npm-spec>" hint.
//
// Bundled channels (Signal / Tlon / Twitch / Slack ...) reach this code path
// too whenever their plugin is not yet enabled, because they share the same
// "installable catalog" bucket. For those we must NOT show "download from
// <npm-spec>" — the plugin already lives under `extensions/<id>` and the
// hint would mislead users into thinking the plugin is missing.
// "installable catalog" bucket. For those we must NOT show "remote install
// from npm: <npm-spec>" — the plugin already lives under `extensions/<id>`
// and the hint would mislead users into thinking the plugin is missing.
const buildStatusByChannelForSelection = (
catalogById: ReturnType<typeof getChannelEntries>["catalogById"],
): Map<ChannelChoice, ChannelSetupStatus> => {