Compare commits

..

1 commit

Author SHA1 Message Date
Nick Tabeling
e2ef9c7c67 feat(DIS-106): RunResult discriminated union replaces Promise<string>
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Add RunResult type in src/agent/types.ts (success|timeout|cli-error|parse-error).
Runner resolves instead of rejects for all error cases.
Router handles all four status variants explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:50:34 +02:00
15 changed files with 133 additions and 674 deletions

48
package-lock.json generated
View file

@ -13,8 +13,7 @@
"discord.js": "^14.16.0", "discord.js": "^14.16.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"yaml": "^2.6.0", "yaml": "^2.6.0"
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.0",
@ -1863,6 +1862,15 @@
"node": ">=12" "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": { "node_modules/ast-v8-to-istanbul": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
@ -1875,15 +1883,6 @@
"js-tokens": "^10.0.0" "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": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -2757,8 +2756,7 @@
"node_modules/help-me": { "node_modules/help-me": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "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": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
@ -2887,6 +2885,13 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC" "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": { "node_modules/istanbul-lib-coverage": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@ -2926,13 +2931,6 @@
"node": ">=8" "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": { "node_modules/js-tokens": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@ -4345,15 +4343,7 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "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"
}
} }
} }
} }

View file

@ -4,9 +4,9 @@
"description": "Discord-bot-powered multi-agent workspace manager built on Claude Code", "description": "Discord-bot-powered multi-agent workspace manager built on Claude Code",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\" && node dist/index.js", "dev": "tsc && node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
@ -24,8 +24,7 @@
"discord.js": "^14.16.0", "discord.js": "^14.16.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"yaml": "^2.6.0", "yaml": "^2.6.0"
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.0",

View file

