Channels: run post-write setup hooks

This commit is contained in:
Gustavo Madeira Santana 2026-03-14 23:32:24 +00:00
parent 81e6c19474
commit cba14062f6
No known key found for this signature in database
18 changed files with 897 additions and 30 deletions

View File

@ -0,0 +1,220 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
const verificationMocks = vi.hoisted(() => ({
bootstrapMatrixVerification: vi.fn(),
}));
vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: verificationMocks.bootstrapMatrixVerification,
}));
import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix setup post-write bootstrap", () => {
const log = vi.fn();
const error = vi.fn();
const exit = vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
});
const runtime: RuntimeEnv = {
log,
error,
exit,
};
beforeEach(() => {
verificationMocks.bootstrapMatrixVerification.mockReset();
log.mockClear();
error.mockClear();
exit.mockClear();
setMatrixRuntime({
state: {
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
},
} as PluginRuntime);
});
it("bootstraps verification for newly added encrypted accounts", async () => {
const previousCfg = {
channels: {
matrix: {
encryption: true,
},
},
} as CoreConfig;
const input = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
password: "secret", // pragma: allowlist secret
};
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
cfg: previousCfg,
accountId: "default",
input,
}) as CoreConfig;
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
success: true,
verification: {
backupVersion: "7",
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
await matrixPlugin.setup!.afterAccountConfigWritten?.({
previousCfg,
cfg: nextCfg,
accountId: "default",
input,
runtime,
});
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
expect(error).not.toHaveBeenCalled();
});
it("does not bootstrap verification for already configured accounts", async () => {
const previousCfg = {
channels: {
matrix: {
accounts: {
flurry: {
encryption: true,
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "token",
},
},
},
},
} as CoreConfig;
const input = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
accessToken: "new-token",
};
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
cfg: previousCfg,
accountId: "flurry",
input,
}) as CoreConfig;
await matrixPlugin.setup!.afterAccountConfigWritten?.({
previousCfg,
cfg: nextCfg,
accountId: "flurry",
input,
runtime,
});
expect(verificationMocks.bootstrapMatrixVerification).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
});
it("logs a warning when verification bootstrap fails", async () => {
const previousCfg = {
channels: {
matrix: {
encryption: true,
},
},
} as CoreConfig;
const input = {
homeserver: "https://matrix.example.org",
userId: "@flurry:example.org",
password: "secret", // pragma: allowlist secret
};
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
cfg: previousCfg,
accountId: "default",
input,
}) as CoreConfig;
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
success: false,
error: "no room-key backup exists on the homeserver",
verification: {
backupVersion: null,
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
await matrixPlugin.setup!.afterAccountConfigWritten?.({
previousCfg,
cfg: nextCfg,
accountId: "default",
input,
runtime,
});
expect(error).toHaveBeenCalledWith(
'Matrix verification bootstrap warning for "default": no room-key backup exists on the homeserver',
);
});
it("bootstraps a newly added env-backed default account when encryption is already enabled", async () => {
const previousEnv = {
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
};
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
process.env.MATRIX_ACCESS_TOKEN = "env-token";
try {
const previousCfg = {
channels: {
matrix: {
encryption: true,
},
},
} as CoreConfig;
const input = {
useEnv: true,
};
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
cfg: previousCfg,
accountId: "default",
input,
}) as CoreConfig;
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
success: true,
verification: {
backupVersion: "9",
},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
await matrixPlugin.setup!.afterAccountConfigWritten?.({
previousCfg,
cfg: nextCfg,
accountId: "default",
input,
runtime,
});
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
accountId: "default",
});
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
});

View File

