refactor(config): derive cloud worker metadata from Zod (#114818)

This commit is contained in:
Peter Steinberger
2026-07-27 20:57:01 -04:00
committed by GitHub
parent 0bfe7dd357
commit dd248759ef
6 changed files with 112 additions and 77 deletions
+17 -57
View File
@@ -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(
+2 -10
View File
@@ -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":
+2 -5
View File
@@ -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({});
+47 -5
View File
@@ -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");
+2
View File
@@ -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>();