diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 09c8f6c2968..46ba7af67b9 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -61,7 +61,7 @@ See the [full reference](/gateway/configuration-reference) for every available f
## Strict validation
-OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**.
+OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**. The only root-level exception is `$schema` (string), so editors can attach JSON Schema metadata.
When validation fails:
diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md
index 0c1d91c48ad..9605730c2b0 100644
--- a/docs/refactor/strict-config.md
+++ b/docs/refactor/strict-config.md
@@ -11,7 +11,7 @@ title: "Strict Config Validation"
## Goals
-- **Reject unknown config keys everywhere** (root + nested).
+- **Reject unknown config keys everywhere** (root + nested), except root `$schema` metadata.
- **Reject plugin config without a schema**; don’t load that plugin.
- **Remove legacy auto-migration on load**; migrations run via doctor only.
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
@@ -24,7 +24,7 @@ title: "Strict Config Validation"
## Strict validation rules
- Config must match the schema exactly at every level.
-- Unknown keys are validation errors (no passthrough at root or nested).
+- Unknown keys are validation errors (no passthrough at root or nested), except root `$schema` when it is a string.
- `plugins.entries..config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
- Unknown `channels.` keys are errors unless a plugin manifest declares the channel id.
diff --git a/src/config/config.schema-key.test.ts b/src/config/config.schema-key.test.ts
index e3cf5650e4e..effa08347fa 100644
--- a/src/config/config.schema-key.test.ts
+++ b/src/config/config.schema-key.test.ts
@@ -7,15 +7,8 @@ describe("$schema key in config (#14998)", () => {
$schema: "https://openclaw.ai/config.json",
});
expect(result.success).toBe(true);
- });
-
- it("strips $schema from parsed output so it does not leak into UI", () => {
- const result = OpenClawSchema.safeParse({
- $schema: "https://openclaw.ai/config.json",
- });
- expect(result.success).toBe(true);
if (result.success) {
- expect("$schema" in result.data).toBe(false);
+ expect(result.data.$schema).toBe("https://openclaw.ai/config.json");
}
});
@@ -24,8 +17,8 @@ describe("$schema key in config (#14998)", () => {
expect(result.success).toBe(true);
});
- it("ignores non-string $schema (stripped before validation)", () => {
+ it("rejects non-string $schema", () => {
const result = OpenClawSchema.safeParse({ $schema: 123 });
- expect(result.success).toBe(true);
+ expect(result.success).toBe(false);
});
});
diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts
index e59eb2a9a74..98a6065cb31 100644
--- a/src/config/schema.test.ts
+++ b/src/config/schema.test.ts
@@ -7,6 +7,7 @@ describe("config schema", () => {
const schema = res.schema as { properties?: Record };
expect(schema.properties?.gateway).toBeTruthy();
expect(schema.properties?.agents).toBeTruthy();
+ expect(schema.properties?.$schema).toBeUndefined();
expect(res.uiHints.gateway?.label).toBe("Gateway");
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
expect(res.version).toBeTruthy();
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 8af49bce47d..f3ae6bf2fa0 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -303,6 +303,12 @@ function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
if (!root || !root.properties) {
return next;
}
+ // Allow `$schema` in config files for editor tooling, but hide it from the
+ // Control UI form schema so it does not show up as a configurable section.
+ delete root.properties.$schema;
+ if (Array.isArray(root.required)) {
+ root.required = root.required.filter((key) => key !== "$schema");
+ }
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 1d7b354ba80..517ec16de24 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -93,568 +93,555 @@ const MemorySchema = z
.strict()
.optional();
-// Strip `$schema` before strict validation so JSON Schema editor tooling
-// (e.g. VS Code) can add it without triggering unknown-key errors, while
-// keeping it out of the generated JSON Schema (avoids a spurious UI section).
-const stripDollarSchema = (val: unknown) => {
- if (val && typeof val === "object" && !Array.isArray(val) && "$schema" in val) {
- const { $schema: _, ...rest } = val as Record;
- return rest;
- }
- return val;
-};
+export const OpenClawSchema = z
+ .object({
+ $schema: z.string().optional(),
+ meta: z
+ .object({
+ lastTouchedVersion: z.string().optional(),
+ lastTouchedAt: z.string().optional(),
+ })
+ .strict()
+ .optional(),
+ env: z
+ .object({
+ shellEnv: z
+ .object({
+ enabled: z.boolean().optional(),
+ timeoutMs: z.number().int().nonnegative().optional(),
+ })
+ .strict()
+ .optional(),
+ vars: z.record(z.string(), z.string()).optional(),
+ })
+ .catchall(z.string())
+ .optional(),
+ wizard: z
+ .object({
+ lastRunAt: z.string().optional(),
+ lastRunVersion: z.string().optional(),
+ lastRunCommit: z.string().optional(),
+ lastRunCommand: z.string().optional(),
+ lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(),
+ })
+ .strict()
+ .optional(),
+ diagnostics: z
+ .object({
+ enabled: z.boolean().optional(),
+ flags: z.array(z.string()).optional(),
+ otel: z
+ .object({
+ enabled: z.boolean().optional(),
+ endpoint: z.string().optional(),
+ protocol: z.union([z.literal("http/protobuf"), z.literal("grpc")]).optional(),
+ headers: z.record(z.string(), z.string()).optional(),
+ serviceName: z.string().optional(),
+ traces: z.boolean().optional(),
+ metrics: z.boolean().optional(),
+ logs: z.boolean().optional(),
+ sampleRate: z.number().min(0).max(1).optional(),
+ flushIntervalMs: z.number().int().nonnegative().optional(),
+ })
+ .strict()
+ .optional(),
+ cacheTrace: z
+ .object({
+ enabled: z.boolean().optional(),
+ filePath: z.string().optional(),
+ includeMessages: z.boolean().optional(),
+ includePrompt: z.boolean().optional(),
+ includeSystem: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ logging: z
+ .object({
+ level: z
+ .union([
+ z.literal("silent"),
+ z.literal("fatal"),
+ z.literal("error"),
+ z.literal("warn"),
+ z.literal("info"),
+ z.literal("debug"),
+ z.literal("trace"),
+ ])
+ .optional(),
+ file: z.string().optional(),
+ consoleLevel: z
+ .union([
+ z.literal("silent"),
+ z.literal("fatal"),
+ z.literal("error"),
+ z.literal("warn"),
+ z.literal("info"),
+ z.literal("debug"),
+ z.literal("trace"),
+ ])
+ .optional(),
+ consoleStyle: z
+ .union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
+ .optional(),
+ redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(),
+ redactPatterns: z.array(z.string()).optional(),
+ })
+ .strict()
+ .optional(),
+ update: z
+ .object({
+ channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(),
+ checkOnStart: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ browser: z
+ .object({
+ enabled: z.boolean().optional(),
+ evaluateEnabled: z.boolean().optional(),
+ cdpUrl: z.string().optional(),
+ remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
+ remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
+ color: z.string().optional(),
+ executablePath: z.string().optional(),
+ headless: z.boolean().optional(),
+ noSandbox: z.boolean().optional(),
+ attachOnly: z.boolean().optional(),
+ defaultProfile: z.string().optional(),
+ snapshotDefaults: BrowserSnapshotDefaultsSchema,
+ profiles: z
+ .record(
+ z
+ .string()
+ .regex(/^[a-z0-9-]+$/, "Profile names must be alphanumeric with hyphens only"),
+ z
+ .object({
+ cdpPort: z.number().int().min(1).max(65535).optional(),
+ cdpUrl: z.string().optional(),
+ driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(),
+ color: HexColorSchema,
+ })
+ .strict()
+ .refine((value) => value.cdpPort || value.cdpUrl, {
+ message: "Profile must set cdpPort or cdpUrl",
+ }),
+ )
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ ui: z
+ .object({
+ seamColor: HexColorSchema.optional(),
+ assistant: z
+ .object({
+ name: z.string().max(50).optional(),
+ avatar: z.string().max(200).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ auth: z
+ .object({
+ profiles: z
+ .record(
+ z.string(),
+ z
+ .object({
+ provider: z.string(),
+ mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]),
+ email: z.string().optional(),
+ })
+ .strict(),
+ )
+ .optional(),
+ order: z.record(z.string(), z.array(z.string())).optional(),
+ cooldowns: z
+ .object({
+ billingBackoffHours: z.number().positive().optional(),
+ billingBackoffHoursByProvider: z.record(z.string(), z.number().positive()).optional(),
+ billingMaxHours: z.number().positive().optional(),
+ failureWindowHours: z.number().positive().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ models: ModelsConfigSchema,
+ nodeHost: NodeHostSchema,
+ agents: AgentsSchema,
+ tools: ToolsSchema,
+ bindings: BindingsSchema,
+ broadcast: BroadcastSchema,
+ audio: AudioSchema,
+ media: z
+ .object({
+ preserveFilenames: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ messages: MessagesSchema,
+ commands: CommandsSchema,
+ approvals: ApprovalsSchema,
+ session: SessionSchema,
+ cron: z
+ .object({
+ enabled: z.boolean().optional(),
+ store: z.string().optional(),
+ maxConcurrentRuns: z.number().int().positive().optional(),
+ sessionRetention: z.union([z.string(), z.literal(false)]).optional(),
+ })
+ .strict()
+ .optional(),
+ hooks: z
+ .object({
+ enabled: z.boolean().optional(),
+ path: z.string().optional(),
+ token: z.string().optional().register(sensitive),
+ defaultSessionKey: z.string().optional(),
+ allowRequestSessionKey: z.boolean().optional(),
+ allowedSessionKeyPrefixes: z.array(z.string()).optional(),
+ allowedAgentIds: z.array(z.string()).optional(),
+ maxBodyBytes: z.number().int().positive().optional(),
+ presets: z.array(z.string()).optional(),
+ transformsDir: z.string().optional(),
+ mappings: z.array(HookMappingSchema).optional(),
+ gmail: HooksGmailSchema,
+ internal: InternalHooksSchema,
+ })
+ .strict()
+ .optional(),
+ web: z
+ .object({
+ enabled: z.boolean().optional(),
+ heartbeatSeconds: z.number().int().positive().optional(),
+ reconnect: z
+ .object({
+ initialMs: z.number().positive().optional(),
+ maxMs: z.number().positive().optional(),
+ factor: z.number().positive().optional(),
+ jitter: z.number().min(0).max(1).optional(),
+ maxAttempts: z.number().int().min(0).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ channels: ChannelsSchema,
+ discovery: z
+ .object({
+ wideArea: z
+ .object({
+ enabled: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ mdns: z
+ .object({
+ mode: z.enum(["off", "minimal", "full"]).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ canvasHost: z
+ .object({
+ enabled: z.boolean().optional(),
+ root: z.string().optional(),
+ port: z.number().int().positive().optional(),
+ liveReload: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ talk: z
+ .object({
+ voiceId: z.string().optional(),
+ voiceAliases: z.record(z.string(), z.string()).optional(),
+ modelId: z.string().optional(),
+ outputFormat: z.string().optional(),
+ apiKey: z.string().optional().register(sensitive),
+ interruptOnSpeech: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ gateway: z
+ .object({
+ port: z.number().int().positive().optional(),
+ mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
+ bind: z
+ .union([
+ z.literal("auto"),
+ z.literal("lan"),
+ z.literal("loopback"),
+ z.literal("custom"),
+ z.literal("tailnet"),
+ ])
+ .optional(),
+ controlUi: z
+ .object({
+ enabled: z.boolean().optional(),
+ basePath: z.string().optional(),
+ root: z.string().optional(),
+ allowedOrigins: z.array(z.string()).optional(),
+ allowInsecureAuth: z.boolean().optional(),
+ dangerouslyDisableDeviceAuth: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ auth: z
+ .object({
+ mode: z.union([z.literal("token"), z.literal("password")]).optional(),
+ token: z.string().optional().register(sensitive),
+ password: z.string().optional().register(sensitive),
+ allowTailscale: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ trustedProxies: z.array(z.string()).optional(),
+ tools: z
+ .object({
+ deny: z.array(z.string()).optional(),
+ allow: z.array(z.string()).optional(),
+ })
+ .strict()
+ .optional(),
+ tailscale: z
+ .object({
+ mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
+ resetOnExit: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ remote: z
+ .object({
+ url: z.string().optional(),
+ transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
+ token: z.string().optional().register(sensitive),
+ password: z.string().optional().register(sensitive),
+ tlsFingerprint: z.string().optional(),
+ sshTarget: z.string().optional(),
+ sshIdentity: z.string().optional(),
+ })
+ .strict()
+ .optional(),
+ reload: z
+ .object({
+ mode: z
+ .union([
+ z.literal("off"),
+ z.literal("restart"),
+ z.literal("hot"),
+ z.literal("hybrid"),
+ ])
+ .optional(),
+ debounceMs: z.number().int().min(0).optional(),
+ })
+ .strict()
+ .optional(),
+ tls: z
+ .object({
+ enabled: z.boolean().optional(),
+ autoGenerate: z.boolean().optional(),
+ certPath: z.string().optional(),
+ keyPath: z.string().optional(),
+ caPath: z.string().optional(),
+ })
+ .optional(),
+ http: z
+ .object({
+ endpoints: z
+ .object({
+ chatCompletions: z
+ .object({
+ enabled: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
+ responses: z
+ .object({
+ enabled: z.boolean().optional(),
+ maxBodyBytes: z.number().int().positive().optional(),
+ maxUrlParts: z.number().int().nonnegative().optional(),
+ files: z
+ .object({
+ allowUrl: z.boolean().optional(),
+ urlAllowlist: z.array(z.string()).optional(),
+ allowedMimes: z.array(z.string()).optional(),
+ maxBytes: z.number().int().positive().optional(),
+ maxChars: z.number().int().positive().optional(),
+ maxRedirects: z.number().int().nonnegative().optional(),
+ timeoutMs: z.number().int().positive().optional(),
+ pdf: z
+ .object({
+ maxPages: z.number().int().positive().optional(),
+ maxPixels: z.number().int().positive().optional(),
+ minTextChars: z.number().int().nonnegative().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ images: z
+ .object({
+ allowUrl: z.boolean().optional(),
+ urlAllowlist: z.array(z.string()).optional(),
+ allowedMimes: z.array(z.string()).optional(),
+ maxBytes: z.number().int().positive().optional(),
+ maxRedirects: z.number().int().nonnegative().optional(),
+ timeoutMs: z.number().int().positive().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ nodes: z
+ .object({
+ browser: z
+ .object({
+ mode: z
+ .union([z.literal("auto"), z.literal("manual"), z.literal("off")])
+ .optional(),
+ node: z.string().optional(),
+ })
+ .strict()
+ .optional(),
+ allowCommands: z.array(z.string()).optional(),
+ denyCommands: z.array(z.string()).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ memory: MemorySchema,
+ skills: z
+ .object({
+ allowBundled: z.array(z.string()).optional(),
+ load: z
+ .object({
+ extraDirs: z.array(z.string()).optional(),
+ watch: z.boolean().optional(),
+ watchDebounceMs: z.number().int().min(0).optional(),
+ })
+ .strict()
+ .optional(),
+ install: z
+ .object({
+ preferBrew: z.boolean().optional(),
+ nodeManager: z
+ .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun")])
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ entries: z
+ .record(
+ z.string(),
+ z
+ .object({
+ enabled: z.boolean().optional(),
+ apiKey: z.string().optional().register(sensitive),
+ env: z.record(z.string(), z.string()).optional(),
+ config: z.record(z.string(), z.unknown()).optional(),
+ })
+ .strict(),
+ )
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ plugins: z
+ .object({
+ enabled: z.boolean().optional(),
+ allow: z.array(z.string()).optional(),
+ deny: z.array(z.string()).optional(),
+ load: z
+ .object({
+ paths: z.array(z.string()).optional(),
+ })
+ .strict()
+ .optional(),
+ slots: z
+ .object({
+ memory: z.string().optional(),
+ })
+ .strict()
+ .optional(),
+ entries: z
+ .record(
+ z.string(),
+ z
+ .object({
+ enabled: z.boolean().optional(),
+ config: z.record(z.string(), z.unknown()).optional(),
+ })
+ .strict(),
+ )
+ .optional(),
+ installs: z
+ .record(
+ z.string(),
+ z
+ .object({
+ source: z.union([z.literal("npm"), z.literal("archive"), z.literal("path")]),
+ spec: z.string().optional(),
+ sourcePath: z.string().optional(),
+ installPath: z.string().optional(),
+ version: z.string().optional(),
+ installedAt: z.string().optional(),
+ })
+ .strict(),
+ )
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .superRefine((cfg, ctx) => {
+ const agents = cfg.agents?.list ?? [];
+ if (agents.length === 0) {
+ return;
+ }
+ const agentIds = new Set(agents.map((agent) => agent.id));
-export const OpenClawSchema = z.preprocess(
- stripDollarSchema,
- z
- .object({
- meta: z
- .object({
- lastTouchedVersion: z.string().optional(),
- lastTouchedAt: z.string().optional(),
- })
- .strict()
- .optional(),
- env: z
- .object({
- shellEnv: z
- .object({
- enabled: z.boolean().optional(),
- timeoutMs: z.number().int().nonnegative().optional(),
- })
- .strict()
- .optional(),
- vars: z.record(z.string(), z.string()).optional(),
- })
- .catchall(z.string())
- .optional(),
- wizard: z
- .object({
- lastRunAt: z.string().optional(),
- lastRunVersion: z.string().optional(),
- lastRunCommit: z.string().optional(),
- lastRunCommand: z.string().optional(),
- lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(),
- })
- .strict()
- .optional(),
- diagnostics: z
- .object({
- enabled: z.boolean().optional(),
- flags: z.array(z.string()).optional(),
- otel: z
- .object({
- enabled: z.boolean().optional(),
- endpoint: z.string().optional(),
- protocol: z.union([z.literal("http/protobuf"), z.literal("grpc")]).optional(),
- headers: z.record(z.string(), z.string()).optional(),
- serviceName: z.string().optional(),
- traces: z.boolean().optional(),
- metrics: z.boolean().optional(),
- logs: z.boolean().optional(),
- sampleRate: z.number().min(0).max(1).optional(),
- flushIntervalMs: z.number().int().nonnegative().optional(),
- })
- .strict()
- .optional(),
- cacheTrace: z
- .object({
- enabled: z.boolean().optional(),
- filePath: z.string().optional(),
- includeMessages: z.boolean().optional(),
- includePrompt: z.boolean().optional(),
- includeSystem: z.boolean().optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- logging: z
- .object({
- level: z
- .union([
- z.literal("silent"),
- z.literal("fatal"),
- z.literal("error"),
- z.literal("warn"),
- z.literal("info"),
- z.literal("debug"),
- z.literal("trace"),
- ])
- .optional(),
- file: z.string().optional(),
- consoleLevel: z
- .union([
- z.literal("silent"),
- z.literal("fatal"),
- z.literal("error"),
- z.literal("warn"),
- z.literal("info"),
- z.literal("debug"),
- z.literal("trace"),
- ])
- .optional(),
- consoleStyle: z
- .union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
- .optional(),
- redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(),
- redactPatterns: z.array(z.string()).optional(),
- })
- .strict()
- .optional(),
- update: z
- .object({
- channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(),
- checkOnStart: z.boolean().optional(),
- })
- .strict()
- .optional(),
- browser: z
- .object({
- enabled: z.boolean().optional(),
- evaluateEnabled: z.boolean().optional(),
- cdpUrl: z.string().optional(),
- remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
- remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
- color: z.string().optional(),
- executablePath: z.string().optional(),
- headless: z.boolean().optional(),
- noSandbox: z.boolean().optional(),
- attachOnly: z.boolean().optional(),
- defaultProfile: z.string().optional(),
- snapshotDefaults: BrowserSnapshotDefaultsSchema,
- profiles: z
- .record(
- z
- .string()
- .regex(/^[a-z0-9-]+$/, "Profile names must be alphanumeric with hyphens only"),
- z
- .object({
- cdpPort: z.number().int().min(1).max(65535).optional(),
- cdpUrl: z.string().optional(),
- driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(),
- color: HexColorSchema,
- })
- .strict()
- .refine((value) => value.cdpPort || value.cdpUrl, {
- message: "Profile must set cdpPort or cdpUrl",
- }),
- )
- .optional(),
- })
- .strict()
- .optional(),
- ui: z
- .object({
- seamColor: HexColorSchema.optional(),
- assistant: z
- .object({
- name: z.string().max(50).optional(),
- avatar: z.string().max(200).optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- auth: z
- .object({
- profiles: z
- .record(
- z.string(),
- z
- .object({
- provider: z.string(),
- mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]),
- email: z.string().optional(),
- })
- .strict(),
- )
- .optional(),
- order: z.record(z.string(), z.array(z.string())).optional(),
- cooldowns: z
- .object({
- billingBackoffHours: z.number().positive().optional(),
- billingBackoffHoursByProvider: z.record(z.string(), z.number().positive()).optional(),
- billingMaxHours: z.number().positive().optional(),
- failureWindowHours: z.number().positive().optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- models: ModelsConfigSchema,
- nodeHost: NodeHostSchema,
- agents: AgentsSchema,
- tools: ToolsSchema,
- bindings: BindingsSchema,
- broadcast: BroadcastSchema,
- audio: AudioSchema,
- media: z
- .object({
- preserveFilenames: z.boolean().optional(),
- })
- .strict()
- .optional(),
- messages: MessagesSchema,
- commands: CommandsSchema,
- approvals: ApprovalsSchema,
- session: SessionSchema,
- cron: z
- .object({
- enabled: z.boolean().optional(),
- store: z.string().optional(),
- maxConcurrentRuns: z.number().int().positive().optional(),
- sessionRetention: z.union([z.string(), z.literal(false)]).optional(),
- })
- .strict()
- .optional(),
- hooks: z
- .object({
- enabled: z.boolean().optional(),
- path: z.string().optional(),
- token: z.string().optional().register(sensitive),
- defaultSessionKey: z.string().optional(),
- allowRequestSessionKey: z.boolean().optional(),
- allowedSessionKeyPrefixes: z.array(z.string()).optional(),
- allowedAgentIds: z.array(z.string()).optional(),
- maxBodyBytes: z.number().int().positive().optional(),
- presets: z.array(z.string()).optional(),
- transformsDir: z.string().optional(),
- mappings: z.array(HookMappingSchema).optional(),
- gmail: HooksGmailSchema,
- internal: InternalHooksSchema,
- })
- .strict()
- .optional(),
- web: z
- .object({
- enabled: z.boolean().optional(),
- heartbeatSeconds: z.number().int().positive().optional(),
- reconnect: z
- .object({
- initialMs: z.number().positive().optional(),
- maxMs: z.number().positive().optional(),
- factor: z.number().positive().optional(),
- jitter: z.number().min(0).max(1).optional(),
- maxAttempts: z.number().int().min(0).optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- channels: ChannelsSchema,
- discovery: z
- .object({
- wideArea: z
- .object({
- enabled: z.boolean().optional(),
- })
- .strict()
- .optional(),
- mdns: z
- .object({
- mode: z.enum(["off", "minimal", "full"]).optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- canvasHost: z
- .object({
- enabled: z.boolean().optional(),
- root: z.string().optional(),
- port: z.number().int().positive().optional(),
- liveReload: z.boolean().optional(),
- })
- .strict()
- .optional(),
- talk: z
- .object({
- voiceId: z.string().optional(),
- voiceAliases: z.record(z.string(), z.string()).optional(),
- modelId: z.string().optional(),
- outputFormat: z.string().optional(),
- apiKey: z.string().optional().register(sensitive),
- interruptOnSpeech: z.boolean().optional(),
- })
- .strict()
- .optional(),
- gateway: z
- .object({
- port: z.number().int().positive().optional(),
- mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
- bind: z
- .union([
- z.literal("auto"),
- z.literal("lan"),
- z.literal("loopback"),
- z.literal("custom"),
- z.literal("tailnet"),
- ])
- .optional(),
- controlUi: z
- .object({
- enabled: z.boolean().optional(),
- basePath: z.string().optional(),
- root: z.string().optional(),
- allowedOrigins: z.array(z.string()).optional(),
- allowInsecureAuth: z.boolean().optional(),
- dangerouslyDisableDeviceAuth: z.boolean().optional(),
- })
- .strict()
- .optional(),
- auth: z
- .object({
- mode: z.union([z.literal("token"), z.literal("password")]).optional(),
- token: z.string().optional().register(sensitive),
- password: z.string().optional().register(sensitive),
- allowTailscale: z.boolean().optional(),
- })
- .strict()
- .optional(),
- trustedProxies: z.array(z.string()).optional(),
- tools: z
- .object({
- deny: z.array(z.string()).optional(),
- allow: z.array(z.string()).optional(),
- })
- .strict()
- .optional(),
- tailscale: z
- .object({
- mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
- resetOnExit: z.boolean().optional(),
- })
- .strict()
- .optional(),
- remote: z
- .object({
- url: z.string().optional(),
- transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
- token: z.string().optional().register(sensitive),
- password: z.string().optional().register(sensitive),
- tlsFingerprint: z.string().optional(),
- sshTarget: z.string().optional(),
- sshIdentity: z.string().optional(),
- })
- .strict()
- .optional(),
- reload: z
- .object({
- mode: z
- .union([
- z.literal("off"),
- z.literal("restart"),
- z.literal("hot"),
- z.literal("hybrid"),
- ])
- .optional(),
- debounceMs: z.number().int().min(0).optional(),
- })
- .strict()
- .optional(),
- tls: z
- .object({
- enabled: z.boolean().optional(),
- autoGenerate: z.boolean().optional(),
- certPath: z.string().optional(),
- keyPath: z.string().optional(),
- caPath: z.string().optional(),
- })
- .optional(),
- http: z
- .object({
- endpoints: z
- .object({
- chatCompletions: z
- .object({
- enabled: z.boolean().optional(),
- })
- .strict()
- .optional(),
- responses: z
- .object({
- enabled: z.boolean().optional(),
- maxBodyBytes: z.number().int().positive().optional(),
- maxUrlParts: z.number().int().nonnegative().optional(),
- files: z
- .object({
- allowUrl: z.boolean().optional(),
- urlAllowlist: z.array(z.string()).optional(),
- allowedMimes: z.array(z.string()).optional(),
- maxBytes: z.number().int().positive().optional(),
- maxChars: z.number().int().positive().optional(),
- maxRedirects: z.number().int().nonnegative().optional(),
- timeoutMs: z.number().int().positive().optional(),
- pdf: z
- .object({
- maxPages: z.number().int().positive().optional(),
- maxPixels: z.number().int().positive().optional(),
- minTextChars: z.number().int().nonnegative().optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- images: z
- .object({
- allowUrl: z.boolean().optional(),
- urlAllowlist: z.array(z.string()).optional(),
- allowedMimes: z.array(z.string()).optional(),
- maxBytes: z.number().int().positive().optional(),
- maxRedirects: z.number().int().nonnegative().optional(),
- timeoutMs: z.number().int().positive().optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- nodes: z
- .object({
- browser: z
- .object({
- mode: z
- .union([z.literal("auto"), z.literal("manual"), z.literal("off")])
- .optional(),
- node: z.string().optional(),
- })
- .strict()
- .optional(),
- allowCommands: z.array(z.string()).optional(),
- denyCommands: z.array(z.string()).optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .optional(),
- memory: MemorySchema,
- skills: z
- .object({
- allowBundled: z.array(z.string()).optional(),
- load: z
- .object({
- extraDirs: z.array(z.string()).optional(),
- watch: z.boolean().optional(),
- watchDebounceMs: z.number().int().min(0).optional(),
- })
- .strict()
- .optional(),
- install: z
- .object({
- preferBrew: z.boolean().optional(),
- nodeManager: z
- .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun")])
- .optional(),
- })
- .strict()
- .optional(),
- entries: z
- .record(
- z.string(),
- z
- .object({
- enabled: z.boolean().optional(),
- apiKey: z.string().optional().register(sensitive),
- env: z.record(z.string(), z.string()).optional(),
- config: z.record(z.string(), z.unknown()).optional(),
- })
- .strict(),
- )
- .optional(),
- })
- .strict()
- .optional(),
- plugins: z
- .object({
- enabled: z.boolean().optional(),
- allow: z.array(z.string()).optional(),
- deny: z.array(z.string()).optional(),
- load: z
- .object({
- paths: z.array(z.string()).optional(),
- })
- .strict()
- .optional(),
- slots: z
- .object({
- memory: z.string().optional(),
- })
- .strict()
- .optional(),
- entries: z
- .record(
- z.string(),
- z
- .object({
- enabled: z.boolean().optional(),
- config: z.record(z.string(), z.unknown()).optional(),
- })
- .strict(),
- )
- .optional(),
- installs: z
- .record(
- z.string(),
- z
- .object({
- source: z.union([z.literal("npm"), z.literal("archive"), z.literal("path")]),
- spec: z.string().optional(),
- sourcePath: z.string().optional(),
- installPath: z.string().optional(),
- version: z.string().optional(),
- installedAt: z.string().optional(),
- })
- .strict(),
- )
- .optional(),
- })
- .strict()
- .optional(),
- })
- .strict()
- .superRefine((cfg, ctx) => {
- const agents = cfg.agents?.list ?? [];
- if (agents.length === 0) {
- return;
+ const broadcast = cfg.broadcast;
+ if (!broadcast) {
+ return;
+ }
+
+ for (const [peerId, ids] of Object.entries(broadcast)) {
+ if (peerId === "strategy") {
+ continue;
}
- const agentIds = new Set(agents.map((agent) => agent.id));
-
- const broadcast = cfg.broadcast;
- if (!broadcast) {
- return;
+ if (!Array.isArray(ids)) {
+ continue;
}
-
- for (const [peerId, ids] of Object.entries(broadcast)) {
- if (peerId === "strategy") {
- continue;
- }
- if (!Array.isArray(ids)) {
- continue;
- }
- for (let idx = 0; idx < ids.length; idx += 1) {
- const agentId = ids[idx];
- if (!agentIds.has(agentId)) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ["broadcast", peerId, idx],
- message: `Unknown agent id "${agentId}" (not in agents.list).`,
- });
- }
+ for (let idx = 0; idx < ids.length; idx += 1) {
+ const agentId = ids[idx];
+ if (!agentIds.has(agentId)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["broadcast", peerId, idx],
+ message: `Unknown agent id "${agentId}" (not in agents.list).`,
+ });
}
}
- }),
-);
+ }
+ });