@ -1,17 +1,24 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as YAML from "yaml"; 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";
/** /**
* Loads agent.yaml from the workspace folder and returns the validated identity. * 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.
* Re-read on every call so edits take effect immediately. * Re-read on every call so edits take effect immediately.
*/ */
export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml { export function loadAgentIdentity(workspacePath: string): AgentIdentity {
const yamlPath = path.join(workspacePath, "agent.yaml"); const yamlPath = path.join(workspacePath, "agent.yaml");
if (!fs.existsSync(yamlPath)) { if (!fs.existsSync(yamlPath)) {
@ -19,20 +26,7 @@ export function loadAgentIdentity(workspacePath: string): import("./schema").Age
} }
const raw = fs.readFileSync(yamlPath, "utf-8"); const raw = fs.readFileSync(yamlPath, "utf-8");
const rawParsed = YAML.parse(raw); return YAML.parse(raw) as AgentIdentity;
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;
}
} }
/** /**

View file

@ -1,19 +0,0 @@
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<typeof AgentYamlSchema>;
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<typeof ClaudeJsonOutputSchema>;

View file

@ -3,15 +3,19 @@ import * as os from "os";
import * as path from "path"; import * as path from "path";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import * as YAML from "yaml"; import * as YAML from "yaml";
import { ZodError } from "zod";
import { childLogger } from "../runtime/logger"; import { childLogger } from "../runtime/logger";
import { DisclawConfigSchema } from "./schema";
import type { DisclawConfig } from "./schema";
export type { DisclawConfig } from "./schema";
const log = childLogger("config"); 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 { export interface EnvConfig {
DISCORD_BOT_TOKEN: string; DISCORD_BOT_TOKEN: string;
DISCORD_GUILD_ID?: string; DISCORD_GUILD_ID?: string;
@ -74,25 +78,18 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig {
} }
const raw = fs.readFileSync(configPath, "utf-8"); const raw = fs.readFileSync(configPath, "utf-8");
const rawParsed = YAML.parse(raw); const parsed = YAML.parse(raw) as Partial<DisclawConfig>;
let validated: DisclawConfig; // Apply defaults
try { const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces";
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 = { const config: DisclawConfig = {
...validated, workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir),
workspaces_root: expandWorkspacesRoot(validated.workspaces_root, 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,
}; };
return config; return config;

View file

@ -1,12 +0,0 @@
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<typeof DisclawConfigSchema>;

View file

@ -2,7 +2,29 @@ import Database from "better-sqlite3";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8"); 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);
`;
export interface Workspace { export interface Workspace {
id: number; id: number;
@ -39,7 +61,7 @@ export class DisclawDatabase {
} }
private runMigrations(): void { private runMigrations(): void {
this.db.exec(schema); this.db.exec(SCHEMA_SQL);
} }
// --- Workspace queries --- // --- Workspace queries ---

View file

@ -1,7 +1,3 @@
-- 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 ( CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL UNIQUE, channel_id TEXT NOT NULL UNIQUE,
@ -11,7 +7,6 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Conversations: stores per-message history for each workspace.
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id), workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
@ -22,6 +17,5 @@ CREATE TABLE IF NOT EXISTS conversations (
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 CREATE INDEX IF NOT EXISTS idx_conversations_workspace
ON conversations(workspace_id, created_at); ON conversations(workspace_id, created_at);

View file

@ -1,93 +0,0 @@
const DISCORD_HARD_LIMIT = 2000;
/**
* Splits text into chunks that fit within Discord's character limit while
* keeping Markdown fenced code blocks intact. When a chunk boundary falls
* inside a fence, the current chunk is closed with ``` and the next chunk
* reopens with ```<lang> so the fence state is preserved across chunks.
*
* @param text The full text to split.
* @param limit Soft target size per chunk (default 1900). Must be <= 2000.
*/
export function splitForDiscord(text: string, limit = 1900): string[] {
if (text.length === 0) {
return [""];
}
if (text.length <= limit) {
return [text];
}
const effectiveLimit = Math.min(limit, DISCORD_HARD_LIMIT);
const chunks: string[] = [];
const lines = text.split("\n");
let current = "";
let inFence = false;
let fenceLang = "";
const flush = () => {
// Hard-split if current exceeds the Discord hard limit (e.g. because
// a fence closure "\n```" was appended after the soft-limit check).
while (current.length > DISCORD_HARD_LIMIT) {
chunks.push(current.slice(0, DISCORD_HARD_LIMIT));
current = current.slice(DISCORD_HARD_LIMIT);
}
if (current.length > 0) {
chunks.push(current);
current = "";
}
};
for (const line of lines) {
// Detect fence toggle: line starts with ```
const fenceMatch = line.match(/^```(\S*)/);
const isFenceLine = fenceMatch !== null;
// Calculate the length this line would add to current chunk
const separator = current.length > 0 ? "\n" : "";
const addition = separator + line;
// Reserve space for a closing fence (``` + newline) when inside a code block
const fenceOverhead = inFence ? 4 : 0; // "\n```".length
if (current.length + addition.length + fenceOverhead > effectiveLimit) {
// Need to flush before adding this line
if (inFence) {
// Close the open fence before flushing
current += "\n```";
}
flush();
// Reopen fence in new chunk if we were inside one
if (inFence) {
current = "```" + fenceLang;
}
}
// Toggle fence state after deciding to flush (so reopening uses correct lang)
if (isFenceLine) {
if (!inFence) {
inFence = true;
fenceLang = fenceMatch[1] ?? "";
} else {
inFence = false;
fenceLang = "";
}
}
const sep = current.length > 0 ? "\n" : "";
current += sep + line;
// Safety: if a single line alone exceeds the hard limit, hard-split it
while (current.length > DISCORD_HARD_LIMIT) {
chunks.push(current.slice(0, DISCORD_HARD_LIMIT));
current = current.slice(DISCORD_HARD_LIMIT);
}
}
flush();
return chunks;
}

View file

