mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:14:46 +00:00
fix(channels): install externalized same-id adds
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -2537,6 +2537,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
|
||||
warnings: [
|
||||
`Failed to install missing configured plugin "brave" from ${expectedNpmInstallSpec("@openclaw/brave-plugin")}: network unavailable`,
|
||||
],
|
||||
failedPluginIds: ["brave"],
|
||||
records,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user