From 5718284518e889fd9973da7876822f5b020941f6 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 17:49:44 +0200 Subject: [PATCH] feat(DIS-107): Zod schemas for disclaw.yaml, agent.yaml, CLI output Add DisclawConfigSchema and AgentYamlSchema with Zod validation. loader.ts and identity.ts validate external data at parse time. Clear error messages with field name and expected type. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 48 +++++++++++++--------- package.json | 3 +- src/agent/identity.ts | 35 +++++++++------- src/agent/schema.ts | 19 +++++++++ src/config/loader.ts | 38 +++++++++-------- src/config/schema.ts | 12 ++++++ tests/unit/schemas.test.ts | 84 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 src/agent/schema.ts create mode 100644 src/config/schema.ts create mode 100644 tests/unit/schemas.test.ts diff --git a/package-lock.json b/package-lock.json index 1b19c95..9c836aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "discord.js": "^14.16.0", "dotenv": "^16.4.0", "pino": "^10.3.1", - "yaml": "^2.6.0" + "yaml": "^2.6.0", + "zod": "^3.23.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", @@ -1862,15 +1863,6 @@ "node": ">=12" } }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", @@ -1883,6 +1875,15 @@ "js-tokens": "^10.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2756,7 +2757,8 @@ "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true }, "node_modules/html-escaper": { "version": "2.0.2", @@ -2885,13 +2887,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2931,6 +2926,13 @@ "node": ">=8" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4343,7 +4345,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } - diff --git a/package.json b/package.json index efc41ff..37ddb1c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "discord.js": "^14.16.0", "dotenv": "^16.4.0", "pino": "^10.3.1", - "yaml": "^2.6.0" + "yaml": "^2.6.0", + "zod": "^3.23.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.0", diff --git a/src/agent/identity.ts b/src/agent/identity.ts index c1997ec..89f057a 100644 --- a/src/agent/identity.ts +++ b/src/agent/identity.ts @@ -1,24 +1,16 @@ import * as fs from "fs"; import * as path from "path"; import * as YAML from "yaml"; +import { ZodError } from "zod"; +import { AgentYamlSchema } from "./schema"; + +export type { AgentYaml as AgentIdentity } from "./schema"; /** - * Metadata stored in agent.yaml -- DisClaw-specific fields only. - * Identity, personality, and instructions now live in CLAUDE.md, - * which Claude Code reads automatically. - */ -export interface AgentIdentity { - name: string; - display_name: string; - role: string; - channel_id: string; -} - -/** - * Loads agent.yaml from the workspace folder and returns the parsed identity. + * Loads agent.yaml from the workspace folder and returns the validated identity. * Re-read on every call so edits take effect immediately. */ -export function loadAgentIdentity(workspacePath: string): AgentIdentity { +export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml { const yamlPath = path.join(workspacePath, "agent.yaml"); if (!fs.existsSync(yamlPath)) { @@ -26,7 +18,20 @@ export function loadAgentIdentity(workspacePath: string): AgentIdentity { } const raw = fs.readFileSync(yamlPath, "utf-8"); - return YAML.parse(raw) as AgentIdentity; + const rawParsed = YAML.parse(raw); + + try { + return AgentYamlSchema.parse(rawParsed); + } catch (err) { + if (err instanceof ZodError) { + const first = err.errors[0]; + const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)"; + throw new Error( + `Invalid agent.yaml at ${yamlPath}: field "${fieldPath}" -- ${first.message}` + ); + } + throw err; + } } /** diff --git a/src/agent/schema.ts b/src/agent/schema.ts new file mode 100644 index 0000000..b1169c6 --- /dev/null +++ b/src/agent/schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const AgentYamlSchema = z.object({ + name: z.string().min(1), + display_name: z.string().optional(), + role: z.string().optional(), + channel_id: z.string().min(1), +}); + +export type AgentYaml = z.infer; + +export const ClaudeJsonOutputSchema = z.union([ + z.object({ result: z.string() }), + z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })) }), + z.array(z.object({ type: z.string(), text: z.string().optional() })), + z.string(), +]); + +export type ClaudeJsonOutput = z.infer; diff --git a/src/config/loader.ts b/src/config/loader.ts index 00cb55b..b8dbf1f 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -3,19 +3,14 @@ import * as os from "os"; import * as path from "path"; import * as dotenv from "dotenv"; import * as YAML from "yaml"; +import { ZodError } from "zod"; import { childLogger } from "../runtime/logger"; +import { DisclawConfigSchema } from "./schema"; + +export type { DisclawConfig } from "./schema"; const log = childLogger("config"); -export interface DisclawConfig { - workspaces_root: string; - management_channel: string; - claude_command: string; - claude_timeout_seconds: number; - env_blocklist: string[]; - max_concurrent_agents: number; -} - export interface EnvConfig { DISCORD_BOT_TOKEN: string; DISCORD_GUILD_ID?: string; @@ -78,18 +73,25 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig { } const raw = fs.readFileSync(configPath, "utf-8"); - const parsed = YAML.parse(raw) as Partial; + const rawParsed = YAML.parse(raw); - // Apply defaults - const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces"; + let validated: DisclawConfig; + try { + validated = DisclawConfigSchema.parse(rawParsed); + } catch (err) { + if (err instanceof ZodError) { + const first = err.errors[0]; + const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)"; + throw new Error( + `Invalid disclaw.yaml: field "${fieldPath}" -- ${first.message}` + ); + } + throw err; + } const config: DisclawConfig = { - workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir), - management_channel: parsed.management_channel ?? "disclaw", - claude_command: parsed.claude_command ?? "claude", - claude_timeout_seconds: parsed.claude_timeout_seconds ?? 120, - env_blocklist: parsed.env_blocklist ?? [], - max_concurrent_agents: parsed.max_concurrent_agents ?? 4, + ...validated, + workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir), }; return config; diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..fcc67cb --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const DisclawConfigSchema = z.object({ + workspaces_root: z.string().min(1), + management_channel: z.string().min(1).default("disclaw"), + claude_command: z.string().min(1).default("claude"), + claude_timeout_seconds: z.number().int().positive().default(120), + max_concurrent_agents: z.number().int().positive().default(4), + env_blocklist: z.array(z.string()).default([]), +}); + +export type DisclawConfig = z.infer; diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts new file mode 100644 index 0000000..5195629 --- /dev/null +++ b/tests/unit/schemas.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { ZodError } from "zod"; +import { DisclawConfigSchema } from "../../src/config/schema"; +import { AgentYamlSchema } from "../../src/agent/schema"; + +describe("DisclawConfigSchema", () => { + it("(a) parses valid disclaw.yaml data correctly", () => { + const input = { + workspaces_root: "/home/user/.disclaw/workspaces", + management_channel: "disclaw", + claude_command: "claude", + claude_timeout_seconds: 60, + max_concurrent_agents: 2, + env_blocklist: ["SECRET_KEY"], + }; + + const result = DisclawConfigSchema.parse(input); + + expect(result.workspaces_root).toBe("/home/user/.disclaw/workspaces"); + expect(result.management_channel).toBe("disclaw"); + expect(result.claude_command).toBe("claude"); + expect(result.claude_timeout_seconds).toBe(60); + expect(result.max_concurrent_agents).toBe(2); + expect(result.env_blocklist).toEqual(["SECRET_KEY"]); + }); + + it("(b) throws ZodError when workspaces_root is missing", () => { + const input = { + management_channel: "disclaw", + }; + + expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError); + }); + + it("(c) throws ZodError when claude_timeout_seconds is a string", () => { + const input = { + workspaces_root: "/some/path", + claude_timeout_seconds: "abc", + }; + + expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError); + }); + + it("(d) applies defaults when optional fields are not provided", () => { + const input = { + workspaces_root: "/some/path", + }; + + const result = DisclawConfigSchema.parse(input); + + expect(result.management_channel).toBe("disclaw"); + expect(result.claude_command).toBe("claude"); + expect(result.claude_timeout_seconds).toBe(120); + expect(result.max_concurrent_agents).toBe(4); + expect(result.env_blocklist).toEqual([]); + }); +}); + +describe("AgentYamlSchema", () => { + it("(e) parses valid agent.yaml data correctly", () => { + const input = { + name: "my-agent", + display_name: "My Agent", + role: "Backend developer", + channel_id: "1234567890", + }; + + const result = AgentYamlSchema.parse(input); + + expect(result.name).toBe("my-agent"); + expect(result.display_name).toBe("My Agent"); + expect(result.role).toBe("Backend developer"); + expect(result.channel_id).toBe("1234567890"); + }); + + it("(f) throws ZodError when channel_id is missing", () => { + const input = { + name: "my-agent", + role: "Backend developer", + }; + + expect(() => AgentYamlSchema.parse(input)).toThrow(ZodError); + }); +}); -- 2.45.2