@ -1,12 +1,57 @@
import * as os from "os";
import { Message, TextChannel } from "discord.js"; import { Message, TextChannel } from "discord.js";
import { DisclawDatabase } from "./db/database"; import { DisclawDatabase } from "./db/database";
import { DisclawConfig } from "./config/loader"; import { DisclawConfig } from "./config/loader";
import { runAgent } from "./agent/runner"; import { runAgent } from "./agent/runner";
import { splitForDiscord } from "./discord/split";
import { sanitizeForDiscord } from "./runtime/sanitize";
import type { RunResult } from "./agent/types"; import type { RunResult } from "./agent/types";
const DISCORD_MAX_LENGTH = 2000;
/**
* Splits a message at line breaks so each chunk stays under the Discord
* character limit. If a single line exceeds the limit, it is split at
* the character boundary as a last resort.
*/
function splitMessage(text: string): string[] {
if (text.length <= DISCORD_MAX_LENGTH) {
return [text];
}
const chunks: string[] = [];
const lines = text.split("\n");
let current = "";
for (const line of lines) {
// If adding this line would exceed the limit, flush current chunk
if (current.length + line.length + 1 > DISCORD_MAX_LENGTH) {
if (current.length > 0) {
chunks.push(current);
current = "";
}
// Handle lines that are themselves too long
if (line.length > DISCORD_MAX_LENGTH) {
let remaining = line;
while (remaining.length > DISCORD_MAX_LENGTH) {
chunks.push(remaining.slice(0, DISCORD_MAX_LENGTH));
remaining = remaining.slice(DISCORD_MAX_LENGTH);
}
if (remaining.length > 0) {
current = remaining;
}
continue;
}
}
current += (current.length > 0 ? "\n" : "") + line;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
/** /**
* Routes a Discord message to the correct agent based on channel_id. * Routes a Discord message to the correct agent based on channel_id.
* Returns true if the message was handled, false if the channel is not * Returns true if the message was handled, false if the channel is not
@ -40,11 +85,6 @@ export async function routeMessage(
// Send immediately as well // Send immediately as well
await channel.sendTyping().catch(() => {}); await channel.sendTyping().catch(() => {});
const sanitizeOpts = {
repoRoot: workspace.workspace_path,
home: os.homedir(),
};
try { try {
// Load recent conversation history for context // Load recent conversation history for context
const history = db.getConversationHistory(workspace.id, 30); const history = db.getConversationHistory(workspace.id, 30);
@ -67,14 +107,8 @@ export async function routeMessage(
message.author.username 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 sanitizedResponse = sanitizeForDiscord(result.text, sanitizeOpts);
// Split and send response // Split and send response
const chunks = splitForDiscord(sanitizedResponse); const chunks = splitMessage(result.text);
let firstMsgId: string | null = null; let firstMsgId: string | null = null;
for (const chunk of chunks) { for (const chunk of chunks) {
@ -84,13 +118,13 @@ export async function routeMessage(
} }
} }
// Store sanitized assistant response (use first message id for dedup) // Store assistant response (use first message id for dedup)
if (firstMsgId) { if (firstMsgId) {
db.addConversation( db.addConversation(
workspace.id, workspace.id,
firstMsgId, firstMsgId,
"assistant", "assistant",
sanitizedResponse, result.text,
null null
); );
} }
@ -100,7 +134,7 @@ export async function routeMessage(
.catch(() => {}); .catch(() => {});
} else if (result.status === "cli-error") { } else if (result.status === "cli-error") {
await channel await channel
.send(sanitizeForDiscord(`❌ CLI-Fehler: ${result.message}`, sanitizeOpts)) .send(`❌ CLI-Fehler: ${result.message}`)
.catch(() => {}); .catch(() => {});
} else if (result.status === "parse-error") { } else if (result.status === "parse-error") {
await channel await channel
@ -108,10 +142,9 @@ export async function routeMessage(
.catch(() => {}); .catch(() => {});
} }
} catch (error) { } catch (error) {
// Fallback for unexpected errors not captured by RunResult
const msg = error instanceof Error ? error.message : "Unbekannter Fehler"; const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
await channel await channel.send(`Fehler bei der Verarbeitung: ${msg}`).catch(() => {});
.send(sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, sanitizeOpts))
.catch(() => {});
} finally { } finally {
clearInterval(typingInterval); clearInterval(typingInterval);
} }

View file

@ -1,58 +0,0 @@
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 <disclaw>
* 2. home ~
* 3. Discord bot token pattern <redacted>
* 4. Anthropic API key pattern <redacted>
*/
/** 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, "<disclaw>");
}
}
// 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,
"<redacted>"
);
// 4. Anthropic API key — format: sk-ant-<20+ base64url chars>
result = result.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, "<redacted>");
return result;
}

View file

@ -1,97 +0,0 @@
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");
});
});

View file

@ -1,87 +0,0 @@
import { describe, it, expect } from "vitest";
import { sanitizeForDiscord } from "../../src/runtime/sanitize";
describe("sanitizeForDiscord", () => {
// (a) Repo-root path is replaced with <disclaw>
it("replaces repoRoot with <disclaw>", () => {
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>/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("<redacted>");
});
// (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("<redacted>");
});
// (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("<disclaw>/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 <disclaw>\\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 <disclaw>/src/main.ts");
});
});

View file

@ -1,84 +0,0 @@
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);
});
});

View file

@ -1,120 +0,0 @@
import { describe, it, expect } from "vitest";
import { splitForDiscord } from "../../src/discord/split";
const DISCORD_HARD_LIMIT = 2000;
describe("splitForDiscord", () => {
// (a) Short message below limit → returned as single unchanged chunk
it("returns a single chunk for short messages", () => {
const text = "Hello, world!";
const result = splitForDiscord(text);
expect(result).toHaveLength(1);
expect(result[0]).toBe(text);
});
// (b) Text exactly at the limit → single chunk
it("returns a single chunk when text is exactly at the limit", () => {
const text = "a".repeat(1900);
const result = splitForDiscord(text, 1900);
expect(result).toHaveLength(1);
expect(result[0]).toBe(text);
});
// (c) Long plain text → split at line boundaries
it("splits long plain text at line boundaries", () => {
const line = "x".repeat(100);
// 25 lines = 25*100 + 24 newlines = 2524 chars → must split with limit 1900
const text = Array.from({ length: 25 }, () => line).join("\n");
const result = splitForDiscord(text, 1900);
expect(result.length).toBeGreaterThan(1);
// Reassembled text must equal original
expect(result.join("\n")).toBe(text);
});
// (d) Code block spanning a split → fence is closed and reopened with lang tag
it("closes and reopens fenced code blocks across chunk boundaries", () => {
const preamble = "Here is some code:\n";
const fenceOpen = "```typescript\n";
// Fill enough code lines to force a split inside the fence
const codeLine = "const x = 1; // " + "y".repeat(80) + "\n";
const codeLines = Array.from({ length: 20 }, () => codeLine).join("");
const fenceClose = "```\n";
const epilogue = "End of message.";
const text = preamble + fenceOpen + codeLines + fenceClose + epilogue;
const result = splitForDiscord(text, 1900);
expect(result.length).toBeGreaterThan(1);
// Every chunk that starts mid-fence must open with ```typescript
// Every chunk that ends mid-fence must close with ```
for (let i = 0; i < result.length - 1; i++) {
const chunk = result[i];
// Count fence toggles to determine if we end inside a fence
const fenceToggles = (chunk.match(/^```/gm) ?? []).length;
if (fenceToggles % 2 === 1) {
// Odd number means we opened but didn't close — the splitter must close it
expect(chunk.trimEnd()).toMatch(/```$/);
// And the next chunk must reopen with the same language
expect(result[i + 1]).toMatch(/^```typescript/);
}
}
});
// (e) No chunk exceeds 2000 characters
it("never produces a chunk exceeding 2000 characters", () => {
const line = "z".repeat(150);
const text = Array.from({ length: 50 }, () => line).join("\n");
const result = splitForDiscord(text, 1900);
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT);
}
});
// (f) Empty input → no crash, returns consistent value
it("handles empty input without crashing", () => {
expect(() => splitForDiscord("")).not.toThrow();
const result = splitForDiscord("");
expect(Array.isArray(result)).toBe(true);
});
// Additional: fence language tag is preserved correctly
it("preserves the fence language tag when reopening after a split", () => {
const limit = 200;
const header = "Intro\n";
const fenceOpen = "```python\n";
// Enough lines to exceed 200 chars inside the fence
const codeLine = "print('hello')\n";
const codeLines = Array.from({ length: 20 }, () => codeLine).join("");
const fenceClose = "```";
const text = header + fenceOpen + codeLines + fenceClose;
const result = splitForDiscord(text, limit);
// Find the first chunk that ends with ``` (closed fence)
const closingChunk = result.find(
(c, i) => i < result.length - 1 && c.trimEnd().endsWith("```")
);
expect(closingChunk).toBeDefined();
// The chunk following a mid-fence close should start with ```python
const idx = result.indexOf(closingChunk!);
expect(result[idx + 1]).toMatch(/^```python/);
});
// Regression: fence closure "\n```" must not push a chunk beyond the 2000 hard limit
it("does not exceed 2000 chars even when fence closure is appended at limit boundary", () => {
const fenceOpen = "```typescript\n";
// Line long enough that current is near 2000 when fence closure is added
const longLine = "x".repeat(1984);
const nextLine = "y".repeat(10);
const fenceClose = "\n```";
const text = fenceOpen + longLine + "\n" + nextLine + fenceClose;
const result = splitForDiscord(text, 2000);
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT);
}
});
});