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..79e668b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "description": "Discord-bot-powered multi-agent workspace manager built on Claude Code", "main": "dist/index.js", "scripts": { - "build": "tsc", + "build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"", "start": "node dist/index.js", - "dev": "tsc && node dist/index.js", + "dev": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\" && node dist/index.js", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -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..3d99b26 100644 --- a/src/agent/identity.ts +++ b/src/agent/identity.ts @@ -1,24 +1,17 @@ import * as fs from "fs"; import * as path from "path"; import * as YAML from "yaml"; +import { ZodError } from "zod"; +import { AgentYamlSchema } from "./schema"; +import type { AgentYaml as AgentIdentity } 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 +19,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..b26a7f3 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -3,19 +3,15 @@ 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"; +import type { DisclawConfig } 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 +74,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/src/db/database.ts b/src/db/database.ts index 37bb4c9..2b49ddb 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -2,29 +2,7 @@ import Database from "better-sqlite3"; import * as fs from "fs"; import * as path from "path"; -const SCHEMA_SQL = ` -CREATE TABLE IF NOT EXISTS workspaces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id TEXT NOT NULL UNIQUE, - guild_id TEXT NOT NULL, - agent_name TEXT NOT NULL UNIQUE, - workspace_path TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE TABLE IF NOT EXISTS conversations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER NOT NULL REFERENCES workspaces(id), - discord_msg_id TEXT NOT NULL UNIQUE, - role TEXT NOT NULL, - content TEXT NOT NULL, - author_name TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -CREATE INDEX IF NOT EXISTS idx_conversations_workspace - ON conversations(workspace_id, created_at); -`; +const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8"); export interface Workspace { id: number; @@ -61,7 +39,7 @@ export class DisclawDatabase { } private runMigrations(): void { - this.db.exec(SCHEMA_SQL); + this.db.exec(schema); } // --- Workspace queries --- diff --git a/src/db/schema.sql b/src/db/schema.sql index 5e58204..4836d79 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,3 +1,7 @@ +-- DisClaw SQLite schema (SSOT) +-- All statements use IF NOT EXISTS for idempotent initialization. + +-- Workspaces: maps a Discord channel to a local agent workspace directory. CREATE TABLE IF NOT EXISTS workspaces ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL UNIQUE, @@ -7,6 +11,7 @@ CREATE TABLE IF NOT EXISTS workspaces ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Conversations: stores per-message history for each workspace. CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id INTEGER NOT NULL REFERENCES workspaces(id), @@ -17,5 +22,6 @@ CREATE TABLE IF NOT EXISTS conversations ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- Index for efficient conversation history retrieval ordered by time. CREATE INDEX IF NOT EXISTS idx_conversations_workspace ON conversations(workspace_id, created_at); diff --git a/src/router.ts b/src/router.ts index fb75823..f6cc89c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,8 +1,10 @@ +import * as os from "os"; import { Message, TextChannel } from "discord.js"; import { DisclawDatabase } from "./db/database"; import { DisclawConfig } from "./config/loader"; import { runAgent } from "./agent/runner"; import { splitForDiscord } from "./discord/split"; +import { sanitizeForDiscord } from "./runtime/sanitize"; /** * Routes a Discord message to the correct agent based on channel_id. @@ -58,8 +60,18 @@ export async function routeMessage( message.author.username ); + // Sanitize the full response once, then use the sanitized version + // for both Discord output and conversation history storage. + // This prevents a feedback loop where raw paths in stored history + // get fed back to the agent as context, causing it to reproduce them. + const sanitizeOpts = { + repoRoot: workspace.workspace_path, + home: os.homedir(), + }; + const sanitizedResponse = sanitizeForDiscord(response, sanitizeOpts); + // Split and send response - const chunks = splitForDiscord(response); + const chunks = splitForDiscord(sanitizedResponse); let firstMsgId: string | null = null; for (const chunk of chunks) { @@ -69,19 +81,23 @@ export async function routeMessage( } } - // Store assistant response (use first message id for dedup) + // Store sanitized assistant response (use first message id for dedup) if (firstMsgId) { db.addConversation( workspace.id, firstMsgId, "assistant", - response, + sanitizedResponse, null ); } } catch (error) { const msg = error instanceof Error ? error.message : "Unbekannter Fehler"; - await channel.send(`Fehler bei der Verarbeitung: ${msg}`).catch(() => {}); + const safeMsg = sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, { + repoRoot: workspace.workspace_path, + home: os.homedir(), + }); + await channel.send(safeMsg).catch(() => {}); } finally { clearInterval(typingInterval); } diff --git a/src/runtime/sanitize.ts b/src/runtime/sanitize.ts new file mode 100644 index 0000000..bc7431d --- /dev/null +++ b/src/runtime/sanitize.ts @@ -0,0 +1,58 @@ +export interface SanitizeOptions { + repoRoot?: string; // e.g. /home/user/.disclaw + home?: string; // os.homedir() +} + +/** + * Sanitizes agent output before it is sent to a Discord channel. + * Replaces absolute paths and sensitive tokens so they are never leaked. + * + * Replacement order is intentional — longest/most-specific patterns first: + * 1. repoRoot → + * 2. home → ~ + * 3. Discord bot token pattern → + * 4. Anthropic API key pattern → + */ +/** Returns both backslash and forward-slash variants of a path (for Windows). */ +function pathVariants(p: string): string[] { + const fwd = p.replace(/\\/g, "/"); + const bwd = p.replace(/\//g, "\\"); + return fwd === bwd ? [p] : [p, fwd, bwd]; +} + +function replaceAll(text: string, search: string, replacement: string): string { + return text.split(search).join(replacement); +} + +export function sanitizeForDiscord( + text: string, + opts: SanitizeOptions = {} +): string { + let result = text; + + // 1. Replace repo root before home so that sub-paths are caught by the + // more specific substitution first (repoRoot is usually inside home). + if (opts.repoRoot) { + for (const variant of pathVariants(opts.repoRoot)) { + result = replaceAll(result, variant, ""); + } + } + + // 2. Replace home directory with ~ (both slash styles for Windows) + if (opts.home) { + for (const variant of pathVariants(opts.home)) { + result = replaceAll(result, variant, "~"); + } + } + + // 3. Discord bot token — format: [MN]<23 base64url>.<6 base64url>.<27 base64url> + result = result.replace( + /[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g, + "" + ); + + // 4. Anthropic API key — format: sk-ant-<20+ base64url chars> + result = result.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, ""); + + return result; +} diff --git a/tests/integration/db-init.test.ts b/tests/integration/db-init.test.ts new file mode 100644 index 0000000..6a35b70 --- /dev/null +++ b/tests/integration/db-init.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { DisclawDatabase } from "../../src/db/database"; + +/** + * Integration tests for DisclawDatabase schema initialization. + * + * Each test uses a fresh SQLite file in os.tmpdir() to ensure full isolation. + * The DB file is cleaned up after each test. + */ + +function makeTmpDbPath(): string { + return path.join(os.tmpdir(), `disclaw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe("db-init: schema initialization", () => { + let dbPath: string; + let db: DisclawDatabase; + + beforeEach(() => { + dbPath = makeTmpDbPath(); + }); + + afterEach(() => { + try { + db.close(); + } catch { + // ignore if already closed + } + try { + fs.unlinkSync(dbPath); + } catch { + // best-effort cleanup + } + }); + + // (a) All expected tables exist after initialization + it("creates all expected tables on first init", () => { + db = new DisclawDatabase(dbPath); + + // Query sqlite_master for user-created tables + const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db; + const tables = rawDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("conversations"); + }); + + // (b) Initializing the same DB path twice is idempotent (IF NOT EXISTS) + it("is idempotent when initialized twice (IF NOT EXISTS)", () => { + db = new DisclawDatabase(dbPath); + db.close(); + + // Second initialization must not throw + expect(() => { + db = new DisclawDatabase(dbPath); + }).not.toThrow(); + + const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db; + const tables = rawDb + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() as { name: string }[]; + + const tableNames = tables.map((t) => t.name); + expect(tableNames).toContain("workspaces"); + expect(tableNames).toContain("conversations"); + }); + + // (c) Basic CRUD: create a workspace and retrieve it by channel_id + it("supports basic workspace CRUD", () => { + db = new DisclawDatabase(dbPath); + + const created = db.createWorkspace( + "channel-001", + "guild-001", + "test-agent", + "/tmp/test-agent" + ); + + expect(created.id).toBeGreaterThan(0); + expect(created.channel_id).toBe("channel-001"); + expect(created.guild_id).toBe("guild-001"); + expect(created.agent_name).toBe("test-agent"); + expect(created.workspace_path).toBe("/tmp/test-agent"); + expect(created.created_at).toBeTruthy(); + + const fetched = db.getWorkspaceByChannelId("channel-001"); + expect(fetched).toBeDefined(); + expect(fetched!.id).toBe(created.id); + expect(fetched!.agent_name).toBe("test-agent"); + }); +}); diff --git a/tests/unit/sanitize-for-discord.test.ts b/tests/unit/sanitize-for-discord.test.ts new file mode 100644 index 0000000..5a75e42 --- /dev/null +++ b/tests/unit/sanitize-for-discord.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeForDiscord } from "../../src/runtime/sanitize"; + +describe("sanitizeForDiscord", () => { + // (a) Repo-root path is replaced with + it("replaces repoRoot with ", () => { + const text = "Reading config from /home/user/project/disclaw.yaml"; + const result = sanitizeForDiscord(text, { repoRoot: "/home/user/project" }); + expect(result).toBe("Reading config from /disclaw.yaml"); + expect(result).not.toContain("/home/user/project"); + }); + + // (b) Home directory is replaced with ~ + it("replaces home directory with ~", () => { + const text = "Workspace located at /home/user/.disclaw/workspaces/agent-x"; + const result = sanitizeForDiscord(text, { home: "/home/user" }); + expect(result).toBe("Workspace located at ~/.disclaw/workspaces/agent-x"); + expect(result).not.toContain("/home/user"); + }); + + // (c) Discord bot token pattern is redacted + it("redacts Discord bot tokens", () => { + const token = "MTA1NjYwNDczNTA3NDU2NjE2.GbXkRa.abcdefghijklmnopqrstuvwxyz123"; + const text = `Bot initialized with token ${token} — ready.`; + const result = sanitizeForDiscord(text); + expect(result).not.toContain(token); + expect(result).toContain(""); + }); + + // (d) Anthropic API key is redacted + it("redacts Anthropic API keys", () => { + const key = "sk-ant-api03-ABCDEFGHIJ1234567890abcdefghij"; + const text = `Using key ${key} for requests.`; + const result = sanitizeForDiscord(text); + expect(result).not.toContain(key); + expect(result).toContain(""); + }); + + // (e) Harmless text is not modified (no false positives) + it("does not modify harmless text", () => { + const text = "Hello! The agent processed your request successfully in 120ms."; + const result = sanitizeForDiscord(text, { home: "/home/user", repoRoot: "/home/user/project" }); + expect(result).toBe(text); + }); + + // (f) Empty string returns empty string + it("handles empty string input", () => { + expect(sanitizeForDiscord("")).toBe(""); + expect(sanitizeForDiscord("", { home: "/home/user", repoRoot: "/home/user/project" })).toBe(""); + }); + + // (g) No opts provided — plain text stays unchanged when no token patterns present + it("returns text unchanged when no opts and no token patterns", () => { + const text = "This is a perfectly normal response from the agent."; + expect(sanitizeForDiscord(text)).toBe(text); + }); + + // Ordering: repoRoot (sub-path of home) is replaced before home + it("replaces repoRoot before home so nested paths are handled correctly", () => { + const text = "/home/user/project/src/index.ts and /home/user/other.ts"; + const result = sanitizeForDiscord(text, { + repoRoot: "/home/user/project", + home: "/home/user", + }); + expect(result).toBe("/src/index.ts and ~/other.ts"); + }); + + // Windows-style backslash paths are sanitized + it("replaces Windows backslash paths", () => { + const text = "File at C:\\Users\\dev\\.disclaw\\workspaces\\agent-x\\src\\main.ts"; + const result = sanitizeForDiscord(text, { + repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x", + home: "C:\\Users\\dev", + }); + expect(result).toBe("File at \\src\\main.ts"); + }); + + // Mixed slash styles (common on Windows when tools output forward slashes) + it("replaces forward-slash variants of Windows paths", () => { + const text = "File at C:/Users/dev/.disclaw/workspaces/agent-x/src/main.ts"; + const result = sanitizeForDiscord(text, { + repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x", + home: "C:\\Users\\dev", + }); + expect(result).toBe("File at /src/main.ts"); + }); +}); 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); + }); +});