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