Compare commits
7 commits
7ddc22dc0f
...
90f96bdbf4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f96bdbf4 | ||
|
|
57cd3d4a43 | ||
| 67433d63fa | |||
|
|
aeb4bd5b59 | ||
| 419d0990bf | |||
|
|
3dd75ddc30 | ||
|
|
5718284518 |
13 changed files with 410 additions and 81 deletions
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -13,7 +13,8 @@
|
||||||
"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",
|
||||||
|
|
@ -1862,15 +1863,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1883,6 +1875,15 @@
|
||||||
"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",
|
||||||
|
|
@ -2756,7 +2757,8 @@
|
||||||
"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",
|
||||||
|
|
@ -2885,13 +2887,6 @@
|
||||||
"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",
|
||||||
|
|
@ -2931,6 +2926,13 @@
|
||||||
"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",
|
||||||
|
|
@ -4343,7 +4345,15 @@
|
||||||
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"",
|
||||||
"start": "node dist/index.js",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
|
@ -24,7 +24,8 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,17 @@
|
||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata stored in agent.yaml -- DisClaw-specific fields only.
|
* Loads agent.yaml from the workspace folder and returns the validated identity.
|
||||||
* 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): AgentIdentity {
|
export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml {
|
||||||
const yamlPath = path.join(workspacePath, "agent.yaml");
|
const yamlPath = path.join(workspacePath, "agent.yaml");
|
||||||
|
|
||||||
if (!fs.existsSync(yamlPath)) {
|
if (!fs.existsSync(yamlPath)) {
|
||||||
|
|
@ -26,7 +19,20 @@ export function loadAgentIdentity(workspacePath: string): AgentIdentity {
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(yamlPath, "utf-8");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
19
src/agent/schema.ts
Normal file
19
src/agent/schema.ts
Normal file
|
|
@ -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<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>;
|
||||||
|
|
@ -3,19 +3,15 @@ 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;
|
||||||
|
|
@ -78,18 +74,25 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(configPath, "utf-8");
|
const raw = fs.readFileSync(configPath, "utf-8");
|
||||||
const parsed = YAML.parse(raw) as Partial<DisclawConfig>;
|
const rawParsed = YAML.parse(raw);
|
||||||
|
|
||||||
// Apply defaults
|
let validated: DisclawConfig;
|
||||||
const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces";
|
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 = {
|
const config: DisclawConfig = {
|
||||||
workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir),
|
...validated,
|
||||||
management_channel: parsed.management_channel ?? "disclaw",
|
workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir),
|
||||||
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;
|
||||||
|
|
|
||||||
12
src/config/schema.ts
Normal file
12
src/config/schema.ts
Normal file
|
|
@ -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<typeof DisclawConfigSchema>;
|
||||||
|
|
@ -2,29 +2,7 @@ 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_SQL = `
|
const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8");
|
||||||
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;
|
||||||
|
|
@ -61,7 +39,7 @@ export class DisclawDatabase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private runMigrations(): void {
|
private runMigrations(): void {
|
||||||
this.db.exec(SCHEMA_SQL);
|
this.db.exec(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Workspace queries ---
|
// --- Workspace queries ---
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
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,
|
||||||
|
|
@ -7,6 +11,7 @@ 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),
|
||||||
|
|
@ -17,5 +22,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
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 { sanitizeForDiscord } from "./runtime/sanitize";
|
||||||
|
|
||||||
const DISCORD_MAX_LENGTH = 2000;
|
const DISCORD_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
|
@ -110,7 +112,7 @@ export async function routeMessage(
|
||||||
let firstMsgId: string | null = null;
|
let firstMsgId: string | null = null;
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const sent = await channel.send(chunk);
|
const sent = await channel.send(sanitizeForDiscord(chunk, { home: os.homedir() }));
|
||||||
if (!firstMsgId) {
|
if (!firstMsgId) {
|
||||||
firstMsgId = sent.id;
|
firstMsgId = sent.id;
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +130,8 @@ export async function routeMessage(
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
|
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}`, { home: os.homedir() });
|
||||||
|
await channel.send(safeMsg).catch(() => {});
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(typingInterval);
|
clearInterval(typingInterval);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
src/runtime/sanitize.ts
Normal file
43
src/runtime/sanitize.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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>
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
result = result.split(opts.repoRoot).join("<disclaw>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Replace home directory with ~
|
||||||
|
if (opts.home) {
|
||||||
|
result = result.split(opts.home).join("~");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
97
tests/integration/db-init.test.ts
Normal file
97
tests/integration/db-init.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
67
tests/unit/sanitize-for-discord.test.ts
Normal file
67
tests/unit/sanitize-for-discord.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
84
tests/unit/schemas.test.ts
Normal file
84
tests/unit/schemas.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue