fix(zalouser): migrate legacy group allow aliases (#60702)

* fix(channels): prefer source contract surfaces in source checkouts

* fix(zalouser): migrate legacy group allow aliases
This commit is contained in:
Vincent Koc
2026-04-04 14:50:15 +09:00
committed by GitHub
parent ae7942bf5e
commit 73115b5480
16 changed files with 281 additions and 62 deletions

View File

@@ -31145,16 +31145,6 @@
"tags": [],
"hasChildren": true
},
{
"path": "channels.zalouser.accounts.*.groups.*.allow",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.zalouser.accounts.*.groups.*.enabled",
"kind": "channel",
@@ -31449,16 +31439,6 @@
"tags": [],
"hasChildren": true
},
{
"path": "channels.zalouser.groups.*.allow",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.zalouser.groups.*.enabled",
"kind": "channel",

View File

@@ -59185,16 +59185,6 @@
"tags": [],
"hasChildren": true
},
{
"path": "channels.zalouser.accounts.*.groups.*.allow",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.zalouser.accounts.*.groups.*.enabled",
"kind": "channel",
@@ -59489,16 +59479,6 @@
"tags": [],
"hasChildren": true
},
{
"path": "channels.zalouser.groups.*.allow",
"kind": "channel",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "channels.zalouser.groups.*.enabled",
"kind": "channel",

View File

@@ -9,7 +9,6 @@ import {
import { z } from "openclaw/plugin-sdk/zod";
const groupConfigSchema = z.object({
allow: z.boolean().optional(),
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { zalouserDoctor } from "./doctor.js";
describe("zalouser doctor", () => {
it("normalizes legacy group allow aliases to enabled", () => {
const normalize = zalouserDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
zalouser: {
groups: {
"group:trusted": {
allow: true,
},
},
accounts: {
work: {
groups: {
"group:legacy": {
allow: false,
},
},
},
},
},
},
} as never,
});
expect(result.config.channels?.zalouser?.groups?.["group:trusted"]).toEqual({
enabled: true,
});
expect(result.config.channels?.zalouser?.accounts?.work?.groups?.["group:legacy"]).toEqual({
enabled: false,
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (true).",
"Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (false).",
]),
);
});
});

View File

