Gefunden ueber die Brain-Logs: mehrere Nachrichten blieben unbeantwortet, weil
POST /chat mit 502 endete. Ursache:
Proxy HTTP 500: {"error":{"message":"spawn E2BIG"}}
execve() begrenzt argv+envp ZUSAMMEN auf ARG_MAX — im Proxy-Container nur
131072 Bytes (getconf ARG_MAX). Der System-Prompt wurde als einzelnes Argument
uebergeben und war im Fehlerfall 76881 Zeichen gross (gepinnte Erinnerungen +
Skills + Projektkontext) -> Start scheitert, Turn verloren, App bekommt 502.
11 solcher Faelle im aktuellen Log.
Fix: --system-prompt-file <tmpfile> statt --system-prompt <text> (die CLI
unterstuetzt das). Damit faellt die Groessengrenze praktisch weg. Die Temp-Datei
wird bei close/error/catch wieder entfernt; schlaegt das Schreiben fehl, wird
wie bisher das Argument genutzt (kleine Prompts laufen weiter).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
/**
|
|
* Claude Code CLI Subprocess Manager — ARIA-Patch
|
|
*
|
|
* Basis: claude-max-api-proxy dist/subprocess/manager.js, plus die bisher per
|
|
* sed in docker-compose.yml eingespielten Anpassungen (dangerously-skip-
|
|
* permissions, system-prompt, 24h-Timeout, Prompt via stdin) — hier fest im
|
|
* File, damit der groessere Zwischenruf-Umbau nicht per sed gefrickelt werden
|
|
* muss. Wird per `cp` ueber die npm-Version gelegt (siehe docker-compose.yml).
|
|
*
|
|
* ZWISCHENRUF (interject): Statt den Prompt als Text zu schreiben und stdin
|
|
* sofort zu schliessen (--print/text), laeuft claude jetzt im
|
|
* `--input-format stream-json`-Modus. Der initiale Prompt geht als
|
|
* stream-json User-Message rein, stdin bleibt OFFEN — so kann waehrend des
|
|
* laufenden Turns per sendMessage() eine weitere User-Message reingeschoben
|
|
* werden, die claude an der naechsten Tool-Grenze aufgreift (kein Abbruch).
|
|
* Bei 'result' (Turn fertig) wird stdin geschlossen, damit claude sauber
|
|
* beendet und die HTTP-Response (in routes.js an 'close' gebunden) rausgeht.
|
|
*/
|
|
import { spawn } from "child_process";
|
|
import { EventEmitter } from "events";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
|
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
|
export class ClaudeSubprocess extends EventEmitter {
|
|
process = null;
|
|
buffer = "";
|
|
timeoutId = null;
|
|
isKilled = false;
|
|
_stdinClosed = false;
|
|
_systemPromptFile = null;
|
|
_systemPromptDir = null;
|
|
/**
|
|
* Start the Claude CLI subprocess with the given prompt
|
|
*/
|
|
async start(prompt, options) {
|
|
const args = this.buildArgs(prompt, options);
|
|
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
// Use spawn() for security - no shell interpretation
|
|
this.process = spawn("claude", args, {
|
|
cwd: options.cwd || process.cwd(),
|
|
env: { ...process.env },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
// Set timeout
|
|
this.timeoutId = setTimeout(() => {
|
|
if (!this.isKilled) {
|
|
this.isKilled = true;
|
|
this.process?.kill("SIGTERM");
|
|
this.emit("error", new Error(`Request timed out after ${timeout}ms`));
|
|
}
|
|
}, timeout);
|
|
// Handle spawn errors (e.g., claude not found)
|
|
this.process.on("error", (err) => {
|
|
this.clearTimeout();
|
|
this._cleanupSystemPrompt();
|
|
if (err.message.includes("ENOENT")) {
|
|
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
|
}
|
|
else {
|
|
reject(err);
|
|
}
|
|
});
|
|
// stdin BLEIBT OFFEN: initialen Prompt als stream-json User-
|
|
// Message schreiben; spaetere Zwischenrufe kommen via
|
|
// sendMessage(). Geschlossen wird bei 'result' (s. processBuffer).
|
|
this._writeUserMessage(prompt);
|
|
// Falls stdin (z.B. EPIPE) frueh stirbt: nicht crashen.
|
|
this.process.stdin?.on("error", () => {});
|
|
console.error(`[Subprocess] Process spawned with PID: ${this.process.pid}`);
|
|
// Parse JSON stream from stdout
|
|
this.process.stdout?.on("data", (chunk) => {
|
|
const data = chunk.toString();
|
|
console.error(`[Subprocess] Received ${data.length} bytes of stdout`);
|
|
this.buffer += data;
|
|
this.processBuffer();
|
|
});
|
|
// Capture stderr for debugging
|
|
this.process.stderr?.on("data", (chunk) => {
|
|
const errorText = chunk.toString().trim();
|
|
if (errorText) {
|
|
// Don't emit as error unless it's actually an error
|
|
// Claude CLI may write debug info to stderr
|
|
console.error("[Subprocess stderr]:", errorText.slice(0, 200));
|
|
}
|
|
});
|
|
// Handle process close
|
|
this.process.on("close", (code) => {
|
|
console.error(`[Subprocess] Process closed with code: ${code}`);
|
|
this.clearTimeout();
|
|
this._cleanupSystemPrompt();
|
|
// Process any remaining buffer
|
|
if (this.buffer.trim()) {
|
|
this.processBuffer();
|
|
}
|
|
this.emit("close", code);
|
|
});
|
|
// Resolve immediately since we're streaming
|
|
resolve();
|
|
}
|
|
catch (err) {
|
|
this.clearTimeout();
|
|
this._cleanupSystemPrompt();
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Build CLI arguments array
|
|
*/
|
|
buildArgs(prompt, options) {
|
|
const args = [
|
|
"--print", // Non-interactive mode
|
|
"--output-format",
|
|
"stream-json", // JSON streaming output
|
|
"--verbose", // Required for stream-json
|
|
"--include-partial-messages", // Enable streaming chunks
|
|
"--input-format",
|
|
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
|
"--model",
|
|
options.model, // Model alias (opus/sonnet/haiku)
|
|
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
|
|
];
|
|
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
|
|
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
|
|
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
|
|
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
|
|
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
|
|
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
|
|
// praktisch keine Groessengrenze mehr.
|
|
this._systemPromptFile = null;
|
|
const sysPrompt = options.systemPrompt || "";
|
|
if (sysPrompt) {
|
|
try {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
|
|
const file = path.join(dir, "system-prompt.txt");
|
|
fs.writeFileSync(file, sysPrompt, "utf-8");
|
|
this._systemPromptFile = file;
|
|
this._systemPromptDir = dir;
|
|
args.push("--system-prompt-file", file);
|
|
}
|
|
catch (err) {
|
|
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
|
|
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
|
|
args.push("--system-prompt", sysPrompt);
|
|
}
|
|
}
|
|
if (options.sessionId) {
|
|
args.push("--session-id", options.sessionId);
|
|
}
|
|
return args;
|
|
}
|
|
/** Temporaere System-Prompt-Datei wieder entfernen. */
|
|
_cleanupSystemPrompt() {
|
|
try {
|
|
if (this._systemPromptFile)
|
|
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
|
|
}
|
|
catch (_) { }
|
|
this._systemPromptFile = null;
|
|
this._systemPromptDir = null;
|
|
}
|
|
/**
|
|
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
|
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
|
*/
|
|
_writeUserMessage(text) {
|
|
const p = this.process;
|
|
if (!p || !p.stdin || p.stdin.destroyed || this._stdinClosed)
|
|
return false;
|
|
try {
|
|
p.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: String(text) } }) + "\n");
|
|
return true;
|
|
}
|
|
catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Zwischenruf: waehrend eines laufenden Turns eine weitere User-Message
|
|
* reinschieben. claude greift sie an der naechsten Tool-Grenze auf, ohne
|
|
* den Turn abzubrechen. Kein Effekt, wenn stdin schon geschlossen ist
|
|
* (Turn praktisch fertig) — dann ist der Zwischenruf schlicht zu spaet.
|
|
*/
|
|
sendMessage(text) {
|
|
return this._writeUserMessage(text);
|
|
}
|
|
/**
|
|
* stdin schliessen → claude beendet den stream-json-Input und exit't.
|
|
*/
|
|
_closeStdin() {
|
|
if (this._stdinClosed)
|
|
return;
|
|
this._stdinClosed = true;
|
|
try {
|
|
this.process?.stdin?.end();
|
|
}
|
|
catch (_) { }
|
|
}
|
|
/**
|
|
* Process the buffer and emit parsed messages
|
|
*/
|
|
processBuffer() {
|
|
const lines = this.buffer.split("\n");
|
|
this.buffer = lines.pop() || ""; // Keep incomplete line
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed)
|
|
continue;
|
|
try {
|
|
const message = JSON.parse(trimmed);
|
|
this.emit("message", message);
|
|
if (isContentDelta(message)) {
|
|
// Emit content delta for streaming
|
|
this.emit("content_delta", message);
|
|
}
|
|
else if (isAssistantMessage(message)) {
|
|
this.emit("assistant", message);
|
|
}
|
|
else if (isResultMessage(message)) {
|
|
this.emit("result", message);
|
|
// Turn fertig → stdin schliessen, sonst wartet claude im
|
|
// stream-json-Input auf weitere Messages und der Prozess
|
|
// (und damit die HTTP-Response) haengt fuer immer.
|
|
this._closeStdin();
|
|
}
|
|
}
|
|
catch {
|
|
// Non-JSON output, emit as raw
|
|
this.emit("raw", trimmed);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Clear the timeout timer
|
|
*/
|
|
clearTimeout() {
|
|
if (this.timeoutId) {
|
|
clearTimeout(this.timeoutId);
|
|
this.timeoutId = null;
|
|
}
|
|
}
|
|
/**
|
|
* Kill the subprocess
|
|
*/
|
|
kill(signal = "SIGTERM") {
|
|
if (!this.isKilled && this.process) {
|
|
this.isKilled = true;
|
|
this.clearTimeout();
|
|
this.process.kill(signal);
|
|
}
|
|
}
|
|
/**
|
|
* Check if the process is still running
|
|
*/
|
|
isRunning() {
|
|
return this.process !== null && !this.isKilled && this.process.exitCode === null;
|
|
}
|
|
}
|
|
/**
|
|
* Verify that Claude CLI is installed and accessible
|
|
*/
|
|
export async function verifyClaude() {
|
|
return new Promise((resolve) => {
|
|
const proc = spawn("claude", ["--version"], { stdio: "pipe" });
|
|
let output = "";
|
|
proc.stdout?.on("data", (chunk) => {
|
|
output += chunk.toString();
|
|
});
|
|
proc.on("error", () => {
|
|
resolve({
|
|
ok: false,
|
|
error: "Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code",
|
|
});
|
|
});
|
|
proc.on("close", (code) => {
|
|
if (code === 0) {
|
|
resolve({ ok: true, version: output.trim() });
|
|
}
|
|
else {
|
|
resolve({
|
|
ok: false,
|
|
error: "Claude CLI returned non-zero exit code",
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Check if Claude CLI is authenticated
|
|
*/
|
|
export async function verifyAuth() {
|
|
return { ok: true };
|
|
}
|
|
//# sourceMappingURL=manager.js.map
|