Introduces src/runtime/env.ts with a pure sanitizedEnv() function that strips DISCORD_BOT_TOKEN, DISCORD_CLIENT_SECRET, DISCORD_PUBLIC_KEY, and all keys matching /^(DISCLAW_SECRET_|SECRET_|TOKEN_|API_KEY_?)/i before passing the environment to spawned Claude Code child processes. Adds optional env_blocklist field to DisclawConfig. Runner now passes DISCLAW_AGENT_NAME and respects the blocklist. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
263 lines
7 KiB
TypeScript
263 lines
7 KiB
TypeScript
import spawn from "cross-spawn";
|
|
import * as path from "path";
|
|
import type { Conversation } from "../db/database";
|
|
import { loadAgentIdentity } from "./identity";
|
|
import { loadDisclawConfig } from "../config/loader";
|
|
import { resolveClaude } from "../runtime/resolve-claude";
|
|
import { sanitizedEnv } from "../runtime/env";
|
|
|
|
export interface RunAgentOptions {
|
|
workspacePath: string;
|
|
channelName: string;
|
|
userMessage: string;
|
|
conversationHistory: Conversation[];
|
|
abortController?: AbortController;
|
|
}
|
|
|
|
/**
|
|
* Resolves the Claude CLI timeout from configuration.
|
|
*/
|
|
function resolveTimeoutMs(): number {
|
|
try {
|
|
const rootDir = path.resolve(__dirname, "../..");
|
|
const config = loadDisclawConfig(rootDir);
|
|
if (config.claude_timeout_seconds) {
|
|
return config.claude_timeout_seconds * 1000;
|
|
}
|
|
} catch {
|
|
// Config not available, use default
|
|
}
|
|
return 120_000;
|
|
}
|
|
|
|
/**
|
|
* Builds the full prompt string including conversation history context.
|
|
* The agent identity is handled by CLAUDE.md in the workspace directory,
|
|
* which Claude Code reads automatically.
|
|
*/
|
|
function buildPrompt(
|
|
userMessage: string,
|
|
conversationHistory: Conversation[],
|
|
channelName: string
|
|
): string {
|
|
const parts: string[] = [];
|
|
|
|
// Add conversation history if present (oldest first)
|
|
const history = [...conversationHistory].reverse();
|
|
if (history.length > 0) {
|
|
parts.push("Previous conversation context:");
|
|
parts.push("");
|
|
for (const msg of history) {
|
|
const author = msg.author_name ?? msg.role;
|
|
parts.push(`[${msg.role}] ${author}: ${msg.content}`);
|
|
parts.push("");
|
|
}
|
|
parts.push("---");
|
|
parts.push("");
|
|
}
|
|
|
|
// Add runtime context
|
|
parts.push(`Discord channel: #${channelName}`);
|
|
parts.push(`Date: ${new Date().toISOString().split("T")[0]}`);
|
|
parts.push("");
|
|
|
|
// Add the actual user message
|
|
parts.push(`New message:`);
|
|
parts.push(userMessage);
|
|
|
|
return parts.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Parses the JSON output from `claude -p --output-format json`.
|
|
* Extracts the text content from the response.
|
|
*
|
|
* The JSON output format contains a "result" field with the response text,
|
|
* or an array of content blocks with type "text".
|
|
*/
|
|
function parseClaudeJsonOutput(raw: string): string {
|
|
const trimmed = raw.trim();
|
|
|
|
if (!trimmed) {
|
|
return "(no response from agent)";
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
|
|
// Format 1: { result: "text" }
|
|
if (typeof parsed.result === "string") {
|
|
return parsed.result;
|
|
}
|
|
|
|
// Format 2: { content: [{ type: "text", text: "..." }] } or similar
|
|
if (Array.isArray(parsed.content)) {
|
|
const texts: string[] = [];
|
|
for (const block of parsed.content) {
|
|
if (block && block.type === "text" && typeof block.text === "string") {
|
|
texts.push(block.text);
|
|
}
|
|
}
|
|
if (texts.length > 0) {
|
|
return texts.join("\n");
|
|
}
|
|
}
|
|
|
|
// Format 3: Direct array of content blocks
|
|
if (Array.isArray(parsed)) {
|
|
const texts: string[] = [];
|
|
for (const block of parsed) {
|
|
if (block && block.type === "text" && typeof block.text === "string") {
|
|
texts.push(block.text);
|
|
}
|
|
}
|
|
if (texts.length > 0) {
|
|
return texts.join("\n");
|
|
}
|
|
}
|
|
|
|
// Fallback: stringify the whole response
|
|
return typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2);
|
|
} catch {
|
|
// Not valid JSON -- return the raw output as plain text
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs the Claude Code CLI as a child process and returns the text response.
|
|
*
|
|
* The CLI is invoked with:
|
|
* claude -p "prompt" --output-format json
|
|
*
|
|
* The working directory is set to the workspace path so Claude Code
|
|
* automatically reads the CLAUDE.md file for agent identity and context.
|
|
*
|
|
* Timeout defaults to 120 seconds. The process is killed if it exceeds
|
|
* the timeout.
|
|
*/
|
|
export async function runAgent(options: RunAgentOptions): Promise<string> {
|
|
const {
|
|
workspacePath,
|
|
channelName,
|
|
userMessage,
|
|
conversationHistory,
|
|
abortController,
|
|
} = options;
|
|
|
|
// Verify the workspace has an agent.yaml (validates it exists) and get agent name
|
|
const identity = loadAgentIdentity(workspacePath);
|
|
|
|
const claudeCommand = await resolveClaude();
|
|
const prompt = buildPrompt(userMessage, conversationHistory, channelName);
|
|
const timeoutMs = resolveTimeoutMs();
|
|
|
|
// Load config for env_blocklist
|
|
let envBlocklist: string[] = [];
|
|
try {
|
|
const rootDir = path.resolve(__dirname, "../..");
|
|
const config = loadDisclawConfig(rootDir);
|
|
envBlocklist = config.env_blocklist;
|
|
} catch {
|
|
// Config not available, use empty blocklist
|
|
}
|
|
|
|
return new Promise<string>((resolve, reject) => {
|
|
const args = [
|
|
"-p",
|
|
prompt,
|
|
"--output-format",
|
|
"json",
|
|
];
|
|
|
|
const child = spawn(claudeCommand, args, {
|
|
cwd: workspacePath,
|
|
env: sanitizedEnv(
|
|
{ DISCLAW_AGENT_NAME: identity.name },
|
|
envBlocklist
|
|
),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let killed = false;
|
|
|
|
// Collect stdout
|
|
if (child.stdout) {
|
|
child.stdout.setEncoding("utf8");
|
|
child.stdout.on("data", (data: string) => {
|
|
stdout += data;
|
|
});
|
|
}
|
|
|
|
// Collect stderr
|
|
if (child.stderr) {
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (data: string) => {
|
|
stderr += data;
|
|
});
|
|
}
|
|
|
|
// Timeout handling
|
|
const timer = setTimeout(() => {
|
|
killed = true;
|
|
child.kill("SIGTERM");
|
|
// Give it a moment to die gracefully, then force kill
|
|
setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// Already dead
|
|
}
|
|
}, 5000);
|
|
}, timeoutMs);
|
|
|
|
// Abort controller support
|
|
if (abortController) {
|
|
abortController.signal.addEventListener("abort", () => {
|
|
killed = true;
|
|
child.kill("SIGTERM");
|
|
});
|
|
}
|
|
|
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
clearTimeout(timer);
|
|
if (err.code === "ENOENT") {
|
|
reject(
|
|
new Error(
|
|
`Claude Code CLI not found at "${claudeCommand}". ` +
|
|
`Install it with: npm install -g @anthropic-ai/claude-code\n` +
|
|
`Or set CLAUDE_PATH in your .env file.`
|
|
)
|
|
);
|
|
} else {
|
|
reject(new Error(`Failed to start Claude Code CLI: ${err.message}`));
|
|
}
|
|
});
|
|
|
|
child.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
|
|
if (killed) {
|
|
reject(new Error(`Claude Code CLI timed out after ${timeoutMs / 1000} seconds.`));
|
|
return;
|
|
}
|
|
|
|
if (code !== 0) {
|
|
const errorDetail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
|
reject(new Error(`Claude Code CLI error: ${errorDetail}`));
|
|
return;
|
|
}
|
|
|
|
const response = parseClaudeJsonOutput(stdout);
|
|
resolve(response);
|
|
});
|
|
|
|
// Close stdin immediately since we pass everything via args
|
|
if (child.stdin) {
|
|
child.stdin.end();
|
|
}
|
|
});
|
|
}
|