fix(channels): install externalized same-id adds

This commit is contained in:
Peter Steinberger
2026-05-16 12:37:43 +01:00
parent 5a14b1c5c5
commit 9558b2c222
9 changed files with 245 additions and 33 deletions

View File

@@ -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 <id>`, 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.

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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',
]);
});
});

View File

@@ -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));

View File

@@ -2537,6 +2537,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
warnings: [
`Failed to install missing configured plugin "brave" from ${expectedNpmInstallSpec("@openclaw/brave-plugin")}: network unavailable`,
],
failedPluginIds: ["brave"],
records,
});
});

View File

@@ -767,6 +767,7 @@ async function installCandidate(params: {
records: Record<string, PluginInstallRecord>;
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<string>();
const deferredPluginIds = new Set<string>();
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,
};
}

View File

@@ -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" },

View File

@@ -325,6 +325,7 @@ export function collectStalePluginConfigWarnings(params: {
export function maybeRepairStalePluginConfig(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
params?: { preservePluginIds?: Iterable<string> },
): {
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: [] };
}