@ -51,6 +51,7 @@ import {
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@ -392,6 +393,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
initialSyncLimit: input.initialSyncLimit,
});
},
afterAccountConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => {
await runMatrixSetupBootstrapAfterConfigWrite({
previousCfg: previousCfg as CoreConfig,
cfg: cfg as CoreConfig,
accountId,
runtime,
});
},
},
outbound: matrixOutbound,
status: {

View File

@ -435,8 +435,17 @@ describe("matrix CLI verification commands", () => {
});
it("does not bootstrap verification when updating an already configured account", async () => {
resolveMatrixAccountMock.mockReturnValue({
configured: true,
matrixRuntimeLoadConfigMock.mockReturnValue({
channels: {
matrix: {
accounts: {
ops: {
enabled: true,
homeserver: "https://matrix.example.org",
},
},
},
},
});
resolveMatrixAccountConfigMock.mockReturnValue({
encryption: true,

View File

@ -29,6 +29,7 @@ import {
} from "./matrix/direct-management.js";
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
import { getMatrixRuntime } from "./runtime.js";
import { maybeBootstrapNewEncryptedMatrixAccount } from "./setup-bootstrap.js";
import type { CoreConfig } from "./types.js";
let matrixCliExitScheduled = false;
@ -193,8 +194,6 @@ async function addMatrixAccount(params: {
accountId: params.account,
input,
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
const existingAccount = resolveMatrixAccount({ cfg, accountId });
const validationError = setup.validateInput?.({
cfg,
accountId,
@ -218,27 +217,12 @@ async function addMatrixAccount(params: {
recoveryKeyCreatedAt: null,
backupVersion: null,
};
if (existingAccount.configured !== true && accountConfig.encryption === true) {
try {
const bootstrap = await bootstrapMatrixVerification({ accountId });
verificationBootstrap = {
attempted: true,
success: bootstrap.success === true,
recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt,
backupVersion: bootstrap.verification.backupVersion,
...(bootstrap.success
? {}
: { error: bootstrap.error ?? "Matrix verification bootstrap failed" }),
};
} catch (err) {
verificationBootstrap = {
attempted: true,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
error: toErrorMessage(err),
};
}
if (accountConfig.encryption === true) {
verificationBootstrap = await maybeBootstrapNewEncryptedMatrixAccount({
previousCfg: cfg,
cfg: updated,
accountId,
});
}
const desiredDisplayName = input.name?.trim();

View File

@ -1,4 +1,5 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
@ -43,3 +44,25 @@ export function findMatrixAccountConfig(
}
return undefined;
}
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {
const normalized = normalizeAccountId(accountId);
if (findMatrixAccountConfig(cfg, normalized)) {
return true;
}
if (normalized !== DEFAULT_ACCOUNT_ID) {
return false;
}
const matrix = resolveMatrixBaseConfig(cfg);
return (
typeof matrix.enabled === "boolean" ||
typeof matrix.name === "string" ||
typeof matrix.homeserver === "string" ||
typeof matrix.userId === "string" ||
typeof matrix.accessToken === "string" ||
typeof matrix.password === "string" ||
typeof matrix.deviceId === "string" ||
typeof matrix.deviceName === "string" ||
typeof matrix.avatarUrl === "string"
);
}

View File

@ -35,6 +35,7 @@ import {
} from "./matrix/config-update.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
@ -578,6 +579,14 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
intent: action === "add-account" ? "add-account" : "update",
});
},
afterConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => {
await runMatrixSetupBootstrapAfterConfigWrite({
previousCfg: previousCfg as CoreConfig,
cfg: cfg as CoreConfig,
accountId,
runtime,
});
},
dmPolicy,
disable: (cfg) => ({
...(cfg as CoreConfig),

View File

@ -0,0 +1,93 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
import { hasExplicitMatrixAccountConfig } from "./matrix/account-config.js";
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { bootstrapMatrixVerification } from "./matrix/actions/verification.js";
import type { CoreConfig } from "./types.js";
export type MatrixSetupVerificationBootstrapResult = {
attempted: boolean;
success: boolean;
recoveryKeyCreatedAt: string | null;
backupVersion: string | null;
error?: string;
};
export async function maybeBootstrapNewEncryptedMatrixAccount(params: {
previousCfg: CoreConfig;
cfg: CoreConfig;
accountId: string;
}): Promise<MatrixSetupVerificationBootstrapResult> {
const accountConfig = resolveMatrixAccountConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (
hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) ||
accountConfig.encryption !== true
) {
return {
attempted: false,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
};
}
try {
const bootstrap = await bootstrapMatrixVerification({ accountId: params.accountId });
return {
attempted: true,
success: bootstrap.success === true,
recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt,
backupVersion: bootstrap.verification.backupVersion,
...(bootstrap.success
? {}
: { error: bootstrap.error ?? "Matrix verification bootstrap failed" }),
};
} catch (err) {
return {
attempted: true,
success: false,
recoveryKeyCreatedAt: null,
backupVersion: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function runMatrixSetupBootstrapAfterConfigWrite(params: {
previousCfg: CoreConfig;
cfg: CoreConfig;
accountId: string;
runtime: RuntimeEnv;
}): Promise<void> {
const nextAccountConfig = resolveMatrixAccountConfig({
cfg: params.cfg,
accountId: params.accountId,
});
if (nextAccountConfig.encryption !== true) {
return;
}
const bootstrap = await maybeBootstrapNewEncryptedMatrixAccount({
previousCfg: params.previousCfg,
cfg: params.cfg,
accountId: params.accountId,
});
if (!bootstrap.attempted) {
return;
}
if (bootstrap.success) {
params.runtime.log(`Matrix verification bootstrap: complete for "${params.accountId}".`);
if (bootstrap.backupVersion) {
params.runtime.log(
`Matrix backup version for "${params.accountId}": ${bootstrap.backupVersion}`,
);
}
return;
}
params.runtime.error(
`Matrix verification bootstrap warning for "${params.accountId}": ${bootstrap.error ?? "unknown bootstrap failure"}`,
);
}

View File

@ -13,6 +13,7 @@ export type SetupChannelsOptions = {
allowDisable?: boolean;
allowSignalInstall?: boolean;
onSelection?: (selection: ChannelId[]) => void;
onPostWriteHook?: (hook: ChannelOnboardingPostWriteHook) => void;
accountIds?: Partial<Record<ChannelId, string>>;
onAccountId?: (channel: ChannelId, accountId: string) => void;
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void;
@ -64,6 +65,19 @@ export type ChannelSetupConfigureContext = {
forceAllowFrom: boolean;
};
export type ChannelOnboardingPostWriteContext = {
previousCfg: OpenClawConfig;
cfg: OpenClawConfig;
accountId: string;
runtime: RuntimeEnv;
};
export type ChannelOnboardingPostWriteHook = {
channel: ChannelId;
accountId: string;
run: (ctx: { cfg: OpenClawConfig; runtime: RuntimeEnv }) => Promise<void> | void;
};
export type ChannelSetupResult = {
cfg: OpenClawConfig;
accountId?: string;
@ -104,6 +118,7 @@ export type ChannelSetupWizardAdapter = {
configureWhenConfigured?: (
ctx: ChannelSetupInteractiveContext,
) => Promise<ChannelSetupConfiguredResult>;
afterConfigWritten?: (ctx: ChannelOnboardingPostWriteContext) => Promise<void> | void;
dmPolicy?: ChannelSetupDmPolicy;
onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void;
disable?: (cfg: OpenClawConfig) => OpenClawConfig;

View File

@ -74,6 +74,13 @@ export type ChannelSetupAdapter = {
accountId: string;
input: ChannelSetupInput;
}) => OpenClawConfig;
afterAccountConfigWritten?: (params: {
previousCfg: OpenClawConfig;
cfg: OpenClawConfig;
accountId: string;
input: ChannelSetupInput;
runtime: RuntimeEnv;
}) => Promise<void> | void;
validateInput?: (params: {
cfg: OpenClawConfig;
accountId: string;

View File

@ -3,6 +3,8 @@ import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-hel
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const setupChannelsMock = vi.hoisted(() => vi.fn());
const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const wizardMocks = vi.hoisted(() => ({
createClackPrompter: vi.fn(),
@ -18,6 +20,16 @@ vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: wizardMocks.createClackPrompter,
}));
vi.mock("./onboard-channels.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-channels.js")>()),
setupChannels: setupChannelsMock,
}));
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
}));
import { WizardCancelledError } from "../wizard/prompts.js";
import { agentsAddCommand } from "./agents.js";
@ -27,6 +39,8 @@ describe("agents add command", () => {
beforeEach(() => {
readConfigFileSnapshotMock.mockClear();
writeConfigFileMock.mockClear();
setupChannelsMock.mockReset();
ensureWorkspaceAndSessionsMock.mockClear();
wizardMocks.createClackPrompter.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
@ -70,4 +84,61 @@ describe("agents add command", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("runs collected channel post-write hooks after saving the agent config", async () => {
const hookRun = vi.fn().mockResolvedValue(undefined);
readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot });
setupChannelsMock.mockImplementation(async (cfg, _runtime, _prompter, options) => {
options?.onPostWriteHook?.({
channel: "telegram",
accountId: "acct-1",
run: hookRun,
});
return {
...cfg,
channels: {
...cfg.channels,
telegram: {
botToken: "new-token",
},
},
};
});
wizardMocks.createClackPrompter.mockReturnValue({
intro: vi.fn().mockResolvedValue(undefined),
text: vi.fn().mockResolvedValue("/tmp/work"),
confirm: vi.fn().mockResolvedValue(false),
note: vi.fn().mockResolvedValue(undefined),
outro: vi.fn().mockResolvedValue(undefined),
});
await agentsAddCommand({ name: "Work" }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
agents: expect.objectContaining({
list: expect.arrayContaining([
expect.objectContaining({
id: "work",
name: "Work",
workspace: "/tmp/work",
}),
]),
}),
}),
);
expect(hookRun).toHaveBeenCalledWith({
cfg: expect.objectContaining({
channels: {
telegram: {
botToken: "new-token",
},
},
}),
runtime,
});
expect(writeConfigFileMock.mock.invocationCallOrder[0]).toBeLessThan(
hookRun.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
});

View File

@ -25,7 +25,11 @@ import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.
import { applyAgentConfig, findAgentEntryIndex, listAgentEntries } from "./agents.config.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
import { setupChannels } from "./onboard-channels.js";
import {
createChannelOnboardingPostWriteHookCollector,
runCollectedChannelOnboardingPostWriteHooks,
setupChannels,
} from "./onboard-channels.js";
import { ensureWorkspaceAndSessions } from "./onboard-helpers.js";
import type { ChannelChoice } from "./onboard-types.js";
@ -294,8 +298,12 @@ export async function agentsAddCommand(
let selection: ChannelChoice[] = [];
const channelAccountIds: Partial<Record<ChannelChoice, string>> = {};
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowSignalInstall: true,
onPostWriteHook: (hook) => {
postWriteHooks.collect(hook);
},
onSelection: (value) => {
selection = value;
},
@ -343,6 +351,11 @@ export async function agentsAddCommand(
}
await writeConfigFile(nextConfig);
await runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: nextConfig,
runtime,
});
logConfigUpdated(runtime);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),

View File

@ -8,11 +8,12 @@ import type { ChannelChoice } from "./onboard-types.js";
type ChannelSetupWizardAdapterPatch = Partial<
Pick<
ChannelSetupWizardAdapter,
"configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus"
"afterConfigWritten" | "configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus"
>
>;
type PatchedSetupAdapterFields = {
afterConfigWritten?: ChannelSetupWizardAdapter["afterConfigWritten"];
configure?: ChannelSetupWizardAdapter["configure"];
configureInteractive?: ChannelSetupWizardAdapter["configureInteractive"];
configureWhenConfigured?: ChannelSetupWizardAdapter["configureWhenConfigured"];
@ -47,6 +48,10 @@ export function patchChannelSetupWizardAdapter(
previous.getStatus = adapter.getStatus;
adapter.getStatus = patch.getStatus ?? adapter.getStatus;
}
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
previous.afterConfigWritten = adapter.afterConfigWritten;
adapter.afterConfigWritten = patch.afterConfigWritten;
}
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
previous.configure = adapter.configure;
adapter.configure = patch.configure ?? adapter.configure;
@ -64,6 +69,9 @@ export function patchChannelSetupWizardAdapter(
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
adapter.getStatus = previous.getStatus!;
}
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
adapter.afterConfigWritten = previous.afterConfigWritten;
}
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
adapter.configure = previous.configure!;
}

View File

@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
@ -339,4 +340,106 @@ describe("channelsAddCommand", () => {
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("runs post-setup hooks after writing config", async () => {
const afterAccountConfigWritten = vi.fn().mockResolvedValue(undefined);
const plugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "signal",
label: "Signal",
}),
setup: {
applyAccountConfig: ({ cfg, accountId, input }) => ({
...cfg,
channels: {
...cfg.channels,
signal: {
enabled: true,
accounts: {
[accountId]: {
signalNumber: input.signalNumber,
},
},
},
},
}),
afterAccountConfigWritten,
},
} as ChannelPlugin;
setActivePluginRegistry(createTestRegistry([{ pluginId: "signal", plugin, source: "test" }]));
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(
{ channel: "signal", account: "ops", signalNumber: "+15550001" },
runtime,
{ hasFlags: true },
);
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
expect(afterAccountConfigWritten).toHaveBeenCalledTimes(1);
expect(configMocks.writeConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
afterAccountConfigWritten.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
expect(afterAccountConfigWritten).toHaveBeenCalledWith({
previousCfg: baseConfigSnapshot.config,
cfg: expect.objectContaining({
channels: {
signal: {
enabled: true,
accounts: {
ops: {
signalNumber: "+15550001",
},
},
},
},
}),
accountId: "ops",
input: expect.objectContaining({
signalNumber: "+15550001",
}),
runtime,
});
});
it("keeps the saved config when a post-setup hook fails", async () => {
const afterAccountConfigWritten = vi.fn().mockRejectedValue(new Error("hook failed"));
const plugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "signal",
label: "Signal",
}),
setup: {
applyAccountConfig: ({ cfg, accountId, input }) => ({
...cfg,
channels: {
...cfg.channels,
signal: {
enabled: true,
accounts: {
[accountId]: {
signalNumber: input.signalNumber,
},
},
},
},
}),
afterAccountConfigWritten,
},
} as ChannelPlugin;
setActivePluginRegistry(createTestRegistry([{ pluginId: "signal", plugin, source: "test" }]));
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand(
{ channel: "signal", account: "ops", signalNumber: "+15550001" },
runtime,
{ hasFlags: true },
);
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
expect(runtime.exit).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
'Channel signal post-setup warning for "ops": hook failed',
);
});
});

View File

@ -11,6 +11,10 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
import { isCatalogChannelInstalled } from "../channel-setup/discovery.js";
import {
createChannelOnboardingPostWriteHookCollector,
runCollectedChannelOnboardingPostWriteHooks,
} from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
@ -55,6 +59,7 @@ export async function channelsAddCommand(
import("../onboard-channels.js"),
]);
const prompter = createClackPrompter();
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
let selection: ChannelChoice[] = [];
const accountIds: Partial<Record<ChannelChoice, string>> = {};
const resolvedPlugins = new Map<ChannelChoice, ChannelSetupPlugin>();
@ -62,6 +67,9 @@ export async function channelsAddCommand(
let nextConfig = await setupChannels(cfg, runtime, prompter, {
allowDisable: false,
allowSignalInstall: true,
onPostWriteHook: (hook) => {
postWriteHooks.collect(hook);
},
promptAccountIds: true,
onSelection: (value) => {
selection = value;
@ -170,6 +178,11 @@ export async function channelsAddCommand(
}
await writeConfigFile(nextConfig);
await runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: nextConfig,
runtime,
});
await prompter.outro("Channels updated.");
return;
}
@ -337,4 +350,24 @@ export async function channelsAddCommand(
await writeConfigFile(nextConfig);
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
if (plugin.setup.afterAccountConfigWritten) {
await runCollectedChannelOnboardingPostWriteHooks({
hooks: [
{
channel,
accountId,
run: async ({ cfg: writtenCfg, runtime: hookRuntime }) =>
await plugin.setup.afterAccountConfigWritten?.({
previousCfg: cfg,
cfg: writtenCfg,
accountId,
input,
runtime: hookRuntime,
}),
},
],
cfg: nextConfig,
runtime,
});
}
}

View File

@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => ({
waitForGatewayReachable: vi.fn(),
resolveControlUiLinks: vi.fn(),
summarizeExistingConfig: vi.fn(),
noteChannelStatus: vi.fn(),
setupChannels: vi.fn(),
}));
vi.mock("@clack/prompts", () => ({
@ -91,8 +93,10 @@ vi.mock("./onboard-skills.js", () => ({
setupSkills: vi.fn(),
}));
vi.mock("./onboard-channels.js", () => ({
setupChannels: vi.fn(),
vi.mock("./onboard-channels.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-channels.js")>()),
noteChannelStatus: mocks.noteChannelStatus,
setupChannels: mocks.setupChannels,
}));
import { WizardCancelledError } from "../wizard/prompts.js";
@ -100,6 +104,8 @@ import { runConfigureWizard } from "./configure.wizard.js";
describe("runConfigureWizard", () => {
it("persists gateway.mode=local when only the run mode is selected", async () => {
mocks.noteChannelStatus.mockReset();
mocks.setupChannels.mockReset();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
@ -110,6 +116,7 @@ describe("runConfigureWizard", () => {
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.createClackPrompter.mockReturnValue({});
const selectQueue = ["local", "__continue"];
@ -136,6 +143,8 @@ describe("runConfigureWizard", () => {
});
it("exits with code 1 when configure wizard is cancelled", async () => {
mocks.noteChannelStatus.mockReset();
mocks.setupChannels.mockReset();
const runtime = {
log: vi.fn(),
error: vi.fn(),
@ -158,4 +167,75 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("runs channel post-write hooks after persisting channel changes", async () => {
const hookRun = vi.fn().mockResolvedValue(undefined);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
mocks.noteChannelStatus.mockReset();
mocks.noteChannelStatus.mockResolvedValue(undefined);
mocks.setupChannels.mockReset();
mocks.setupChannels.mockImplementation(async (cfg, _runtime, _prompter, options) => {
options?.onPostWriteHook?.({
channel: "telegram",
accountId: "acct-1",
run: hookRun,
});
return {
...cfg,
channels: {
...cfg.channels,
telegram: { botToken: "new-token" },
},
};
});
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.createClackPrompter.mockReturnValue({});
const selectQueue = ["local", "configure"];
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackText.mockResolvedValue("");
mocks.clackConfirm.mockResolvedValue(false);
await runConfigureWizard({ command: "configure", sections: ["channels"] }, runtime);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({ mode: "local" }),
channels: {
telegram: {
botToken: "new-token",
},
},
}),
);
expect(hookRun).toHaveBeenCalledWith({
cfg: expect.objectContaining({
channels: {
telegram: {
botToken: "new-token",
},
},
}),
runtime,
});
expect(mocks.writeConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
hookRun.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
});

View File

@ -31,7 +31,12 @@ import {
} from "./configure.shared.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { healthCommand } from "./health.js";
import { noteChannelStatus, setupChannels } from "./onboard-channels.js";
import {
createChannelOnboardingPostWriteHookCollector,
noteChannelStatus,
runCollectedChannelOnboardingPostWriteHooks,
setupChannels,
} from "./onboard-channels.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
@ -429,6 +434,7 @@ export async function runConfigureWizard(
baseConfig.agents?.defaults?.workspace ??
DEFAULT_WORKSPACE;
let gatewayPort = resolveGatewayPort(baseConfig);
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
const persistConfig = async () => {
nextConfig = applyWizardMetadata(nextConfig, {
@ -436,6 +442,11 @@ export async function runConfigureWizard(
mode,
});
await writeConfigFile(nextConfig);
await runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: nextConfig,
runtime,
});
logConfigUpdated(runtime);
};
@ -494,6 +505,9 @@ export async function runConfigureWizard(
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
allowDisable: true,
allowSignalInstall: true,
onPostWriteHook: (hook) => {
postWriteHooks.collect(hook);
},
skipConfirm: true,
skipStatusNote: true,
});

View File

@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
patchChannelOnboardingAdapter,
setDefaultChannelPluginRegistryForTests,
} from "./channel-test-helpers.js";
import {
createChannelOnboardingPostWriteHookCollector,
runCollectedChannelOnboardingPostWriteHooks,
setupChannels,
} from "./onboard-channels.js";
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return createWizardPrompter(
{
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
},
{ defaultSelect: "__done__" },
);
}
function createQuickstartTelegramSelect() {
return vi.fn(async ({ message }: { message: string }) => {
if (message === "Select channel (QuickStart)") {
return "telegram";
}
return "__done__";
});
}
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
return createPrompter({
select,
multiselect: vi.fn(async () => {
throw new Error("unexpected multiselect");
}),
text: vi.fn(async ({ message }: { message: string }) => {
throw new Error(`unexpected text prompt: ${message}`);
}) as unknown as WizardPrompter["text"],
});
}
describe("setupChannels post-write hooks", () => {
beforeEach(() => {
setDefaultChannelPluginRegistryForTests();
});
it("collects onboarding post-write hooks and runs them against the final config", async () => {
const select = createQuickstartTelegramSelect();
const afterConfigWritten = vi.fn(async () => {});
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 restore = patchChannelOnboardingAdapter("telegram", {
configureInteractive,
afterConfigWritten,
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
channel: "telegram",
configured: Boolean(cfg.channels?.telegram?.botToken),
statusLines: [],
})),
});
const prompter = createUnexpectedQuickstartPrompter(
select as unknown as WizardPrompter["select"],
);
const collector = createChannelOnboardingPostWriteHookCollector();
const runtime = createExitThrowingRuntime();
try {
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
quickstartDefaults: true,
skipConfirm: true,
onPostWriteHook: (hook) => {
collector.collect(hook);
},
});
expect(afterConfigWritten).not.toHaveBeenCalled();
await runCollectedChannelOnboardingPostWriteHooks({
hooks: collector.drain(),
cfg,
runtime,
});
expect(afterConfigWritten).toHaveBeenCalledWith({
previousCfg: {} as OpenClawConfig,
cfg,
accountId: "acct-1",
runtime,
});
} finally {
restore();
}
});
it("logs onboarding post-write hook failures without aborting", async () => {
const runtime = createExitThrowingRuntime();
await runCollectedChannelOnboardingPostWriteHooks({
hooks: [
{
channel: "telegram",
accountId: "acct-1",
run: async () => {
throw new Error("hook failed");
},
},
],
cfg: {} as OpenClawConfig,
runtime,
});
expect(runtime.error).toHaveBeenCalledWith(
'Channel telegram post-setup warning for "acct-1": hook failed',
);
expect(runtime.exit).not.toHaveBeenCalled();
});
});

View File

@ -32,6 +32,7 @@ import type {
ChannelSetupDmPolicy,
ChannelSetupResult,
ChannelSetupStatus,
ChannelOnboardingPostWriteHook,
SetupChannelsOptions,
} from "./channel-setup/types.js";
import type { ChannelChoice } from "./onboard-types.js";
@ -46,6 +47,37 @@ type ChannelStatusSummary = {
statusLines: string[];
};
export function createChannelOnboardingPostWriteHookCollector() {
const hooks = new Map<string, ChannelOnboardingPostWriteHook>();
return {
collect(hook: ChannelOnboardingPostWriteHook) {
hooks.set(`${hook.channel}:${hook.accountId}`, hook);
},
drain(): ChannelOnboardingPostWriteHook[] {
const next = [...hooks.values()];
hooks.clear();
return next;
},
};
}
export async function runCollectedChannelOnboardingPostWriteHooks(params: {
hooks: ChannelOnboardingPostWriteHook[];
cfg: OpenClawConfig;
runtime: RuntimeEnv;
}): Promise<void> {
for (const hook of params.hooks) {
try {
await hook.run({ cfg: params.cfg, runtime: params.runtime });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
params.runtime.error(
`Channel ${hook.channel} post-setup warning for "${hook.accountId}": ${message}`,
);
}
}
}
function formatAccountLabel(accountId: string): string {
return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId;
}
@ -608,9 +640,24 @@ export async function setupChannels(
};
const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => {
const previousCfg = next;
next = result.cfg;
const adapter = getChannelOnboardingAdapter(channel);
if (result.accountId) {
recordAccount(channel, result.accountId);
if (adapter?.afterConfigWritten) {
options?.onPostWriteHook?.({
channel,
accountId: result.accountId,
run: async ({ cfg, runtime }) =>
await adapter.afterConfigWritten?.({
previousCfg,
cfg,
accountId: result.accountId!,
runtime,
}),
});
}
}
addSelection(channel);
await refreshStatus(channel);