fix(channels): repair Windows plugin peer links and restart Gateway when link repair fails (#1242)

This commit is contained in:
paisley
2026-08-13 16:34:07 +08:00
committed by GitHub
parent b780c46be3
commit db82834c1c
9 changed files with 236 additions and 51 deletions
+19 -6
View File
@@ -70,6 +70,20 @@ function isBaseHashConflict(error: unknown): boolean {
return /config changed since last load; re-run config\.get and retry/i.test(message);
}
function isConfigSetResponseLost(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes('RPC timeout: config.set')
|| message.includes('Gateway stopped')
|| message.includes('Gateway not connected')
|| message.includes('Gateway service restart')
|| message.includes('Failed to send RPC request:');
}
async function acceptPersistedConfigSetCommitIfMatched(config: OpenClawConfig): Promise<boolean> {
const persisted = await readFileConfig(resolveOpenClawConfigPath());
return isDeepStrictEqual(persisted.config, config);
}
async function mutateRunningConfig(
manager: ConfigDeliveryGatewayManager,
mutator: OpenClawConfigMutator,
@@ -94,12 +108,11 @@ async function mutateRunningConfig(
if (attempt === 0 && isBaseHashConflict(error)) continue;
// config.set may durably replace the file and then close the socket with
// code 1012 before its RPC response reaches ClawX. If the manager has
// already left running state, verify that exact commit instead of
// reporting a false save failure or replaying the mutation out of band.
if (manager.getStatus().state !== 'running') {
const persisted = await readFileConfig(resolveOpenClawConfigPath());
if (isDeepStrictEqual(persisted.config, config)) return true;
// code 1012 before its RPC response reaches ClawX. Reconnect can restore
// running state before the RPC timeout fires, so verify the persisted
// snapshot whenever the response was lost instead of only while stopped.
if (manager.getStatus().state !== 'running' || isConfigSetResponseLost(error)) {
if (await acceptPersistedConfigSetCommitIfMatched(config)) return true;
}
throw error;
}
+5
View File
@@ -1074,6 +1074,11 @@ export class GatewayManager extends EventEmitter {
this.connectionMonitor.clear();
this.recordSocketClose(closeCode);
this.diagnostics.consecutiveHeartbeatMisses = 0;
if (closeCode === 1012) {
for (const id of [...this.pendingRequests.keys()]) {
rejectPendingGatewayRequest(this.pendingRequests, id, new Error('Gateway service restart'));
}
}
if (this.status.state === 'running') {
this.setStatus({ state: 'stopped' });
// On Windows, skip reconnect from WS close. The Gateway is a local
+17 -7
View File
@@ -34,6 +34,7 @@ import {
ensureWeChatPluginInstalled,
ensureWeComPluginInstalled,
ensureWhatsAppPluginInstalled,
type PluginInstallResult,
} from '../utils/plugin-install';
import {
computeChannelRuntimeStatus,
@@ -967,7 +968,7 @@ function emitChannelEvent(
const CHANNEL_PLUGIN_INSTALLERS: Record<
string,
() => MaybePromise<{ installed: boolean; warning?: string }>
() => MaybePromise<PluginInstallResult>
> = {
dingtalk: ensureDingTalkPluginInstalled,
wecom: ensureWeComPluginInstalled,
@@ -990,8 +991,11 @@ function shouldRestartRunningGateway(ctx: ChannelsApiContext, storedChannelType:
function scheduleGatewayRestartForPluginChannel(
ctx: ChannelsApiContext,
storedChannelType: string,
reason: 'noChange' | 'peerLinkRepairFailed' = 'noChange',
): void {
logger.info(`[channels.saveConfig] scheduling Gateway restart to activate plugin channel=${storedChannelType}`);
logger.info(
`[channels.saveConfig] scheduling Gateway restart to activate plugin channel=${storedChannelType} reason=${reason}`,
);
// The config and scoped binding are already committed. Let the host request
// return while the guarded lifecycle path performs stop/start/readiness.
// GatewayManager owns error logging, status propagation, and restart
@@ -1047,13 +1051,14 @@ async function awaitWeChatQrLogin(
}
}
async function ensureChannelPluginInstalled(storedChannelType: string): Promise<void> {
async function ensureChannelPluginInstalled(storedChannelType: string): Promise<{ peerLinkOk: boolean }> {
const install = CHANNEL_PLUGIN_INSTALLERS[storedChannelType];
if (!install) return;
if (!install) return { peerLinkOk: true };
const result = await install();
if (!result.installed) {
throw new Error(result.warning || `${toUiChannelType(storedChannelType)} plugin install failed`);
}
return { peerLinkOk: result.peerLinkOk !== false };
}
export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceRegistry['channels'] {
@@ -1131,24 +1136,29 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
const storedChannelType = resolveStoredChannelType(channelType);
const restartGateway = shouldRestartRunningGateway(ctx, storedChannelType);
const [, existingValues] = await Promise.all([
const [installResult, existingValues] = await Promise.all([
ensureChannelPluginInstalled(storedChannelType),
getChannelFormValues(channelType, accountId),
]);
if (isSameConfigValues(existingValues, config)) {
await ensureScopedChannelBinding(channelType, accountId);
if (restartGateway) {
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType);
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType, 'noChange');
}
return { success: true, noChange: true, ...(restartGateway ? { activationPending: true } : {}) };
}
await saveChannelConfig(channelType, config, accountId);
await ensureScopedChannelBinding(channelType, accountId);
if (restartGateway && !installResult.peerLinkOk) {
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType, 'peerLinkRepairFailed');
return { success: true, activationPending: true };
}
// A changed running config is delivered through config.set, whose native
// reload activates the plugin. Scheduling another full restart here races
// that code-1012 reload and can trip OpenClaw's restart-loop breaker.
// Keep the explicit restart above only for no-change retries, where no
// config.set reload occurs but a newly copied plugin may still need discovery.
// config.set reload occurs but a newly copied plugin may still need discovery,
// and when OpenClaw peer link repair failed after plugin install.
return { success: true, ...(restartGateway ? { activationPending: true } : {}) };
},
setEnabled: async (payload) => {
+60 -30
View File
@@ -7,7 +7,7 @@
*/
import { app } from 'electron';
import path from 'node:path';
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync } from 'node:fs';
import { existsSync, cpSync, copyFileSync, statSync, lstatSync, mkdirSync, readFileSync, readlinkSync, writeFileSync, readdirSync, realpathSync, symlinkSync, unlinkSync } from 'node:fs';
import { readdir, stat, copyFile, mkdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
@@ -417,6 +417,34 @@ function canonicalComparablePath(filePath: string): string {
return process.platform === 'win32' ? withoutLongPathPrefix.toLowerCase() : withoutLongPathPrefix;
}
function resolveSymlinkTarget(linkPath: string, target: string): string {
return path.isAbsolute(target) ? target : path.resolve(path.dirname(linkPath), target);
}
function openClawPeerLinkPointsTo(linkPath: string, openclawDir: string): boolean {
try {
const stat = lstatSync(fsPath(linkPath));
if (stat.isSymbolicLink()) {
const target = readlinkSync(fsPath(linkPath));
const resolvedTarget = resolveSymlinkTarget(linkPath, target);
return canonicalComparablePath(resolvedTarget) === canonicalComparablePath(openclawDir);
}
if (stat.isDirectory()) {
try {
const packageJson = JSON.parse(readFileSync(fsPath(join(linkPath, 'package.json')), 'utf-8')) as { name?: unknown };
if (packageJson.name === 'openclaw') {
return canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir);
}
} catch {
return false;
}
}
} catch {
return false;
}
return false;
}
/**
* Materialized mirrors live outside the bundled OpenClaw package tree, so
* Node's normal package lookup cannot resolve their declared `openclaw` peer.
@@ -458,12 +486,8 @@ export function repairPluginOpenClawPeerLink(
return false;
}
try {
if (canonicalComparablePath(linkPath) === canonicalComparablePath(openclawDir)) {
return true;
}
} catch {
// Fall through to lstat/creation for a missing or broken link.
if (openClawPeerLinkPointsTo(linkPath, openclawDir)) {
return true;
}
let existing: ReturnType<typeof lstatSync> | null = null;
@@ -495,8 +519,9 @@ export function repairPluginOpenClawPeerLink(
}
}
symlinkSync(openclawDir, fsPath(linkPath), 'junction');
if (canonicalComparablePath(linkPath) !== canonicalComparablePath(openclawDir)) {
const junctionTarget = path.resolve(openclawDir);
symlinkSync(fsPath(junctionTarget), fsPath(linkPath), 'junction');
if (!openClawPeerLinkPointsTo(linkPath, openclawDir)) {
logger.warn(`[plugin] OpenClaw peer link audit failed after creating ${linkPath}`);
return false;
}
@@ -723,28 +748,37 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
// ── Core install / upgrade logic ─────────────────────────────────────────────
export type PluginInstallResult = {
installed: boolean;
warning?: string;
peerLinkOk?: boolean;
};
export async function ensurePluginInstalled(
pluginDirName: string,
candidateSources: string[],
pluginLabel: string,
): Promise<{ installed: boolean; warning?: string }> {
): Promise<PluginInstallResult> {
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
const targetManifest = join(targetDir, 'openclaw.plugin.json');
const targetPkgJson = join(targetDir, 'package.json');
const sourceDir = candidateSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
async function finalizeInstalledMirror(): Promise<{ installed: true; peerLinkOk: boolean }> {
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true, peerLinkOk: repairPluginOpenClawPeerLink(targetDir) };
}
// If already installed, check whether an upgrade is available
if (existsSync(fsPath(targetManifest))) {
if (!sourceDir) {
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // no bundled source to compare, keep existing
return await finalizeInstalledMirror(); // no bundled source to compare, keep existing
}
const installedVersion = readPluginVersion(targetPkgJson);
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version or unable to compare
return await finalizeInstalledMirror(); // same version or unable to compare
}
// Version differs — fall through to overwrite install
logger.info(
@@ -767,9 +801,9 @@ export async function ensurePluginInstalled(
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
}
fixupPluginManifest(targetDir);
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
const installed = await finalizeInstalledMirror();
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
return { installed: true };
return installed;
} catch (error) {
const diagnostic = toErrorDiagnostic(error);
attempts.push({ attempt, ...diagnostic });
@@ -816,8 +850,7 @@ export async function ensurePluginInstalled(
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
fixupPluginManifest(targetDir);
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true };
return await finalizeInstalledMirror();
}
} catch (err) {
logger.warn(
@@ -834,8 +867,7 @@ export async function ensurePluginInstalled(
);
}
} else if (existsSync(fsPath(targetManifest))) {
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
return { installed: true }; // same version, already installed
return await finalizeInstalledMirror(); // same version, already installed
}
}
}
@@ -870,15 +902,15 @@ export function buildCandidateSources(pluginDirName: string): string[] {
// ── Per-channel plugin helpers ───────────────────────────────────────────────
export function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureDingTalkPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
}
export function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureWeComPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
}
export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureFeishuPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled(
'feishu-openclaw-plugin',
buildCandidateSources('feishu-openclaw-plugin'),
@@ -886,25 +918,23 @@ export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; war
);
}
export function ensureWeChatPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureWeChatPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
export function ensureDiscordPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureDiscordPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
}
export function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureQQBotPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
}
export function ensureWhatsAppPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureWhatsAppPluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
}
export function ensureClawXOpenAiImagePluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
export function ensureClawXOpenAiImagePluginInstalled(): Promise<PluginInstallResult> {
return ensurePluginInstalled(
'clawx-openai-image',
buildCandidateSources('clawx-openai-image'),
@@ -10,7 +10,7 @@ When channel plugin ownership changes between bundled OpenClaw extensions and ex
The ClawX channel configuration catalog is intentionally limited to `telegram`, `discord`, `whatsapp`, `wechat`, `dingtalk`, `feishu`, `wecom`, and `qqbot`. OpenClaw may report other channel ids, but the ClawX Channels page must not expose them as configurable or editable channel groups. Filtering an unsupported runtime channel is presentation-only and must not delete or rewrite that channel's underlying OpenClaw configuration.
Channel credentials and account maps must remain under `channels.<id>`; `plugins.entries.<id>` is activation metadata and must not contain ClawX-generated `accounts` or `defaultAccount` fields. Discord, WhatsApp, and QQBot are external plugins in the pinned OpenClaw runtime and must retain explicit `plugins.allow` and `{ enabled }` entries. Saving changed configuration for a supported external plugin channel while Gateway is running must use the coordinator-owned `config.set` reload without scheduling a second ClawX full restart. A no-change retry must still start the guarded full restart path after the scoped-binding commit so a newly copied or previously undiscovered plugin is loaded. Successful WeChat QR completion must likewise leave plugin activation on a single lifecycle path. The host save response may return while activation is still pending, provided it explicitly reports that state and failures are caught and surfaced through normal Gateway status/logging. If `config.set` durably commits before its response is lost to a native code-1012 reload, Main may verify that exact persisted config and treat the transaction as committed; it must not perform an out-of-band replay.
Channel credentials and account maps must remain under `channels.<id>`; `plugins.entries.<id>` is activation metadata and must not contain ClawX-generated `accounts` or `defaultAccount` fields. Discord, WhatsApp, and QQBot are external plugins in the pinned OpenClaw runtime and must retain explicit `plugins.allow` and `{ enabled }` entries. Saving changed configuration for a supported external plugin channel while Gateway is running must use the coordinator-owned `config.set` reload without scheduling a second ClawX full restart when OpenClaw peer link repair succeeds. When peer link repair fails after plugin install, Main must schedule the guarded full restart after the config commit instead of relying on the native reload alone. A no-change retry must still start the guarded full restart path after the scoped-binding commit so a newly copied or previously undiscovered plugin is loaded. Successful WeChat QR completion must likewise leave plugin activation on a single lifecycle path. The host save response may return while activation is still pending, provided it explicitly reports that state and failures are caught and surfaced through normal Gateway status/logging. If `config.set` durably commits before its response is lost to a native code-1012 reload, Main may verify that exact persisted config and treat the transaction as committed; it must not perform an out-of-band replay.
For Feishu/Lark specifically:
@@ -24,6 +24,7 @@ touchedAreas:
- electron/gateway/startup-orchestrator.ts
- electron/utils/channel-config.ts
- electron/utils/openclaw-auth.ts
- electron/utils/plugin-install.ts
- src/components/channels/ChannelConfigModal.tsx
- src/pages/Channels/index.tsx
- tests/unit/channel-config.test.ts
@@ -32,13 +33,14 @@ touchedAreas:
- tests/unit/gateway-config-delivery.test.ts
- tests/unit/gateway-startup-orchestrator.test.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/plugin-install.test.ts
- tests/unit/channels-page.test.tsx
- tests/e2e/channels-plugin-save.spec.ts
- tests/e2e/channels-supported-catalog.spec.ts
expectedUserBehavior:
- Saving a plugin-backed channel returns as soon as its configuration and scoped binding are durably committed instead of waiting for Gateway stop, startup, and readiness.
- The Channels page immediately reloads the committed local configuration and then converges to runtime connection state after the scheduled Gateway restart.
- Changed plugin configuration uses OpenClaw's native config reload without an additional ClawX full restart; no-change retries still use the guarded restart path when plugin discovery is required.
- Changed plugin configuration uses OpenClaw's native config reload without an additional ClawX full restart when OpenClaw peer link repair succeeds; a failed peer link repair schedules the guarded restart path after the config commit, and no-change retries still use that path when plugin discovery is required.
- A config commit whose acknowledgement is lost to native reload is verified from the durable config instead of being reported as a false save failure.
- A stale owned process that fails to recover from an in-process restart is terminated promptly and replaced instead of holding startup for the full cold-start retry budget.
- Restart failures remain visible through normal Gateway status and logging rather than becoming unhandled promise rejections.
@@ -60,11 +62,12 @@ requiredTests:
- tests/unit/host-services.test.ts
- tests/unit/gateway-config-delivery.test.ts
- tests/unit/gateway-startup-orchestrator.test.ts
- tests/unit/plugin-install.test.ts
- tests/unit/channels-page.test.tsx
- tests/e2e/channels-plugin-save.spec.ts
acceptance:
- The save response exposes when plugin activation is pending.
- A changed plugin config relies on the coordinator-owned config.set reload and does not schedule a redundant full restart.
- A changed plugin config relies on the coordinator-owned config.set reload without a redundant full restart when peer link repair succeeds, and schedules a guarded full restart after commit when peer link repair fails.
- A no-change plugin save starts a guarded Gateway restart only after the scoped binding commit completes.
- The save response does not await Gateway restart readiness.
- Immediate post-save refresh is config-only and does not issue an expensive runtime probe while Gateway is restarting.
@@ -471,6 +471,52 @@ describe('OpenClaw config delivery coordinator', () => {
expect(gatewayManager.restart).not.toHaveBeenCalled();
});
it('accepts a config.set commit after RPC timeout when the snapshot was persisted', async () => {
const gatewayManager = createGatewayManager();
gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => {
if (method === 'config.get') {
return { raw: '{ channels: {} }', hash: 'hash-1' };
}
if (method === 'config.set') {
const raw = (params as { raw: string }).raw;
await writeFile(configPath, raw, 'utf8');
throw new Error('RPC timeout: config.set');
}
throw new Error(`Unexpected RPC method: ${method}`);
});
gatewayManager.getStatus.mockReturnValue({ state: 'running' });
registerOpenClawConfigCoordinator(gatewayManager);
await expect(mutateOpenClawConfig((config) => {
(config.channels as Record<string, unknown>).feishu = { enabled: true };
})).resolves.toBe(true);
expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({
channels: { feishu: { enabled: true } },
});
});
it('accepts a config.set commit after a 1012 service restart when the snapshot was persisted', async () => {
const gatewayManager = createGatewayManager();
gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => {
if (method === 'config.get') {
return { raw: '{ channels: {} }', hash: 'hash-1' };
}
if (method === 'config.set') {
const raw = (params as { raw: string }).raw;
await writeFile(configPath, raw, 'utf8');
throw new Error('Gateway service restart');
}
throw new Error(`Unexpected RPC method: ${method}`);
});
gatewayManager.getStatus.mockReturnValue({ state: 'running' });
registerOpenClawConfigCoordinator(gatewayManager);
await expect(mutateOpenClawConfig((config) => {
(config.channels as Record<string, unknown>).feishu = { enabled: true };
})).resolves.toBe(true);
});
it.each(['config.get', 'config.set'] as const)(
'fails closed when running %s fails',
async (failedMethod) => {
+34 -1
View File
@@ -342,7 +342,7 @@ describe('host services', () => {
providerServiceMock.createAccount.mockImplementation(async (account: unknown) => account);
providerServiceMock.setDefaultAccount.mockResolvedValue(undefined);
validateApiKeyWithProviderMock.mockResolvedValue({ valid: true });
ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true });
ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true, peerLinkOk: true });
ensureWeChatPluginInstalledMock.mockResolvedValue({ installed: true });
ensureClawXContextMock.mockResolvedValue(undefined);
rmSync(logDir, { recursive: true, force: true });
@@ -856,6 +856,39 @@ describe('host services', () => {
expect(gatewayManager.restart).not.toHaveBeenCalled();
});
it('schedules Gateway restart when plugin peer link repair fails on changed save', async () => {
listAgentsSnapshotMock.mockResolvedValue({
agents: [{ id: 'main', name: 'Main' }],
defaultAgentId: 'main',
defaultModelRef: null,
configuredChannelTypes: ['feishu'],
channelOwners: {},
channelAccountOwners: {},
});
getChannelFormValuesMock.mockResolvedValue({ appId: 'old', appSecret: 'old-secret' });
ensureFeishuPluginInstalledMock.mockResolvedValue({ installed: true, peerLinkOk: false });
const gatewayManager = {
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
debouncedRestart: vi.fn(),
debouncedReload: vi.fn(),
restart: vi.fn().mockResolvedValue(undefined),
};
const { createChannelsApi } = await import('@electron/services/channels-api');
await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).saveConfig({
channelType: 'feishu',
accountId: 'default',
config: { appId: 'cli_new', appSecret: 'new-secret' },
})).resolves.toEqual({ success: true, activationPending: true });
expect(saveChannelConfigMock).toHaveBeenCalledWith(
'feishu',
{ appId: 'cli_new', appSecret: 'new-secret' },
'default',
);
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
});
it('keeps bundled Telegram on the native config reload path', async () => {
getChannelFormValuesMock.mockResolvedValue({ botToken: 'old-token', allowedUsers: '1' });
const gatewayManager = {
+49 -4
View File
@@ -1,3 +1,4 @@
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
@@ -14,6 +15,7 @@ const {
mockWriteFileSync,
mockReaddirSync,
mockRealpathSync,
mockReadlinkSync,
mockLoggerWarn,
mockLoggerInfo,
mockUpsertPluginInstallRecordsIntoSqlite,
@@ -36,6 +38,7 @@ const {
mockWriteFileSync: vi.fn(),
mockReaddirSync: vi.fn(),
mockRealpathSync: vi.fn(),
mockReadlinkSync: vi.fn(),
mockLoggerWarn: vi.fn(),
mockLoggerInfo: vi.fn(),
mockUpsertPluginInstallRecordsIntoSqlite: vi.fn(() => true),
@@ -70,6 +73,7 @@ vi.mock('node:fs', async () => {
writeFileSync: mockWriteFileSync,
readdirSync: mockReaddirSync,
realpathSync: mockRealpathSync,
readlinkSync: mockReadlinkSync,
};
return {
...mocked,
@@ -329,7 +333,7 @@ describe('plugin installer diagnostics', () => {
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
const result = await ensurePluginInstalled('whatsapp', [sourceDir], 'WhatsApp');
expect(result.installed).toBe(true);
expect(result).toEqual({ installed: true, peerLinkOk: true });
expect(mockUpsertPluginInstallRecordsIntoSqlite).toHaveBeenCalledWith({
whatsapp: expect.objectContaining({
installPath: targetDir,
@@ -338,6 +342,34 @@ describe('plugin installer diagnostics', () => {
});
});
it('reports a failed OpenClaw peer link repair for an installed mirror', async () => {
const targetDir = '/home/test/.openclaw/extensions/qqbot';
mockExistsSync.mockImplementation((input: string) => {
const value = String(input);
return value === `${targetDir}/openclaw.plugin.json`
|| value === `${targetDir}/package.json`;
});
mockReadFileSync.mockImplementation((input: string) => {
if (String(input) === `${targetDir}/package.json`) {
return JSON.stringify({
name: '@openclaw/qqbot',
version: '2026.7.1',
peerDependencies: { openclaw: '>=2026.7.1' },
});
}
return '{}';
});
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
const result = await ensurePluginInstalled('qqbot', ['/bundle/qqbot'], 'QQBot');
expect(result).toEqual({ installed: true, peerLinkOk: false });
expect(mockLoggerWarn).toHaveBeenCalledWith(
expect.stringContaining('runtime package missing'),
);
});
it('removes WeCom updater metadata for the patched legacy-compatible plugin id', async () => {
const targetDir = '/home/test/.openclaw/extensions/wecom';
configState.authoritative = {
@@ -450,12 +482,19 @@ describe('plugin installer diagnostics', () => {
return '{}';
});
mockLstatSync.mockImplementation((input: string) => {
if (String(input) === nodeModulesDir) {
const value = String(input);
if (value === nodeModulesDir) {
return {
isDirectory: () => true,
isSymbolicLink: () => false,
};
}
if (linked && value.endsWith(`${path.sep}openclaw`)) {
return {
isDirectory: () => false,
isSymbolicLink: () => true,
};
}
const error = new Error('missing') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
@@ -463,9 +502,15 @@ describe('plugin installer diagnostics', () => {
mockSymlinkSync.mockImplementation(() => {
linked = true;
});
mockRealpathSync.mockImplementation((input: string) => (
linked && String(input) === linkPath ? openclawDir : String(input)
mockReadlinkSync.mockImplementation((input: string) => (
linked && String(input).endsWith(`${path.sep}openclaw`) ? openclawDir : ''
));
mockRealpathSync.mockImplementation((input: string) => {
if (linked && String(input).endsWith(`${path.sep}openclaw`)) {
return `${openclawDir}-realpath-divergence`;
}
return String(input);
});
const { repairPluginOpenClawPeerLink } = await import('@electron/utils/plugin-install');
expect(repairPluginOpenClawPeerLink(targetDir, openclawDir)).toBe(true);