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).`, + }); } } - }), -); + } + });