diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ddd39f11c..0bf27bdad67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Telegram: persist polling updates through restart replay so queued same-topic messages resume in order instead of losing context after a gateway restart. (#82256) Thanks @VACInc. - Gateway/Gmail: abort in-flight Gmail watcher startup and hot-reload restarts before shutdown so reloads cannot spawn `gog serve` after the Gateway is closing. Thanks @frankekn. - Agents/Codex: fall back to the embedded PI runner when OpenAI's implicit Codex harness preference cannot find a registered Codex plugin, preventing OpenAI-compatible gateway requests from failing with an unregistered harness error. Fixes #82437. +- CLI/channels: install missing externalized same-id channel plugins during `channels add --channel `, so recovery for WhatsApp and other externalized stock channels does not require a separate `plugins enable` step. Fixes #82533. - MCP plugin tools: forward host MCP `tools/call` `AbortSignal` through `createPluginToolsMcpHandlers().callTool` into plugin `tool.execute`, so host cancellation actually cancels in-flight plugin tool calls instead of letting them run to completion. Fixes #82424. (#82443) Thanks @joshavant. - Plugins: accept deprecated `api.on("deactivate")` registrations as a dated compatibility alias for `gateway_stop`, so external plugin cleanup handlers run on Gateway shutdown while authors get migration guidance. - Media: ignore image MIME and filename hints when bytes sniff as generic containers, so zip/octet-stream payloads mislabeled as images do not become local image media or keep image file extensions when staged. diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 7a331e01c47..3568abe7934 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -708,6 +708,84 @@ describe("channelsAddCommand", () => { expectExternalChatEnabledConfigWrite(); }); + it("installs same-id externalized channel plugins before non-interactive add", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "whatsapp", + pluginId: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp channel", + }, + install: { + npmSpec: "@openclaw/whatsapp", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([ + { + pluginId: "whatsapp", + plugin: { + ...createChannelTestPluginBase({ + id: "whatsapp", + label: "WhatsApp", + docsPath: "/channels/whatsapp", + }), + setup: { + applyAccountConfig: ({ cfg, accountId, input }: ApplyAccountConfigParams) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + enabled: true, + accounts: { + [accountId]: { + enabled: true, + authDir: input.authDir, + }, + }, + }, + }, + }), + }, + }, + source: "test", + }, + ]), + ); + + await channelsAddCommand( + { + channel: "whatsapp", + account: "work", + authDir: "/tmp/openclaw-wa-auth", + }, + runtime, + { hasFlags: true }, + ); + + expect(installCall().entry).toBe(catalogEntry); + expect(installCall().promptInstall).toBe(false); + expect(snapshotCall().pluginId).toBe("whatsapp"); + expect(writtenChannel("whatsapp")).toEqual({ + enabled: true, + accounts: { + work: { + enabled: true, + authDir: "/tmp/openclaw-wa-auth", + }, + }, + }); + expect(refreshCall().reason).toBe("source-changed"); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("uses setup-entry snapshots when an already loaded channel plugin has no setup adapter", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry( diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 280b07cd387..b533056320a 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -303,7 +303,7 @@ async function channelsAddCommandImpl( const rawChannel = opts.channel ?? ""; let channel = normalizeChannelId(rawChannel); - let catalogEntry = channel ? undefined : await resolveCatalogChannelEntry(rawChannel, nextConfig); + let catalogEntry = await resolveCatalogChannelEntry(rawChannel, nextConfig); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); // May load a scoped plugin when the channel is not already registered. @@ -333,10 +333,14 @@ async function channelsAddCommandImpl( ); }; - if (!channel && catalogEntry) { + if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); const { isCatalogChannelInstalled } = await import("../channel-setup/discovery.js"); + const registeredPlugin = channel ? getLoadedChannelPlugin(channel) : undefined; + const bundledSetupPlugin = channel ? getBundledChannelSetupPlugin(channel) : undefined; if ( + !registeredPlugin && + !bundledSetupPlugin && !isCatalogChannelInstalled({ cfg: nextConfig, entry: catalogEntry, @@ -363,7 +367,7 @@ async function channelsAddCommandImpl( ...(result.pluginId ? { pluginId: result.pluginId } : {}), }; } - channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); + channel ??= normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); } if (!channel) { diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index c533bc52564..27974d99efe 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -535,18 +535,6 @@ describe("doctor repair sequencing", () => { ], warnings: [], }); - mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({ - config: { - ...cfg, - plugins: { - ...cfg.plugins, - allow: [], - entries: {}, - }, - }, - changes: ["- plugins.entries: removed 1 stale plugin entry (brave)"], - })); - const result = await runDoctorRepairSequence({ state: { cfg: { @@ -610,18 +598,30 @@ describe("doctor repair sequencing", () => { warnings: [ 'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed', ], + failedPluginIds: ["brave"], }); - mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({ - config: { - ...cfg, - plugins: { - ...cfg.plugins, - allow: [], - entries: {}, - }, + mocks.maybeRepairStalePluginConfig.mockImplementationOnce( + ( + cfg: OpenClawConfig, + _env: NodeJS.ProcessEnv | undefined, + params: { preservePluginIds?: string[] }, + ) => { + expect(params.preservePluginIds).toEqual(["brave"]); + return { + config: { + ...cfg, + plugins: { + ...cfg.plugins, + allow: ["brave"], + entries: { + brave: cfg.plugins?.entries?.brave, + }, + }, + }, + changes: ["plugins.entries: removed 1 stale plugin entry (old-plugin)"], + }; }, - changes: ["plugins.entries: removed 1 stale plugin entry (brave)"], - })); + ); const result = await runDoctorRepairSequence({ state: { @@ -641,6 +641,9 @@ describe("doctor repair sequencing", () => { }, }, }, + "old-plugin": { + enabled: true, + }, }, }, } as OpenClawConfig, @@ -660,6 +663,9 @@ describe("doctor repair sequencing", () => { }, }, }, + "old-plugin": { + enabled: true, + }, }, }, } as OpenClawConfig, @@ -669,12 +675,68 @@ describe("doctor repair sequencing", () => { doctorFixCommand: "openclaw doctor --fix", }); - expect(mocks.maybeRepairStalePluginConfig).not.toHaveBeenCalled(); expect(result.state.candidate.plugins?.allow).toEqual(["brave"]); expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true); - expect(result.state.pendingChanges).toBe(false); + expect(result.state.candidate.plugins?.entries?.["old-plugin"]).toBeUndefined(); + expect(result.state.pendingChanges).toBe(true); + expect(result.changeNotes).toContain( + "plugins.entries: removed 1 stale plugin entry (old-plugin)", + ); expect(result.warningNotes).toStrictEqual([ 'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed', ]); }); + + it("preserves configured channels when their install repair fails", async () => { + mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({ + changes: [], + warnings: [ + 'Failed to install missing configured channel plugin "whatsapp" from @openclaw/whatsapp: package install failed', + ], + failedPluginIds: ["whatsapp"], + }); + mocks.maybeRepairStalePluginConfig.mockImplementationOnce( + ( + cfg: OpenClawConfig, + _env: NodeJS.ProcessEnv | undefined, + params: { preservePluginIds?: string[] }, + ) => { + expect(params.preservePluginIds).toEqual(["whatsapp"]); + return { + config: cfg, + changes: [], + }; + }, + ); + + const result = await runDoctorRepairSequence({ + state: { + cfg: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + } as OpenClawConfig, + candidate: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + } as OpenClawConfig, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(mocks.maybeRepairStalePluginConfig).toHaveBeenCalledOnce(); + expect(result.state.candidate.channels?.whatsapp).toEqual({ + allowFrom: ["+15555550123"], + }); + expect(result.warningNotes).toStrictEqual([ + 'Failed to install missing configured channel plugin "whatsapp" from @openclaw/whatsapp: package install failed', + ]); + }); }); diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 7d842c64853..236af47765d 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -99,10 +99,15 @@ export async function runDoctorRepairSequence(params: { if (missingConfiguredPluginInstallRepair.warnings.length > 0) { warningNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.warnings)); } - const missingConfiguredPluginInstallFailed = - missingConfiguredPluginInstallRepair.warnings.length > 0; - if (!isUpdatePackageSwapInProgress(env) && !missingConfiguredPluginInstallFailed) { - applyMutation(maybeRepairStalePluginConfig(state.candidate, env)); + const failedPluginIds = missingConfiguredPluginInstallRepair.failedPluginIds ?? []; + const hasUnscopedInstallRepairWarnings = + missingConfiguredPluginInstallRepair.warnings.length > 0 && failedPluginIds.length === 0; + if (!isUpdatePackageSwapInProgress(env) && !hasUnscopedInstallRepairWarnings) { + applyMutation( + maybeRepairStalePluginConfig(state.candidate, env, { + preservePluginIds: failedPluginIds, + }), + ); } applyMutation(maybeRepairInvalidPluginConfig(state.candidate)); applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate)); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 23c15e73522..682ab8685c6 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -2537,6 +2537,7 @@ describe("repairMissingConfiguredPluginInstalls", () => { warnings: [ `Failed to install missing configured plugin "brave" from ${expectedNpmInstallSpec("@openclaw/brave-plugin")}: network unavailable`, ], + failedPluginIds: ["brave"], records, }); }); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 03a3ae15f18..40f6f8a4a64 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -767,6 +767,7 @@ async function installCandidate(params: { records: Record; changes: string[]; warnings: string[]; + failedPluginId?: string; }> { const { candidate } = params; const extensionsDir = resolveDefaultPluginExtensionsDir(); @@ -815,6 +816,7 @@ async function installCandidate(params: { warnings: [ `Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`, ], + failedPluginId: candidate.pluginId, }; } changes.push( @@ -828,6 +830,7 @@ async function installCandidate(params: { warnings: [ `Failed to install missing configured plugin "${candidate.pluginId}": missing npm spec.`, ], + failedPluginId: candidate.pluginId, }; } const result = await installPluginFromNpmSpec({ @@ -847,6 +850,7 @@ async function installCandidate(params: { warnings: [ `Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`, ], + failedPluginId: candidate.pluginId, }; } const pluginId = result.pluginId; @@ -873,6 +877,7 @@ async function installCandidate(params: { export type RepairMissingPluginInstallsResult = { changes: string[]; warnings: string[]; + failedPluginIds?: string[]; /** * The full install-record map after repair. Equal to the input * `baselineRecords` (or the disk-loaded records when no baseline was @@ -1001,6 +1006,7 @@ async function repairMissingPluginInstalls(params: { const officialReplacementPluginIds = new Set(officialReplacementInstallCandidates.keys()); const changes: string[] = []; const warnings: string[] = []; + const failedPluginIds = new Set(); const deferredPluginIds = new Set(); const updateChannel = resolveRegistryUpdateChannel({ configChannel: normalizeUpdateChannel(params.cfg.update?.channel), @@ -1091,6 +1097,7 @@ async function repairMissingPluginInstalls(params: { ); } else if (outcome.status === "error") { warnings.push(outcome.message); + failedPluginIds.add(outcome.pluginId); } } nextRecords = updateResult.config.plugins?.installs ?? nextRecords; @@ -1186,6 +1193,9 @@ async function repairMissingPluginInstalls(params: { nextRecords = installed.records; changes.push(...installed.changes); warnings.push(...installed.warnings); + if (installed.failedPluginId) { + failedPluginIds.add(installed.failedPluginId); + } } if (nextRecords !== records) { @@ -1198,5 +1208,16 @@ async function repairMissingPluginInstalls(params: { // a stale snapshot. await writePersistedInstalledPluginIndexInstallRecords(nextRecords, { env }); } - return { changes, warnings, records: nextRecords }; + return { + changes, + warnings, + ...(failedPluginIds.size > 0 + ? { + failedPluginIds: [...failedPluginIds].toSorted((left, right) => + left.localeCompare(right), + ), + } + : {}), + records: nextRecords, + }; } diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index e4655d5b9df..1bc05016595 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -401,6 +401,38 @@ describe("configured plugin install release step", () => { }); }); + it("repairs same-id externalized channel installs from channel config after prior update writes", async () => { + mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ + changes: ['Installed missing configured channel plugin "whatsapp".'], + warnings: [], + }); + + const { maybeRunConfiguredPluginInstallReleaseStep } = + await import("./release-configured-plugin-installs.js"); + const result = await maybeRunConfiguredPluginInstallReleaseStep({ + cfg: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + currentVersion: "2026.5.12", + touchedVersion: "2026.5.12", + env: {}, + }); + + const repairCall = readOnlyMissingPluginInstallRepairCall(); + expect(repairCall.pluginIds).toEqual([]); + expect(repairCall.channelIds).toEqual(["whatsapp"]); + expect(result).toEqual({ + changes: ['Installed missing configured channel plugin "whatsapp".'], + warnings: [], + completed: true, + touchedConfig: false, + }); + }); + it("does not touch config when install repair warns", async () => { mocks.detectPluginAutoEnableCandidates.mockReturnValue([ { pluginId: "matrix", kind: "channel-configured", channelId: "matrix" }, diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 0429745e5f7..29cc30b6d92 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -325,6 +325,7 @@ export function collectStalePluginConfigWarnings(params: { export function maybeRepairStalePluginConfig( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, + params?: { preservePluginIds?: Iterable }, ): { config: OpenClawConfig; changes: string[]; @@ -337,7 +338,14 @@ export function maybeRepairStalePluginConfig( return { config: cfg, changes: [] }; } - const hits = scanStalePluginConfigWithState(cfg, registryState); + const preservePluginIds = new Set( + [...(params?.preservePluginIds ?? [])] + .map((pluginId) => normalizePluginId(pluginId)) + .filter((pluginId): pluginId is string => Boolean(pluginId)), + ); + const hits = scanStalePluginConfigWithState(cfg, registryState).filter( + (hit) => !preservePluginIds.has(normalizePluginId(hit.pluginId)), + ); if (hits.length === 0) { return { config: cfg, changes: [] }; }