Onboarding: dedupe plugin hook results and add tests

This commit is contained in:
Gustavo Madeira Santana
2026-02-26 00:41:13 -05:00
parent 218f4d525a
commit 197e720b62
3 changed files with 226 additions and 20 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
- UI/Chat compose: add mobile stacked layout for compose action buttons on small screens to improve send/session controls usability. (#11167) Thanks @junyiz.
- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics.
- Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening.
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.

View File

@@ -5,6 +5,9 @@ import { setActivePluginRegistry } from "../plugins/runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js";
import { setupChannels } from "./onboard-channels.js";
import type { ChannelChoice } from "./onboard-types.js";
import { getChannelOnboardingAdapter } from "./onboarding/registry.js";
import type { ChannelOnboardingAdapter } from "./onboarding/types.js";
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
@@ -28,6 +31,27 @@ function createUnexpectedPromptGuards() {
};
}
function patchOnboardingAdapter<K extends keyof ChannelOnboardingAdapter>(
channel: ChannelChoice,
patch: Pick<ChannelOnboardingAdapter, K>,
): () => void {
const adapter = getChannelOnboardingAdapter(channel);
if (!adapter) {
throw new Error(`missing onboarding adapter for ${channel}`);
}
const keys = Object.keys(patch) as K[];
const previous = {} as Pick<ChannelOnboardingAdapter, K>;
for (const key of keys) {
previous[key] = adapter[key];
adapter[key] = patch[key];
}
return () => {
for (const key of keys) {
adapter[key] = previous[key];
}
};
}
vi.mock("node:fs/promises", () => ({
default: {
access: vi.fn(async () => {
@@ -249,4 +273,180 @@ describe("setupChannels", () => {
expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" }));
expect(multiselect).not.toHaveBeenCalled();
});
it("uses configureInteractive skip without mutating selection/account state", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async () => "skip" as const);
const restore = patchOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledWith(
expect.objectContaining({ configured: false, label: expect.any(String) }),
);
expect(selection).toHaveBeenCalledWith([]);
expect(onAccountId).not.toHaveBeenCalled();
expect(cfg.channels?.telegram?.botToken).toBeUndefined();
} finally {
restore();
}
});
it("applies configureInteractive result cfg/account updates", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, botToken: "new-token" },
},
} as OpenClawConfig,
accountId: "acct-1",
}));
const configure = vi.fn(async () => {
throw new Error("configure should not be called when configureInteractive is present");
});
const restore = patchOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
});
expect(configureInteractive).toHaveBeenCalledTimes(1);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-1");
expect(cfg.channels?.telegram?.botToken).toBe("new-token");
} finally {
restore();
}
});
it("uses configureWhenConfigured when channel is already configured", async () => {
const select = vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
const selection = vi.fn();
const onAccountId = vi.fn();
const configureWhenConfigured = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
channels: {
...cfg.channels,
telegram: { ...cfg.channels?.telegram, botToken: "updated-token" },
},
} as OpenClawConfig,
accountId: "acct-2",
}));
const configure = vi.fn(async () => {
throw new Error(
"configure should not be called when configureWhenConfigured handles updates",
);
});
const restore = patchOnboardingAdapter("telegram", {
getStatus: vi.fn(async ({ cfg }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
configureInteractive: undefined,
configureWhenConfigured,
configure,
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels(
{
channels: {
telegram: {
botToken: "old-token",
},
},
} as OpenClawConfig,
runtime,
prompter,
{
skipConfirm: true,
quickstartDefaults: true,
onSelection: selection,
onAccountId,
},
);
expect(configureWhenConfigured).toHaveBeenCalledTimes(1);
expect(configure).not.toHaveBeenCalled();
expect(selection).toHaveBeenCalledWith(["telegram"]);
expect(onAccountId).toHaveBeenCalledWith("telegram", "acct-2");
expect(cfg.channels?.telegram?.botToken).toBe("updated-token");
} finally {
restore();
}
});
});

View File

@@ -27,7 +27,9 @@ import {
listChannelOnboardingAdapters,
} from "./onboarding/registry.js";
import type {
ChannelOnboardingConfiguredResult,
ChannelOnboardingDmPolicy,
ChannelOnboardingResult,
ChannelOnboardingStatus,
SetupChannelsOptions,
} from "./onboarding/types.js";
@@ -488,6 +490,26 @@ export async function setupChannels(
return true;
};
const applyOnboardingResult = async (channel: ChannelChoice, result: ChannelOnboardingResult) => {
next = result.cfg;
if (result.accountId) {
recordAccount(channel, result.accountId);
}
addSelection(channel);
await refreshStatus(channel);
};
const applyCustomOnboardingResult = async (
channel: ChannelChoice,
result: ChannelOnboardingConfiguredResult,
) => {
if (result === "skip") {
return false;
}
await applyOnboardingResult(channel, result);
return true;
};
const configureChannel = async (channel: ChannelChoice) => {
const adapter = getChannelOnboardingAdapter(channel);
if (!adapter) {
@@ -503,12 +525,7 @@ export async function setupChannels(
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
});
next = result.cfg;
if (result.accountId) {
recordAccount(channel, result.accountId);
}
addSelection(channel);
await refreshStatus(channel);
await applyOnboardingResult(channel, result);
};
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
@@ -524,15 +541,9 @@ export async function setupChannels(
shouldPromptAccountIds,
forceAllowFrom: forceAllowFromChannels.has(channel),
});
if (custom === "skip") {
if (!(await applyCustomOnboardingResult(channel, custom))) {
return;
}
next = custom.cfg;
if (custom.accountId) {
recordAccount(channel, custom.accountId);
}
addSelection(channel);
await refreshStatus(channel);
return;
}
const supportsDisable = Boolean(
@@ -652,15 +663,9 @@ export async function setupChannels(
configured,
label,
});
if (custom === "skip") {
if (!(await applyCustomOnboardingResult(channel, custom))) {
return;
}
next = custom.cfg;
if (custom.accountId) {
recordAccount(channel, custom.accountId);
}
addSelection(channel);
await refreshStatus(channel);
return;
}
if (configured) {