mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
refactor(config): derive cloud worker metadata from Zod (#114818)
This commit is contained in:
committed by
GitHub
parent
0bfe7dd357
commit
dd248759ef
+17
-57
@@ -17,11 +17,6 @@ import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
type ConfigSchema = Record<string, unknown>;
|
||||
|
||||
type FieldDocumentation = {
|
||||
titles: Record<string, string>;
|
||||
descriptions: Record<string, string>;
|
||||
};
|
||||
|
||||
type JsonSchemaObject = Record<string, unknown> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
@@ -39,34 +34,12 @@ const LEGACY_HIDDEN_PUBLIC_PATHS = ["hooks.internal.handlers"] as const;
|
||||
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
|
||||
asSchemaObject(value) as JsonSchemaObject | null;
|
||||
|
||||
function buildFieldDocumentation(): FieldDocumentation {
|
||||
const titles: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(FIELD_LABELS)) {
|
||||
if (value) {
|
||||
titles[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const descriptions: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(FIELD_HELP)) {
|
||||
if (value) {
|
||||
descriptions[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return { titles, descriptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk a JSON Schema object and apply field docs using dot-path
|
||||
* matching. Existing titles/descriptions (for example from Zod metadata) are
|
||||
* preserved.
|
||||
*/
|
||||
function applyFieldDocumentation(
|
||||
node: JsonSchemaObject,
|
||||
documentation: FieldDocumentation,
|
||||
prefixes: readonly string[] = [""],
|
||||
): void {
|
||||
function applyFieldDocumentation(node: JsonSchemaObject, prefixes: readonly string[] = [""]): void {
|
||||
const props = node.properties;
|
||||
if (props) {
|
||||
for (const [key, child] of Object.entries(props)) {
|
||||
@@ -75,8 +48,8 @@ function applyFieldDocumentation(
|
||||
continue;
|
||||
}
|
||||
const childPrefixes = prefixes.map((prefix) => (prefix ? `${prefix}.${key}` : key));
|
||||
applyNodeDocumentation(childObj, documentation, childPrefixes);
|
||||
applyFieldDocumentation(childObj, documentation, childPrefixes);
|
||||
applyNodeDocumentation(childObj, childPrefixes);
|
||||
applyFieldDocumentation(childObj, childPrefixes);
|
||||
}
|
||||
}
|
||||
// Handle additionalProperties (wildcard keys like "models.providers.*")
|
||||
@@ -84,8 +57,8 @@ function applyFieldDocumentation(
|
||||
const addObj = asJsonSchemaObject(node.additionalProperties);
|
||||
if (addObj) {
|
||||
const wildcardPrefixes = prefixes.map((prefix) => (prefix ? `${prefix}.*` : "*"));
|
||||
applyNodeDocumentation(addObj, documentation, wildcardPrefixes);
|
||||
applyFieldDocumentation(addObj, documentation, wildcardPrefixes);
|
||||
applyNodeDocumentation(addObj, wildcardPrefixes);
|
||||
applyFieldDocumentation(addObj, wildcardPrefixes);
|
||||
}
|
||||
}
|
||||
// Handle array items. Help/labels may use either "[]" notation
|
||||
@@ -102,8 +75,8 @@ function applyFieldDocumentation(
|
||||
}),
|
||||
),
|
||||
);
|
||||
applyNodeDocumentation(itemsObj, documentation, itemPrefixes);
|
||||
applyFieldDocumentation(itemsObj, documentation, itemPrefixes);
|
||||
applyNodeDocumentation(itemsObj, itemPrefixes);
|
||||
applyFieldDocumentation(itemsObj, itemPrefixes);
|
||||
}
|
||||
}
|
||||
// Recurse into composition branches (anyOf, oneOf, allOf) using the same
|
||||
@@ -114,35 +87,22 @@ function applyFieldDocumentation(
|
||||
for (const branch of branches) {
|
||||
const branchObj = asJsonSchemaObject(branch);
|
||||
if (branchObj) {
|
||||
applyFieldDocumentation(branchObj, documentation, prefixes);
|
||||
applyFieldDocumentation(branchObj, prefixes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodeDocumentation(
|
||||
node: JsonSchemaObject,
|
||||
documentation: FieldDocumentation,
|
||||
pathCandidates: readonly string[],
|
||||
): void {
|
||||
if (!node.title) {
|
||||
for (const path of pathCandidates) {
|
||||
const title = documentation.titles[path];
|
||||
if (title) {
|
||||
node.title = title;
|
||||
break;
|
||||
}
|
||||
function applyNodeDocumentation(node: JsonSchemaObject, pathCandidates: readonly string[]): void {
|
||||
for (const path of pathCandidates) {
|
||||
const title = FIELD_LABELS[path];
|
||||
if (!node.title && title) {
|
||||
node.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node.description) {
|
||||
for (const path of pathCandidates) {
|
||||
const description = documentation.descriptions[path];
|
||||
if (description) {
|
||||
node.description = description;
|
||||
break;
|
||||
}
|
||||
const description = FIELD_HELP[path];
|
||||
if (!node.description && description) {
|
||||
node.description = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +199,7 @@ function computeBaseConfigSchemaStablePayload(): BaseConfigSchemaStablePayload {
|
||||
schema.title = "OpenClawConfig";
|
||||
const schemaRoot = asJsonSchemaObject(schema);
|
||||
if (schemaRoot) {
|
||||
applyFieldDocumentation(schemaRoot, buildFieldDocumentation());
|
||||
applyFieldDocumentation(schemaRoot);
|
||||
}
|
||||
const baseHints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints());
|
||||
const sensitiveUrlPaths = collectMatchingSchemaPaths(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Defines user-facing config field help text for docs and UI surfaces.
|
||||
import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js";
|
||||
import { CLOUD_WORKER_FIELD_HELP } from "./zod-schema.cloud-workers.js";
|
||||
|
||||
export const CORE_FIELD_HELP: Record<string, string> = {
|
||||
"channels.discord.activities":
|
||||
@@ -71,16 +72,7 @@ export const CORE_FIELD_HELP: Record<string, string> = {
|
||||
"Enable background auto-update for stable and beta package installs; extended-stable never auto-applies (default: false).",
|
||||
cloudWorkers:
|
||||
"Opt-in cloud worker profiles for disposable remote environments. When this section is omitted or has no profiles, cloud worker creation remains unavailable and existing gateway/node status behavior is unchanged.",
|
||||
"cloudWorkers.profiles":
|
||||
"Named cloud worker profiles. Each profile selects a worker provider registered by a plugin and carries provider-owned settings.",
|
||||
"cloudWorkers.profiles.*":
|
||||
"One cloud worker profile selected by name when creating an environment. Keep provider credentials in supported references rather than embedding secret material in this block.",
|
||||
"cloudWorkers.profiles.*.provider":
|
||||
"Worker provider id registered by a plugin. The configured plugin must expose this id before the gateway can provision environments from the profile.",
|
||||
"cloudWorkers.profiles.*.install":
|
||||
'Worker installation method: "bundle" (default) transfers the gateway\'s content-hashed installed build and supports released, development, and unreleased versions; "npm" installs the exact gateway version and is available only when that version is released.',
|
||||
"cloudWorkers.profiles.*.settings":
|
||||
"Provider-owned settings validated by the selected plugin. Use SecretRef objects for secret-bearing values; opaque settings do not gain automatic secret resolution.",
|
||||
...CLOUD_WORKER_FIELD_HELP,
|
||||
gateway:
|
||||
"Gateway runtime surface for bind mode, auth, control UI, remote transport, and operational safety controls. Keep conservative defaults unless you intentionally expose the gateway beyond trusted local interfaces.",
|
||||
"gateway.port":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Defines user-facing config field labels used by schema metadata.
|
||||
import { MEDIA_AUDIO_FIELD_LABELS } from "./media-audio-field-metadata.js";
|
||||
import { NODE_CAPABILITY_FIELD_LABELS } from "./schema.node-capabilities.js";
|
||||
import { CLOUD_WORKER_FIELD_LABELS } from "./zod-schema.cloud-workers.js";
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.discord.activities": "Discord Activities",
|
||||
@@ -98,11 +99,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.entries.*.agentRuntime": "Legacy Agent Runtime",
|
||||
"agents.entries.*.agentRuntime.id": "Legacy Agent Runtime ID",
|
||||
cloudWorkers: "Cloud Workers",
|
||||
"cloudWorkers.profiles": "Cloud Worker Profiles",
|
||||
"cloudWorkers.profiles.*": "Cloud Worker Profile",
|
||||
"cloudWorkers.profiles.*.provider": "Cloud Worker Provider",
|
||||
"cloudWorkers.profiles.*.install": "Cloud Worker Install Method",
|
||||
"cloudWorkers.profiles.*.settings": "Cloud Worker Provider Settings",
|
||||
...CLOUD_WORKER_FIELD_LABELS,
|
||||
gateway: "Gateway",
|
||||
"gateway.port": "Gateway Port",
|
||||
"gateway.mode": "Gateway Mode",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Verifies cloud-worker provider profile config parsing.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeBaseConfigSchemaResponse } from "./schema-base.js";
|
||||
import { CLOUD_WORKER_FIELD_HELP, CLOUD_WORKER_FIELD_LABELS } from "./zod-schema.cloud-workers.js";
|
||||
import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
function parseCloudWorkers(value: unknown) {
|
||||
@@ -11,6 +13,46 @@ function parseCloudWorkers(value: unknown) {
|
||||
}
|
||||
|
||||
describe("OpenClawSchema cloudWorkers config", () => {
|
||||
it("derives cloud worker labels and help from the field schemas", () => {
|
||||
const response = computeBaseConfigSchemaResponse({ generatedAt: "cloud-worker-metadata" });
|
||||
const properties = (
|
||||
response.schema as {
|
||||
properties?: {
|
||||
cloudWorkers?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: {
|
||||
profiles?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
additionalProperties?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: Record<string, { title?: string; description?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).properties?.cloudWorkers;
|
||||
const profiles = properties?.properties?.profiles;
|
||||
const profile = profiles?.additionalProperties;
|
||||
|
||||
for (const [path, schema] of [
|
||||
["cloudWorkers.profiles", profiles],
|
||||
["cloudWorkers.profiles.*", profile],
|
||||
["cloudWorkers.profiles.*.provider", profile?.properties?.provider],
|
||||
["cloudWorkers.profiles.*.install", profile?.properties?.install],
|
||||
["cloudWorkers.profiles.*.settings", profile?.properties?.settings],
|
||||
] as const) {
|
||||
expect(schema?.title, path).toBe(CLOUD_WORKER_FIELD_LABELS[path]);
|
||||
expect(schema?.description, path).toBe(CLOUD_WORKER_FIELD_HELP[path]);
|
||||
expect(response.uiHints[path]?.label, path).toBe(schema?.title);
|
||||
expect(response.uiHints[path]?.help, path).toBe(schema?.description);
|
||||
}
|
||||
});
|
||||
|
||||
it("is absent by default and accepts an empty opt-in block", () => {
|
||||
expect(OpenClawSchema.parse({}).cloudWorkers).toBeUndefined();
|
||||
expect(parseCloudWorkers({})).toStrictEqual({});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isValidSecretRef } from "../secrets/ref-contract.js";
|
||||
import { isSensitiveConfigPath } from "./sensitive-paths.js";
|
||||
import type { CloudWorkerProfileConfig, CloudWorkersConfig } from "./types.cloud-workers.js";
|
||||
import { isSecretRef } from "./types.secrets.js";
|
||||
import { configUiMetadata } from "./zod-schema.sensitive.js";
|
||||
|
||||
type ConfigSchemaShape<T extends object> = {
|
||||
[Key in keyof T]-?: z.ZodType<T[Key]>;
|
||||
@@ -56,12 +57,27 @@ const CloudWorkerSettingsSchema = z.record(z.string(), z.unknown()).superRefine(
|
||||
});
|
||||
|
||||
const CloudWorkerProfileShape = {
|
||||
provider: z.string().trim().min(1),
|
||||
install: z.enum(["bundle", "npm"]).optional().default("bundle"),
|
||||
settings: CloudWorkerSettingsSchema.optional(),
|
||||
provider: z.string().trim().min(1).register(configUiMetadata, {
|
||||
label: "Cloud Worker Provider",
|
||||
help: "Worker provider id registered by a plugin. The configured plugin must expose this id before the gateway can provision environments from the profile.",
|
||||
}),
|
||||
install: z.enum(["bundle", "npm"]).optional().default("bundle").register(configUiMetadata, {
|
||||
label: "Cloud Worker Install Method",
|
||||
help: 'Worker installation method: "bundle" (default) transfers the gateway\'s content-hashed installed build and supports released, development, and unreleased versions; "npm" installs the exact gateway version and is available only when that version is released.',
|
||||
}),
|
||||
settings: CloudWorkerSettingsSchema.optional().register(configUiMetadata, {
|
||||
label: "Cloud Worker Provider Settings",
|
||||
help: "Provider-owned settings validated by the selected plugin. Use SecretRef objects for secret-bearing values; opaque settings do not gain automatic secret resolution.",
|
||||
}),
|
||||
} satisfies ConfigSchemaShape<CloudWorkerProfileConfig>;
|
||||
|
||||
const CloudWorkerProfileSchema = z.object(CloudWorkerProfileShape).strict();
|
||||
const CloudWorkerProfileSchema = z
|
||||
.object(CloudWorkerProfileShape)
|
||||
.strict()
|
||||
.register(configUiMetadata, {
|
||||
label: "Cloud Worker Profile",
|
||||
help: "One cloud worker profile selected by name when creating an environment. Keep provider credentials in supported references rather than embedding secret material in this block.",
|
||||
});
|
||||
const CloudWorkerProfileIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -71,7 +87,33 @@ const CloudWorkerProfileIdSchema = z
|
||||
);
|
||||
|
||||
const CloudWorkersConfigShape = {
|
||||
profiles: z.record(CloudWorkerProfileIdSchema, CloudWorkerProfileSchema).optional(),
|
||||
profiles: z
|
||||
.record(CloudWorkerProfileIdSchema, CloudWorkerProfileSchema)
|
||||
.optional()
|
||||
.register(configUiMetadata, {
|
||||
label: "Cloud Worker Profiles",
|
||||
help: "Named cloud worker profiles. Each profile selects a worker provider registered by a plugin and carries provider-owned settings.",
|
||||
}),
|
||||
} satisfies ConfigSchemaShape<CloudWorkersConfig>;
|
||||
|
||||
export const CloudWorkersConfigSchema = z.object(CloudWorkersConfigShape).strict().optional();
|
||||
|
||||
const CLOUD_WORKER_FIELD_SCHEMAS = {
|
||||
"cloudWorkers.profiles": CloudWorkersConfigShape.profiles,
|
||||
"cloudWorkers.profiles.*": CloudWorkerProfileSchema,
|
||||
"cloudWorkers.profiles.*.provider": CloudWorkerProfileShape.provider,
|
||||
"cloudWorkers.profiles.*.install": CloudWorkerProfileShape.install,
|
||||
"cloudWorkers.profiles.*.settings": CloudWorkerProfileShape.settings,
|
||||
};
|
||||
|
||||
function projectCloudWorkerFieldMetadata(field: "label" | "help"): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(CLOUD_WORKER_FIELD_SCHEMAS).flatMap(([path, schema]) => {
|
||||
const value = configUiMetadata.get(schema)?.[field];
|
||||
return typeof value === "string" ? [[path, value]] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const CLOUD_WORKER_FIELD_LABELS = projectCloudWorkerFieldMetadata("label");
|
||||
export const CLOUD_WORKER_FIELD_HELP = projectCloudWorkerFieldMetadata("help");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Defines sensitive config schema fragments and redaction metadata.
|
||||
import { z } from "zod";
|
||||
import type { ConfigUiHint } from "../shared/config-ui-hints-types.js";
|
||||
|
||||
// Everything registered here will be redacted when the config is exposed,
|
||||
// e.g. sent to the dashboard
|
||||
export const sensitive = z.registry<undefined, z.ZodType>();
|
||||
export const configUiMetadata = z.registry<ConfigUiHint, z.ZodType>();
|
||||
|
||||
Reference in New Issue
Block a user