disclaw/src/agent/runner.ts
Nick Tabeling 510969b2d1 fix(runner): add sanitizedEnv, remove process.env passthrough
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>
2026-04-09 10:39:06 +02:00

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