@@ -1,4 +1,8 @@
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import type {
ChannelDoctorAdapter,
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
import { isZalouserMutableGroupEntry } from "./security-audit.js";
@@ -13,6 +17,143 @@ function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
function hasLegacyZalouserGroupAllowAlias(value: unknown): boolean {
const group = asObjectRecord(value);
return Boolean(group && typeof group.allow === "boolean");
}
function hasLegacyZalouserGroupAllowAliases(value: unknown): boolean {
const groups = asObjectRecord(value);
return Boolean(
groups && Object.values(groups).some((group) => hasLegacyZalouserGroupAllowAlias(group)),
);
}
function hasLegacyZalouserAccountGroupAllowAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => {
const accountRecord = asObjectRecord(account);
return Boolean(accountRecord && hasLegacyZalouserGroupAllowAliases(accountRecord.groups));
});
}
function normalizeZalouserGroupAllowAliases(params: {
groups: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { groups: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextGroups: Record<string, unknown> = { ...params.groups };
for (const [groupId, groupValue] of Object.entries(params.groups)) {
const group = asObjectRecord(groupValue);
if (!group || typeof group.allow !== "boolean") {
continue;
}
const nextGroup = { ...group };
if (typeof nextGroup.enabled !== "boolean") {
nextGroup.enabled = group.allow;
}
delete nextGroup.allow;
nextGroups[groupId] = nextGroup;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled (${String(nextGroup.enabled)}).`,
);
}
return { groups: nextGroups, changed };
}
function normalizeZalouserCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const channels = asObjectRecord(cfg.channels);
const zalouser = asObjectRecord(channels?.zalouser);
if (!zalouser) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedZalouser: Record<string, unknown> = zalouser;
let changed = false;
const groups = asObjectRecord(updatedZalouser.groups);
if (groups) {
const normalized = normalizeZalouserGroupAllowAliases({
groups,
pathPrefix: "channels.zalouser.groups",
changes,
});
if (normalized.changed) {
updatedZalouser = { ...updatedZalouser, groups: normalized.groups };
changed = true;
}
}
const accounts = asObjectRecord(updatedZalouser.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asObjectRecord(accountValue);
if (!account) {
continue;
}
const accountGroups = asObjectRecord(account.groups);
if (!accountGroups) {
continue;
}
const normalized = normalizeZalouserGroupAllowAliases({
groups: accountGroups,
pathPrefix: `channels.zalouser.accounts.${accountId}.groups`,
changes,
});
if (!normalized.changed) {
continue;
}
nextAccounts[accountId] = {
...account,
groups: normalized.groups,
};
accountsChanged = true;
}
if (accountsChanged) {
updatedZalouser = { ...updatedZalouser, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
zalouser: updatedZalouser as OpenClawConfig["channels"]["zalouser"],
},
},
changes,
};
}
const ZALOUSER_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "zalouser", "groups"],
message:
"channels.zalouser.groups.<id>.allow is legacy; use channels.zalouser.groups.<id>.enabled instead (auto-migrated on load).",
match: hasLegacyZalouserGroupAllowAliases,
},
{
path: ["channels", "zalouser", "accounts"],
message:
"channels.zalouser.accounts.<id>.groups.<id>.allow is legacy; use channels.zalouser.accounts.<id>.groups.<id>.enabled instead (auto-migrated on load).",
match: hasLegacyZalouserAccountGroupAllowAliases,
},
];
export function collectZalouserMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
const hits: Array<{ path: string; entry: string }> = [];
@@ -53,5 +194,7 @@ export const zalouserDoctor: ChannelDoctorAdapter = {
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: ZALOUSER_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeZalouserCompatibilityConfig(cfg),
collectMutableAllowlistWarnings: ({ cfg }) => collectZalouserMutableAllowlistWarnings(cfg),
};

View File

@@ -37,7 +37,7 @@ describe("zalouser group policy helpers", () => {
it("finds the first matching group entry", () => {
const groups = {
"group:123": { allow: true },
"group:123": { enabled: true },
"team-alpha": { requireMention: false },
"*": { requireMention: true },
};
@@ -49,12 +49,12 @@ describe("zalouser group policy helpers", () => {
includeGroupIdAlias: true,
}),
);
expect(entry).toEqual({ allow: true });
expect(entry).toEqual({ enabled: true });
});
it("evaluates allow/enable flags", () => {
expect(isZalouserGroupEntryAllowed({ allow: true, enabled: true })).toBe(true);
expect(isZalouserGroupEntryAllowed({ allow: false })).toBe(false);
expect(isZalouserGroupEntryAllowed({ enabled: true })).toBe(true);
expect(isZalouserGroupEntryAllowed({ allow: false } as never)).toBe(false);
expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false);
expect(isZalouserGroupEntryAllowed(undefined)).toBe(false);
});

View File

@@ -77,5 +77,6 @@ export function isZalouserGroupEntryAllowed(entry: ZalouserGroupConfig | undefin
if (!entry) {
return false;
}
return entry.allow !== false && entry.enabled !== false;
const legacyAllow = (entry as ZalouserGroupConfig & { allow?: unknown }).allow;
return legacyAllow !== false && entry.enabled !== false;
}

View File

@@ -348,8 +348,8 @@ describe("zalouser monitor group mention gating", () => {
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { allow: true },
"Trusted Team": { allow: true },
"group:g-trusted-001": { enabled: true },
"Trusted Team": { enabled: true },
},
},
},
@@ -525,7 +525,7 @@ describe("zalouser monitor group mention gating", () => {
groupPolicy: "allowlist",
allowFrom: ["123"],
groups: {
"group:g-1": { allow: true, requireMention: true },
"group:g-1": { enabled: true, requireMention: true },
},
},
},

View File

@@ -204,7 +204,7 @@ function isSenderAllowed(senderId: string | undefined, allowFrom: string[]): boo
function resolveGroupRequireMention(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean; requireMention?: boolean }>;
groups: Record<string, { enabled?: boolean; requireMention?: boolean }>;
allowNameMatching?: boolean;
}): boolean {
const entry = findZalouserGroupEntry(

View File

@@ -160,6 +160,23 @@ describe("zalouser setup wizard", () => {
).toBe(true);
});
it("writes canonical enabled entries for configured groups", async () => {
const prompter = createQuickstartPrompter({
groupAccess: true,
groupPolicy: "allowlist",
textByMessage: {
"Zalo groups allowlist (comma-separated)": "Family, Work",
},
});
const result = await runSetup({ prompter });
expect(result.cfg.channels?.zalouser?.groups).toEqual({
Family: { enabled: true, requireMention: true },
Work: { enabled: true, requireMention: true },
});
});
it("preserves non-quickstart forceAllowFrom behavior", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const seen: string[] = [];

View File

@@ -94,7 +94,7 @@ function setZalouserGroupAllowlist(
groupKeys: string[],
): OpenClawConfig {
const groups = Object.fromEntries(
groupKeys.map((key) => [key, { allow: true, requireMention: true }]),
groupKeys.map((key) => [key, { enabled: true, requireMention: true }]),
);
return setZalouserAccountScopedConfig(cfg, accountId, {
groups,

View File

@@ -88,7 +88,6 @@ export type ZaloAuthStatus = {
export type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
export type ZalouserGroupConfig = {
allow?: boolean;
enabled?: boolean;
requireMention?: boolean;
tools?: ZalouserToolConfig;

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getBundledChannelContractSurfaceModule } from "./contract-surfaces.js";
describe("bundled channel contract surfaces", () => {
it("resolves Telegram contract surfaces from a source checkout", () => {
const surface = getBundledChannelContractSurfaceModule<{
normalizeTelegramCommandName?: (value: string) => string;
}>({
pluginId: "telegram",
preferredBasename: "contract-surfaces.ts",
});
expect(surface).not.toBeNull();
expect(surface?.normalizeTelegramCommandName?.("/Hello-World")).toBe("hello_world");
});
});

View File

@@ -61,6 +61,16 @@ function createModuleLoader() {
const loadModule = createModuleLoader();
function getContractSurfaceDiscoveryEnv(): NodeJS.ProcessEnv {
if (RUNNING_FROM_BUILT_ARTIFACT) {
return process.env;
}
return {
...process.env,
VITEST: process.env.VITEST || "1",
};
}
function matchesPreferredBasename(
basename: ContractSurfaceBasename,
preferredBasename: ContractSurfaceBasename | undefined,
@@ -153,9 +163,11 @@ function loadBundledChannelContractSurfaceEntries(): Array<{
pluginId: string;
surface: unknown;
}> {
const discovery = discoverOpenClawPlugins({ cache: false });
const env = getContractSurfaceDiscoveryEnv();
const discovery = discoverOpenClawPlugins({ cache: false, env });
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
@@ -204,9 +216,11 @@ export function getBundledChannelContractSurfaceModule<T = unknown>(params: {
if (cachedPreferredSurfaceModules.has(cacheKey)) {
return (cachedPreferredSurfaceModules.get(cacheKey) ?? null) as T | null;
}
const discovery = discoverOpenClawPlugins({ cache: false });
const env = getContractSurfaceDiscoveryEnv();
const discovery = discoverOpenClawPlugins({ cache: false, env });
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,

View File

@@ -15318,9 +15318,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
additionalProperties: {
type: "object",
properties: {
allow: {
type: "boolean",
},
enabled: {
type: "boolean",
},
@@ -15435,9 +15432,6 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
additionalProperties: {
type: "object",
properties: {
allow: {
type: "boolean",
},
enabled: {
type: "boolean",
},

View File

@@ -558,7 +558,7 @@ describe("legacy migrate nested channel enabled aliases", () => {
});
});
it("moves legacy allow toggles into enabled for slack, googlechat, discord, and matrix", () => {
it("moves legacy allow toggles into enabled for slack, googlechat, discord, matrix, and zalouser", () => {
const res = migrateLegacyConfig({
channels: {
slack: {
@@ -633,6 +633,22 @@ describe("legacy migrate nested channel enabled aliases", () => {
},
},
},
zalouser: {
groups: {
"group:trusted": {
allow: false,
},
},
accounts: {
work: {
groups: {
"group:legacy": {
allow: true,
},
},
},
},
},
},
});
@@ -660,6 +676,12 @@ describe("legacy migrate nested channel enabled aliases", () => {
expect(res.changes).toContain(
"Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (true).",
);
expect(res.changes).toContain(
"Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (false).",
);
expect(res.changes).toContain(
"Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (true).",
);
expect(res.config?.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
@@ -675,6 +697,12 @@ describe("legacy migrate nested channel enabled aliases", () => {
expect(res.config?.channels?.matrix?.accounts?.work?.rooms?.["!legacy:example.org"]).toEqual({
enabled: true,
});
expect(res.config?.channels?.zalouser?.groups?.["group:trusted"]).toEqual({
enabled: false,
});
expect(res.config?.channels?.zalouser?.accounts?.work?.groups?.["group:legacy"]).toEqual({
enabled: true,
});
});
it("drops legacy allow when enabled is already set", () => {