From dd248759ef565438fdcd74ec0dd5f5412bd13bb0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 20:57:01 -0400 Subject: [PATCH] refactor(config): derive cloud worker metadata from Zod (#114818) --- src/config/schema-base.ts | 74 +++++---------------- src/config/schema.help.core.ts | 12 +--- src/config/schema.labels.ts | 7 +- src/config/zod-schema.cloud-workers.test.ts | 42 ++++++++++++ src/config/zod-schema.cloud-workers.ts | 52 +++++++++++++-- src/config/zod-schema.sensitive.ts | 2 + 6 files changed, 112 insertions(+), 77 deletions(-) diff --git a/src/config/schema-base.ts b/src/config/schema-base.ts index 94c61250af3d..053d8de3717e 100644 --- a/src/config/schema-base.ts +++ b/src/config/schema-base.ts @@ -17,11 +17,6 @@ import { OpenClawSchema } from "./zod-schema.js"; type ConfigSchema = Record; -type FieldDocumentation = { - titles: Record; - descriptions: Record; -}; - type JsonSchemaObject = Record & { 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 = {}; - for (const [key, value] of Object.entries(FIELD_LABELS)) { - if (value) { - titles[key] = value; - } - } - - const descriptions: Record = {}; - 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( diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 3ba3d05e40a4..fef34e2b2fee 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -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 = { "channels.discord.activities": @@ -71,16 +72,7 @@ export const CORE_FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index fdc54f75fd80..d0b4bb252966 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -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 = { "channels.discord.activities": "Discord Activities", @@ -98,11 +99,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/zod-schema.cloud-workers.test.ts b/src/config/zod-schema.cloud-workers.test.ts index b7e41e2be299..5cd9380dcd96 100644 --- a/src/config/zod-schema.cloud-workers.test.ts +++ b/src/config/zod-schema.cloud-workers.test.ts @@ -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; + }; + }; + }; + }; + }; + } + ).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({}); diff --git a/src/config/zod-schema.cloud-workers.ts b/src/config/zod-schema.cloud-workers.ts index 6abc8aa35152..82a7aab87a11 100644 --- a/src/config/zod-schema.cloud-workers.ts +++ b/src/config/zod-schema.cloud-workers.ts @@ -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 = { [Key in keyof T]-?: z.ZodType; @@ -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; -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; 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 { + 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"); diff --git a/src/config/zod-schema.sensitive.ts b/src/config/zod-schema.sensitive.ts index ddc3e5c1e970..252ea3f2c46e 100644 --- a/src/config/zod-schema.sensitive.ts +++ b/src/config/zod-schema.sensitive.ts @@ -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(); +export const configUiMetadata = z.registry();