fix(config): render Feishu group policy in Form mode (#107336)

* fix(ui): scope unsupported config guidance to fields

* fix(config): export Feishu transform inputs

* chore: remove release-owned changelog entry
This commit is contained in:
Peter Steinberger
2026-07-14 03:42:43 -07:00
committed by GitHub
parent b68714ee42
commit 3229fbdc48
7 changed files with 199 additions and 18 deletions
+2 -3
View File
@@ -61,7 +61,6 @@ import type {
ClawdbotConfig,
} from "./channel-runtime-api.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
chunkTextForOutbound,
createActionGate,
@@ -71,7 +70,7 @@ import {
} from "./channel-runtime-api.js";
import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js";
import { isRecord } from "./comment-shared.js";
import { FeishuConfigSchema } from "./config-schema.js";
import { FeishuChannelConfigSchema } from "./config-schema.js";
import {
buildFeishuConversationId,
buildFeishuModelOverrideParentCandidates,
@@ -981,7 +980,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
},
reload: { configPrefixes: ["channels.feishu"] },
doctor: feishuDoctor,
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
configSchema: FeishuChannelConfigSchema,
config: {
...feishuConfigAdapter,
setAccountEnabled: ({ cfg, accountId, enabled }) => {
+21 -1
View File
@@ -1,6 +1,6 @@
// Feishu tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { FeishuConfigSchema } from "./config-schema.js";
import { FeishuChannelConfigSchema, FeishuConfigSchema } from "./config-schema.js";
// The NEGATIVE webhook fixtures below spread these bases and add
// verificationToken separately so the GHSA-G353-MGV3-8PCJ opengrep pattern —
@@ -62,6 +62,26 @@ describe("FeishuConfigSchema webhook validation", () => {
expect(result.groupPolicy).toBe("open");
});
it("exports legacy groupPolicy as a typed config input", () => {
const expected = {
anyOf: [
{ type: "string", enum: ["open", "allowlist", "disabled"] },
{ type: "string", const: "allowall" },
],
};
expect(FeishuChannelConfigSchema.schema).toMatchObject({
properties: {
groupPolicy: expected,
accounts: {
additionalProperties: {
properties: { groupPolicy: expected },
},
},
},
});
});
it("rejects top-level webhook mode without verificationToken", () => {
const result = FeishuConfigSchema.safeParse({
connectionMode: "webhook",
+5
View File
@@ -1,5 +1,6 @@
// Feishu helper module supports config schema behavior.
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
@@ -351,3 +352,7 @@ export const FeishuConfigSchema = z
}
}
});
export const FeishuChannelConfigSchema = buildChannelConfigSchema(FeishuConfigSchema, {
jsonSchemaMode: "input",
});
@@ -50,6 +50,33 @@ describe("buildChannelConfigSchema", () => {
});
});
it("can describe accepted transform inputs instead of unrepresentable outputs", () => {
const result = buildChannelConfigSchema(
z.object({
policy: z.union([
z.enum(["open", "disabled"]),
z.literal("legacy").transform(() => "open" as const),
]),
}),
{ jsonSchemaMode: "input" },
);
expect(result.schema).toMatchObject({
properties: {
policy: {
anyOf: [
{ type: "string", enum: ["open", "disabled"] },
{ type: "string", const: "legacy" },
],
},
},
});
expect(result.runtime?.safeParse({ policy: "legacy" })).toEqual({
success: true,
data: { policy: "open" },
});
});
it("passes through ui hints and exposes a runtime parser", () => {
const result = buildChannelConfigSchema(z.object({ enabled: z.boolean().default(true) }), {
uiHints: { enabled: { label: "Enabled" } },
+3
View File
@@ -50,6 +50,8 @@ export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodOb
type BuildChannelConfigSchemaOptions = {
uiHints?: Record<string, ChannelConfigUiHint>;
/** Select input mode when transforms must expose accepted config values to editors. */
jsonSchemaMode?: "input" | "output";
};
type BuildJsonChannelConfigSchemaOptions = {
@@ -146,6 +148,7 @@ export function buildChannelConfigSchema(
return {
schema: schemaWithJson.toJSONSchema({
target: "draft-07",
...(options?.jsonSchemaMode ? { io: options.jsonSchemaMode } : {}),
unrepresentable: "any",
}) as JsonSchemaObject,
...(options?.uiHints ? { uiHints: options.uiHints } : {}),
File diff suppressed because one or more lines are too long
+127
View File
@@ -0,0 +1,127 @@
// Control UI tests cover form support for transform-backed config fields.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const globalWarning =
"Your config contains fields the form editor can't safely represent. Use Raw mode to edit those entries.";
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(
process.cwd(),
".artifacts",
"control-ui-e2e",
"config-form-guidance",
);
let browser: Browser;
let server: ControlUiE2eServer;
describeControlUiE2e("Control UI config form guidance mocked Gateway E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(
`Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("renders every accepted branch of a transform input schema", async () => {
const context = await browser.newContext({
colorScheme: "dark",
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 1000, width: 1440 },
});
const page = await context.newPage();
const config = { update: { groupPolicy: "allowlist" } };
await installMockGateway(page, {
methodResponses: {
"config.get": {
config,
hash: "config-form-guidance-e2e",
issues: [],
raw: JSON.stringify(config),
valid: true,
},
"config.schema": {
generatedAt: "2026-07-14T00:00:00.000Z",
schema: {
type: "object",
properties: {
update: {
type: "object",
title: "Updates",
properties: {
groupPolicy: {
title: "Group policy",
anyOf: [
{ type: "string", enum: ["open", "allowlist", "disabled"] },
{ type: "string", const: "allowall" },
],
},
},
},
},
},
uiHints: {},
version: "e2e",
},
},
});
try {
const response = await page.goto(`${server.baseUrl}settings/general`);
expect(response?.status()).toBe(200);
await page.locator('.content-header wa-radio[value="advanced"]').click();
await page.getByRole("button", { name: "Core" }).click();
await page.getByRole("button", { name: "Updates", exact: true }).click();
const policyRow = page.locator(".settings-row").filter({ hasText: "Group policy" });
await expect.poll(() => policyRow.locator("wa-radio").count()).toBe(4);
await expect.poll(() => policyRow.getByText("open", { exact: true }).count()).toBe(1);
await expect.poll(() => policyRow.getByText("allowlist", { exact: true }).count()).toBe(1);
await expect.poll(() => policyRow.getByText("disabled", { exact: true }).count()).toBe(1);
await expect.poll(() => policyRow.getByText("allowall", { exact: true }).count()).toBe(1);
await expect
.poll(() => page.getByText("Unsupported schema node. Use Raw mode.").count())
.toBe(0);
await expect.poll(() => page.getByText(globalWarning).count()).toBe(0);
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(uiProofArtifactDir, "01-transform-field-supported.png"),
});
}
await page.getByRole("button", { name: "Raw", exact: true }).click();
await expect.poll(() => page.locator(".config-raw-field textarea").count()).toBe(1);
await expect.poll(() => page.getByText(globalWarning).count()).toBe(0);
} finally {
await context.close();
}
});
});