test(feishu): cover legacy dual-plugin migration (#990)

This commit is contained in:
Lingxuan Zuo
2026-05-07 20:04:18 +08:00
committed by GitHub
parent 05eb54edb6
commit c6adf36b57
15 changed files with 482 additions and 5 deletions

View File

@@ -0,0 +1,18 @@
---
id: active-config-guards
title: Active Config Guards
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
- plugin-lifecycle-management
---
Final OpenClaw runtime config must represent the resolved and validated plugin state, not raw discovery state.
Rules:
- unresolved or conflicted capabilities must not be written as active runtime owners
- allowlists and entries must agree about which package owns a single-owner capability
- disabling a bundled plugin is required when removing it from an allowlist is not sufficient to stop runtime loading
- stale plugin registrations for unconfigured capabilities must be removed during sanitize or recovery paths
- tests for config rewrites should assert the final active config, not only intermediate helper output

View File

@@ -0,0 +1,18 @@
---
id: capability-owner-resolution
title: Capability Owner Resolution
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
- plugin-lifecycle-management
---
Plugin discovery may find multiple packages that declare the same capability. Resolution must choose the canonical owner before activation.
Rules:
- single-owner capabilities, including channel capabilities such as `feishu`, must resolve to at most one active owner
- discovery code must not treat a found package as automatically active
- bundled and external package ownership decisions must be explicit and covered by migration tests when changed
- losing owners must be removed, disabled, or marked inactive according to the runtime's loading behavior
- business configuration, such as channel account credentials and bindings, must be preserved while plugin ownership is migrated

View File

@@ -0,0 +1,16 @@
---
id: channel-plugin-migration-guards
title: Channel Plugin Migration Guards
type: ai-coding-rule
appliesTo:
- gateway-backend-communication
---
When channel plugin ownership changes between bundled OpenClaw extensions and external `~/.openclaw/extensions/*` installs, ClawX must normalize configuration to one active plugin identity per channel.
For Feishu/Lark specifically:
- a configured Feishu channel must not leave both the bundled `feishu` plugin and the legacy external `openclaw-lark` / `feishu-openclaw-plugin` registrations active at the same time
- when the canonical Feishu plugin is external, ClawX must explicitly disable the bundled `feishu` plugin instead of only removing allowlist entries
- when the Feishu channel is not configured, stale Feishu plugin registrations must be removed from `plugins.allow` and `plugins.entries`
- changes to `electron/utils/openclaw-auth.ts`, `electron/utils/channel-config.ts`, or `electron/gateway/config-sync.ts` that affect channel/plugin migration must keep direct regression coverage for the dual-plugin migration state

View File

@@ -29,6 +29,9 @@ requiredRules:
- host-api-fallback-policy
- host-events-fallback-policy
- gateway-readiness-policy
- channel-plugin-migration-guards
- capability-owner-resolution
- active-config-guards
- comms-regression
- docs-sync
forbiddenPatterns:
@@ -52,3 +55,5 @@ Renderer code must not own transport selection, direct IPC channels, direct Gate
Explicit local fallback flags are narrow exceptions:
`clawx:allow-localhost-fallback` belongs to Host API browser fallback only, `clawx:allow-sse-fallback` belongs to host event SSE fallback only, and `clawx:gateway-ws-diagnostic` belongs to API client transport diagnostics only.
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.

View File

@@ -0,0 +1,56 @@
---
id: plugin-lifecycle-management
title: Plugin Lifecycle Management
type: plugin-lifecycle
ownedPaths:
- electron/gateway/config-sync.ts
- electron/utils/openclaw-auth.ts
- electron/utils/channel-config.ts
- electron/utils/plugin-install.ts
- electron/gateway/skills-symlink-cleanup.ts
- electron/api/routes/skills.ts
- src/stores/skills.ts
- resources/skills/**
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
- tests/unit/plugin-install.test.ts
- tests/unit/harness-specs.test.ts
- harness/src/cli.mjs
- harness/src/rules.mjs
- harness/src/specs.mjs
- harness/specs/scenarios/gateway-backend-communication.md
- harness/specs/scenarios/plugin-lifecycle-management.md
- harness/specs/rules/**
- harness/specs/tasks/**
requiredProfiles:
- fast
conditionalProfiles:
comms:
when:
- plugin activation changes Gateway startup or runtime message delivery
- channel/provider plugin ownership changes OpenClaw runtime config
e2e:
when:
- plugin status, install, recovery, or diagnostics behavior is visible in the UI
requiredRules:
- channel-plugin-migration-guards
- capability-owner-resolution
- active-config-guards
---
Plugin lifecycle management covers bundled and external plugins as one system with different source types. The core model has two layers:
- `PluginPackage`: a discoverable, installable, upgradable, and recoverable package with identity, version, source, manifest, install location, and validation state.
- `PluginCapability`: an integration surface declared by a package, such as a channel, provider, skill, or runtime extension.
Lifecycle stages:
- Declare: plugin manifests describe package identity, source expectations, compatibility, and provided capabilities.
- Discover: ClawX scans bundled and external sources and produces a factual inventory. Discovery does not choose active owners.
- Resolve: ClawX chooses canonical package/capability owners, applies migration rules, and marks conflicts before config activation.
- Materialize: ClawX installs, upgrades, copies, links, or verifies the physical package selected by resolution.
- Validate: ClawX verifies package manifests, dependencies, capability config, ownership uniqueness, and startup requirements.
- Activate: only resolved and validated capabilities enter final OpenClaw runtime config.
- Recover: failed upgrades, stale registrations, conflicts, and removed channels converge to a single diagnosable state with rollback or cleanup paths.
First-stage priority is integration safety: single-owner capability resolution, active config guards, direct regression tests for migration cases, and explicit task specs for resolution, validation, and recovery work.

View File

@@ -0,0 +1,44 @@
---
id: guard-feishu-dual-plugin-migration
title: Guard Feishu dual-plugin migration during gateway config sanitization
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Preserve the single-plugin Feishu migration contract across upgrades and config rewrites.
touchedAreas:
- electron/utils/openclaw-auth.ts
- electron/utils/channel-config.ts
- electron/gateway/config-sync.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
- tests/unit/harness-specs.test.ts
- harness/src/cli.mjs
- harness/src/rules.mjs
- harness/src/specs.mjs
- harness/specs/tasks/guard-feishu-dual-plugin-migration.md
- harness/specs/tasks/guard-plugin-resolution.md
- harness/specs/tasks/plugin-validation.md
- harness/specs/tasks/plugin-recovery-and-rollback.md
- harness/specs/scenarios/gateway-backend-communication.md
- harness/specs/scenarios/plugin-lifecycle-management.md
- harness/specs/rules/channel-plugin-migration-guards.md
- harness/specs/rules/capability-owner-resolution.md
- harness/specs/rules/active-config-guards.md
expectedUserBehavior:
- Migrated Feishu/Lark users do not end up with duplicate message handling because only one Feishu plugin remains active.
- Saving Feishu channel settings rewrites stale plugin registration state to a single canonical external plugin and disables the bundled plugin when required.
- Removing Feishu channel configuration clears stale Feishu plugin registrations from the OpenClaw config.
requiredProfiles:
- fast
- comms
requiredTests:
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
acceptance:
- `sanitizeOpenClawConfig()` keeps at most one active Feishu plugin identity in `plugins.allow` / `plugins.entries`.
- Bundled `feishu` is explicitly disabled when the canonical Feishu plugin is external.
- Residual Feishu plugin registrations are removed when the Feishu channel is not configured.
docs:
required: false
---
Use this task spec when changing Feishu/Lark plugin migration, channel config normalization, or gateway prelaunch config sanitization.

View File

@@ -0,0 +1,36 @@
---
id: guard-plugin-resolution
title: Guard plugin package and capability owner resolution
scenario: plugin-lifecycle-management
taskType: plugin-lifecycle
intent: Change plugin discovery, migration, or owner selection without introducing duplicate active capability owners.
touchedAreas:
- electron/gateway/config-sync.ts
- electron/utils/openclaw-auth.ts
- electron/utils/channel-config.ts
- electron/utils/plugin-install.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
- tests/unit/plugin-install.test.ts
- harness/specs/tasks/guard-plugin-resolution.md
- harness/specs/rules/capability-owner-resolution.md
- harness/specs/rules/active-config-guards.md
- harness/specs/scenarios/plugin-lifecycle-management.md
expectedUserBehavior:
- Plugin upgrades and migrations keep configured capabilities owned by one active package.
- Existing channel/provider/skill business config survives ownership migration.
- Stale plugin registrations do not reactivate old owners after restart.
requiredProfiles:
- fast
requiredTests:
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
acceptance:
- Single-owner capabilities have at most one active owner in final runtime config.
- Bundled and external ownership conflicts have direct regression coverage.
- Discovery, resolution, and activation responsibilities remain separate in the changed code.
docs:
required: false
---
Use this task spec when changing plugin discovery, canonical owner selection, plugin ID migration, channel/provider plugin registration, or startup config sanitization.

View File

@@ -0,0 +1,37 @@
---
id: plugin-recovery-and-rollback
title: Recover from plugin upgrade, migration, and activation failures
scenario: plugin-lifecycle-management
taskType: plugin-lifecycle
intent: Keep ClawX recoverable when plugin upgrades, migrations, or activation changes fail.
touchedAreas:
- electron/gateway/config-sync.ts
- electron/utils/openclaw-auth.ts
- electron/utils/channel-config.ts
- electron/utils/plugin-install.ts
- electron/gateway/skills-symlink-cleanup.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
- tests/unit/plugin-install.test.ts
- harness/specs/tasks/plugin-recovery-and-rollback.md
- harness/specs/rules/capability-owner-resolution.md
- harness/specs/rules/active-config-guards.md
- harness/specs/scenarios/plugin-lifecycle-management.md
expectedUserBehavior:
- Failed plugin upgrades do not leave duplicate or stale active capability owners.
- Removed or unconfigured capabilities have residual plugin registrations cleaned up.
- Recovery preserves user-owned config where possible and leaves clear diagnostics when manual action is needed.
requiredProfiles:
- fast
requiredTests:
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
acceptance:
- Recovery paths converge to a single diagnosable runtime state.
- Stale plugin directories, entries, allowlist values, and install metadata are handled according to source type.
- Rollback or cleanup behavior has direct regression coverage for the affected plugin class.
docs:
required: false
---
Use this task spec when changing plugin cleanup, rollback, stale install handling, or failure recovery paths.

View File

@@ -0,0 +1,36 @@
---
id: plugin-validation
title: Validate plugin packages and capabilities before activation
scenario: plugin-lifecycle-management
taskType: plugin-lifecycle
intent: Ensure plugin packages and capabilities are validated before they become active runtime configuration.
touchedAreas:
- electron/gateway/config-sync.ts
- electron/utils/openclaw-auth.ts
- electron/utils/channel-config.ts
- electron/utils/plugin-install.ts
- electron/api/routes/skills.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
- tests/unit/plugin-install.test.ts
- harness/specs/tasks/plugin-validation.md
- harness/specs/rules/active-config-guards.md
- harness/specs/scenarios/plugin-lifecycle-management.md
expectedUserBehavior:
- Invalid plugin packages do not become active silently.
- Capability config errors are surfaced as blocked, conflicted, degraded, or actionable diagnostics.
- Valid plugin changes keep Gateway startup and configured capabilities usable.
requiredProfiles:
- fast
requiredTests:
- tests/unit/openclaw-auth.test.ts
- tests/unit/channel-config.test.ts
acceptance:
- Package manifest, dependency, and compatibility checks run before activation when affected by the task.
- Capability validation checks owner uniqueness and required config.
- Failure states are diagnosable and do not leave stale active owners.
docs:
required: false
---
Use this task spec when changing plugin validation, manifest checks, dependency readiness, or capability readiness gates.

View File

@@ -6,6 +6,7 @@ import { writeReport } from './report.mjs';
import { runStep } from './runner.mjs';
import {
isGatewayBackendCommunicationTask,
isPluginLifecycleTask,
loadRuleSpecs,
loadScenarioSpecs,
loadSpec,
@@ -15,6 +16,7 @@ import {
scanBackendCommunicationBoundary,
touchesCommunicationPath,
validateGatewayTaskSpec,
validatePluginLifecycleTaskSpec,
} from './rules.mjs';
function parseArgs(argv) {
@@ -86,6 +88,8 @@ async function validate(specPath, options = {}) {
}
} else if (isGatewayBackendCommunicationTask(spec)) {
failures.push(...validateGatewayTaskSpec(spec, scenario, changedFiles));
} else if (isPluginLifecycleTask(spec)) {
failures.push(...validatePluginLifecycleTaskSpec(spec, scenario, changedFiles));
} else if (!spec.data.id || !spec.data.title) {
failures.push(`${spec.path}: spec must include id and title`);
}

View File

@@ -82,6 +82,24 @@ export async function scanBackendCommunicationBoundary(files) {
}
export function validateGatewayTaskSpec(taskSpec, scenarioSpec, changedFiles = []) {
return validateTaskSpec(taskSpec, scenarioSpec, changedFiles, {
scenarioId: 'gateway-backend-communication',
taskType: 'runtime-bridge',
label: 'gateway backend communication',
requiredProfiles: ['fast', 'comms'],
});
}
export function validatePluginLifecycleTaskSpec(taskSpec, scenarioSpec, changedFiles = []) {
return validateTaskSpec(taskSpec, scenarioSpec, changedFiles, {
scenarioId: 'plugin-lifecycle-management',
taskType: 'plugin-lifecycle',
label: 'plugin lifecycle',
requiredProfiles: ['fast'],
});
}
function validateTaskSpec(taskSpec, scenarioSpec, changedFiles, options) {
const failures = [];
const data = taskSpec.data ?? {};
const requiredProfiles = toArray(data.requiredProfiles);
@@ -93,15 +111,15 @@ export function validateGatewayTaskSpec(taskSpec, scenarioSpec, changedFiles = [
if (!data[field]) failures.push(`${taskSpec.path}: missing required field "${field}"`);
}
if (data.scenario !== 'gateway-backend-communication') {
failures.push(`${taskSpec.path}: gateway backend communication tasks must set scenario: gateway-backend-communication`);
if (data.scenario !== options.scenarioId) {
failures.push(`${taskSpec.path}: ${options.label} tasks must set scenario: ${options.scenarioId}`);
}
if (data.taskType !== 'runtime-bridge') {
failures.push(`${taskSpec.path}: gateway backend communication tasks must set taskType: runtime-bridge`);
if (data.taskType !== options.taskType) {
failures.push(`${taskSpec.path}: ${options.label} tasks must set taskType: ${options.taskType}`);
}
for (const profile of ['fast', 'comms']) {
for (const profile of options.requiredProfiles) {
if (!requiredProfiles.includes(profile)) {
failures.push(`${taskSpec.path}: requiredProfiles must include "${profile}"`);
}

View File

@@ -122,6 +122,11 @@ export function isGatewayBackendCommunicationTask(spec) {
|| toArray(spec.data?.scenarios).includes('gateway-backend-communication');
}
export function isPluginLifecycleTask(spec) {
return spec.data?.scenario === 'plugin-lifecycle-management'
|| toArray(spec.data?.scenarios).includes('plugin-lifecycle-management');
}
export function globToRegExp(glob) {
const escaped = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')

View File

@@ -159,6 +159,35 @@ describe('WeCom plugin configuration', () => {
expect(plugins.entries['wecom'].enabled).toBe(true);
});
it('normalizes feishu plugin registration to openclaw-lark and disables built-in feishu on save', async () => {
const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config');
await writeOpenClawConfig({
plugins: {
enabled: true,
allow: ['custom-plugin', 'feishu', 'feishu-openclaw-plugin'],
entries: {
'custom-plugin': { enabled: true },
feishu: { enabled: true },
'feishu-openclaw-plugin': { enabled: true },
},
},
});
await saveChannelConfig('feishu', { appId: 'test-app', appSecret: 'test-secret' }, 'default');
const config = await readOpenClawJson();
const plugins = config.plugins as { allow: string[]; entries: Record<string, { enabled?: boolean }> };
expect(plugins.allow).toContain('custom-plugin');
expect(plugins.allow).toContain('openclaw-lark');
expect(plugins.allow).not.toContain('feishu');
expect(plugins.allow).not.toContain('feishu-openclaw-plugin');
expect(plugins.entries['openclaw-lark']).toEqual({ enabled: true });
expect(plugins.entries.feishu).toEqual({ enabled: false });
expect(plugins.entries['feishu-openclaw-plugin']).toBeUndefined();
});
it('saves whatsapp as a built-in channel instead of a plugin', async () => {
const { saveChannelConfig } = await import('@electron/utils/channel-config');

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
isPluginLifecycleTask,
parseFrontmatter,
pathMatchesAny,
} from '../../harness/src/specs.mjs';
@@ -8,6 +9,7 @@ import {
scanBackendCommunicationBoundary,
touchesCommunicationPath,
validateGatewayTaskSpec,
validatePluginLifecycleTaskSpec,
} from '../../harness/src/rules.mjs';
describe('harness specs', () => {
@@ -62,6 +64,79 @@ Body`);
);
});
it('detects plugin lifecycle task specs for strict validation', () => {
expect(isPluginLifecycleTask({
data: {
scenario: 'plugin-lifecycle-management',
},
})).toBe(true);
expect(isPluginLifecycleTask({
data: {
scenarios: ['plugin-lifecycle-management'],
},
})).toBe(true);
expect(isPluginLifecycleTask({
data: {
scenario: 'gateway-backend-communication',
},
})).toBe(false);
});
it('requires plugin lifecycle tasks to declare strict task fields', () => {
const taskSpec = {
path: 'harness/specs/tasks/plugin-example.md',
data: {
id: 'plugin-example',
title: 'Plugin Example',
scenario: 'plugin-lifecycle-management',
taskType: 'plugin-lifecycle',
intent: 'Adjust plugin lifecycle behavior.',
requiredProfiles: [],
docs: { required: false },
},
};
const scenarioSpec = {
data: {
requiredProfiles: ['fast'],
ownedPaths: ['electron/utils/plugin-install.ts'],
},
};
expect(validatePluginLifecycleTaskSpec(taskSpec, scenarioSpec)).toEqual(
expect.arrayContaining([
'harness/specs/tasks/plugin-example.md: requiredProfiles must include "fast"',
'harness/specs/tasks/plugin-example.md: touchedAreas must declare affected paths',
'harness/specs/tasks/plugin-example.md: expectedUserBehavior must declare visible behavior',
'harness/specs/tasks/plugin-example.md: acceptance must declare completion criteria',
]),
);
});
it('rejects plugin lifecycle tasks with the wrong scenario or task type', () => {
const taskSpec = {
path: 'harness/specs/tasks/plugin-example.md',
data: {
id: 'plugin-example',
title: 'Plugin Example',
scenario: 'gateway-backend-communication',
taskType: 'runtime-bridge',
intent: 'Adjust plugin lifecycle behavior.',
touchedAreas: ['electron/utils/plugin-install.ts'],
expectedUserBehavior: ['Plugin remains usable.'],
requiredProfiles: ['fast'],
acceptance: ['Validation passes.'],
docs: { required: false },
},
};
expect(validatePluginLifecycleTaskSpec(taskSpec, null)).toEqual(
expect.arrayContaining([
'harness/specs/tasks/plugin-example.md: plugin lifecycle tasks must set scenario: plugin-lifecycle-management',
'harness/specs/tasks/plugin-example.md: plugin lifecycle tasks must set taskType: plugin-lifecycle',
]),
);
});
it('detects communication path changes', () => {
expect(touchesCommunicationPath(['electron/gateway/manager.ts'])).toBe(true);
expect(touchesCommunicationPath(['README.md'])).toBe(false);

View File

@@ -465,6 +465,86 @@ describe('sanitizeOpenClawConfig', () => {
expect(telegram.botToken).toBe('telegram-token');
});
it('normalizes legacy feishu plugin state to a single external plugin and disables built-in feishu', async () => {
await writeOpenClawJson({
channels: {
feishu: {
enabled: true,
appId: 'cli-feishu-app',
appSecret: 'cli-feishu-secret',
},
},
plugins: {
enabled: true,
allow: ['custom-plugin', 'feishu', 'openclaw-lark'],
entries: {
'custom-plugin': { enabled: true },
feishu: { enabled: true },
'openclaw-lark': { enabled: true, config: { preserved: true } },
},
},
});
const legacyPluginDir = join(testHome, '.openclaw', 'extensions', 'openclaw-lark');
await mkdir(legacyPluginDir, { recursive: true });
await writeFile(
join(legacyPluginDir, 'openclaw.plugin.json'),
JSON.stringify({ id: 'openclaw-lark' }, null, 2),
'utf8',
);
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
await sanitizeOpenClawConfig();
const result = await readOpenClawJson();
const plugins = result.plugins as Record<string, unknown>;
const allow = plugins.allow as string[];
const entries = plugins.entries as Record<string, Record<string, unknown>>;
expect(allow).toContain('openclaw-lark');
expect(allow).not.toContain('feishu');
expect(entries['openclaw-lark']).toEqual({
enabled: true,
config: { preserved: true },
});
expect(entries.feishu).toEqual({ enabled: false });
});
it('removes residual feishu plugin registrations when feishu channel is not configured', async () => {
await writeOpenClawJson({
channels: {
telegram: {
enabled: true,
botToken: 'telegram-token',
},
},
plugins: {
enabled: true,
allow: ['custom-plugin', 'feishu', 'openclaw-lark'],
entries: {
'custom-plugin': { enabled: true },
feishu: { enabled: false },
'openclaw-lark': { enabled: true },
},
},
});
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
await sanitizeOpenClawConfig();
const result = await readOpenClawJson();
const plugins = result.plugins as Record<string, unknown>;
const allow = plugins.allow as string[];
const entries = plugins.entries as Record<string, Record<string, unknown>>;
expect(allow).toContain('custom-plugin');
expect(allow).not.toContain('feishu');
expect(allow).not.toContain('openclaw-lark');
expect(entries['custom-plugin']).toEqual({ enabled: true });
expect(entries.feishu).toBeUndefined();
expect(entries['openclaw-lark']).toBeUndefined();
});
it('strips defaultAccount (but preserves accounts) from dingtalk during sanitize', async () => {
await writeOpenClawJson({
channels: {