From cba14062f6c99ba8c8701f060e656cd7a64f2106 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sat, 14 Mar 2026 23:32:24 +0000 Subject: [PATCH] Channels: run post-write setup hooks --- extensions/matrix/src/channel.setup.test.ts | 220 ++++++++++++++++++ extensions/matrix/src/channel.ts | 9 + extensions/matrix/src/cli.test.ts | 13 +- extensions/matrix/src/cli.ts | 30 +-- .../matrix/src/matrix/account-config.ts | 23 ++ extensions/matrix/src/onboarding.ts | 9 + extensions/matrix/src/setup-bootstrap.ts | 93 ++++++++ src/channels/plugins/setup-wizard-types.ts | 15 ++ src/channels/plugins/types.adapters.ts | 7 + src/commands/agents.add.test.ts | 71 ++++++ src/commands/agents.commands.add.ts | 15 +- src/commands/channel-test-helpers.ts | 10 +- src/commands/channels.add.test.ts | 103 ++++++++ src/commands/channels/add.ts | 33 +++ src/commands/configure.wizard.test.ts | 84 ++++++- src/commands/configure.wizard.ts | 16 +- .../onboard-channels.post-write.test.ts | 129 ++++++++++ src/commands/onboard-channels.ts | 47 ++++ 18 files changed, 897 insertions(+), 30 deletions(-) create mode 100644 extensions/matrix/src/channel.setup.test.ts create mode 100644 extensions/matrix/src/setup-bootstrap.ts create mode 100644 src/commands/onboard-channels.post-write.test.ts diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts new file mode 100644 index 00000000000..d515f58d228 --- /dev/null +++ b/extensions/matrix/src/channel.setup.test.ts @@ -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; + } + } + } + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 66af8443b68..64391a27e61 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -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 = { initialSyncLimit: input.initialSyncLimit, }); }, + afterAccountConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => { + await runMatrixSetupBootstrapAfterConfigWrite({ + previousCfg: previousCfg as CoreConfig, + cfg: cfg as CoreConfig, + accountId, + runtime, + }); + }, }, outbound: matrixOutbound, status: { diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 9d15c022665..a0729583b2d 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -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, diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index ac81b4a5efd..3b408b171e5 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -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(); diff --git a/extensions/matrix/src/matrix/account-config.ts b/extensions/matrix/src/matrix/account-config.ts index 140dfb11d53..8f8c65b428e 100644 --- a/extensions/matrix/src/matrix/account-config.ts +++ b/extensions/matrix/src/matrix/account-config.ts @@ -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" + ); +} diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 908b8865658..b407195d775 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -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), diff --git a/extensions/matrix/src/setup-bootstrap.ts b/extensions/matrix/src/setup-bootstrap.ts new file mode 100644 index 00000000000..6c1304de498 --- /dev/null +++ b/extensions/matrix/src/setup-bootstrap.ts @@ -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 { + 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 { + 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"}`, + ); +} diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index 6c47ffc3712..f5939757626 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -13,6 +13,7 @@ export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; onSelection?: (selection: ChannelId[]) => void; + onPostWriteHook?: (hook: ChannelOnboardingPostWriteHook) => void; accountIds?: Partial>; 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; +}; + export type ChannelSetupResult = { cfg: OpenClawConfig; accountId?: string; @@ -104,6 +118,7 @@ export type ChannelSetupWizardAdapter = { configureWhenConfigured?: ( ctx: ChannelSetupInteractiveContext, ) => Promise; + afterConfigWritten?: (ctx: ChannelOnboardingPostWriteContext) => Promise | void; dmPolicy?: ChannelSetupDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index d0744761389..14a7ab10b8e 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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; validateInput?: (params: { cfg: OpenClawConfig; accountId: string; diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 56184eb5849..2ed6a0c5757 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -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()), + setupChannels: setupChannelsMock, +})); + +vi.mock("./onboard-helpers.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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, + ); + }); }); diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 3d34ada1c5c..f686dd11805 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -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> = {}; + 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), diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 38f3621146f..329c0605e22 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -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!; } diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index ad5d323f427..99fa5bb7ce7 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -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', + ); + }); }); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 4f8b3e8133c..03aa841edd5 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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> = {}; const resolvedPlugins = new Map(); @@ -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, + }); + } } diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 034a3fdf505..fcbc354fdc5 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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()), + 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, + ); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 78cd0716376..19325c936a7 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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, }); diff --git a/src/commands/onboard-channels.post-write.test.ts b/src/commands/onboard-channels.post-write.test.ts new file mode 100644 index 00000000000..f96dd276e22 --- /dev/null +++ b/src/commands/onboard-channels.post-write.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 60c89e4b374..a476077e07b 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -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(); + 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 { + 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);