Files
ttstd-infinite-canvas/canvas-agent/src/dreamina-cli-process.ts

339 lines
13 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { buildCliChildEnvironment, CliChildEnvironmentError } from "./cli-child-environment.js";
const OUTPUT_LIMIT_BYTES = 2 * 1024 * 1024;
const CLEANUP_DEADLINE_MS = 5_000;
export type DreaminaProcessRequest = {
executable: string;
args: string[];
timeoutMs: number;
completeOnJsonOutput?: (value: unknown) => boolean;
signal?: AbortSignal;
env?: Record<string, string | undefined>;
onSpawn?: (pid: number) => void;
};
export type DreaminaProcessResult = { exitCode: number | null; stdout: string; stderr: string };
export type DreaminaProcessDependencies = {
terminateProcessTree?: (child: ChildProcess) => Promise<void>;
cleanupTimeoutMs?: number;
};
export class DreaminaCliError extends Error {
constructor(readonly code: string, message: string, readonly statusCode = 500) {
super(message);
this.name = "DreaminaCliError";
}
}
const STABLE_DREAMINA_ERROR_CODES = new Set([
"dreamina_cancelled",
"dreamina_cleanup_failed",
"dreamina_command_invalid",
"dreamina_command_timeout",
"dreamina_environment_invalid",
"dreamina_generation_failed",
"dreamina_idempotency_conflict",
"dreamina_internal_error",
"dreamina_interrupted_before_submission",
"dreamina_login_failed",
"dreamina_login_pending",
"dreamina_login_required",
"dreamina_login_response_invalid",
"dreamina_logout_failed",
"dreamina_missing",
"dreamina_official_incomplete",
"dreamina_output_too_large",
"dreamina_query_failed",
"dreamina_query_response_invalid",
"dreamina_reference_budget_exceeded",
"dreamina_reference_cleanup_failed",
"dreamina_reference_invalid",
"dreamina_request_invalid",
"dreamina_spawn_failed",
"dreamina_state_busy",
"dreamina_state_fenced",
"dreamina_state_invalid",
"dreamina_submit_exit_nonzero",
"dreamina_submit_receipt_missing",
"dreamina_submit_spawn_failed",
"dreamina_submit_timeout",
"dreamina_submission_unknown",
"dreamina_task_not_found",
"dreamina_version_failed",
]);
export function isStableDreaminaErrorCode(value: string) {
return STABLE_DREAMINA_ERROR_CODES.has(value);
}
export async function runDreaminaProcess(
request: DreaminaProcessRequest,
dependencies: DreaminaProcessDependencies = {},
): Promise<DreaminaProcessResult> {
validateInvocation(request);
const environment = safeEnvironment(request.env ?? process.env);
const cleanupTimeoutMs = Number.isFinite(dependencies.cleanupTimeoutMs)
? Math.max(1, Math.min(dependencies.cleanupTimeoutMs!, CLEANUP_DEADLINE_MS))
: CLEANUP_DEADLINE_MS;
const terminateProcessTree = dependencies.terminateProcessTree ?? terminateExactProcessTree;
if (request.signal?.aborted) {
throw new DreaminaCliError("dreamina_cancelled", "Dreamina 操作已取消", 499);
}
return new Promise((resolve, reject) => {
let child: ChildProcess;
let settled = false;
let stopping = false;
let totalBytes = 0;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let timer: NodeJS.Timeout | undefined;
let childClosed = false;
let resolveChildClosed!: () => void;
const childClosedPromise = new Promise<void>((resolve) => { resolveChildClosed = resolve; });
const finish = (error?: DreaminaCliError, result?: DreaminaProcessResult) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
request.signal?.removeEventListener("abort", onAbort);
if (error) reject(error);
else resolve(result!);
};
const stop = (error: DreaminaCliError) => {
if (settled || stopping) return;
stopping = true;
if (timer) clearTimeout(timer);
request.signal?.removeEventListener("abort", onAbort);
void terminateWithDeadline(child, terminateProcessTree, childClosedPromise, () => childClosed, cleanupTimeoutMs).then(
() => finish(error),
() => finish(new DreaminaCliError(
"dreamina_cleanup_failed",
"Dreamina 进程清理失败,请检查本机进程状态",
500,
)),
);
};
const onAbort = () => stop(new DreaminaCliError("dreamina_cancelled", "Dreamina 操作已取消", 499));
const completeFromOutput = () => {
if (settled || stopping || !request.completeOnJsonOutput) return;
const value = acceptedJsonOutput(Buffer.concat(stdout).toString("utf8"), request.completeOnJsonOutput);
if (!value) return;
stopping = true;
if (timer) clearTimeout(timer);
request.signal?.removeEventListener("abort", onAbort);
void terminateWithDeadline(child, terminateProcessTree, childClosedPromise, () => childClosed, cleanupTimeoutMs).then(
() => finish(undefined, {
exitCode: 0,
stdout: value,
stderr: Buffer.concat(stderr).toString("utf8"),
}),
() => finish(new DreaminaCliError(
"dreamina_cleanup_failed",
"Dreamina 进程清理失败,请检查本机进程状态",
500,
)),
);
};
const collect = (target: Buffer[], onCollected?: () => void) => (chunk: Buffer | string) => {
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += value.byteLength;
if (totalBytes > OUTPUT_LIMIT_BYTES) {
stop(new DreaminaCliError("dreamina_output_too_large", "Dreamina 返回内容超过安全上限", 502));
return;
}
target.push(value);
onCollected?.();
};
try {
child = spawn(request.executable, request.args, {
detached: process.platform !== "win32",
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
env: environment,
});
} catch {
finish(new DreaminaCliError("dreamina_spawn_failed", "无法启动 Dreamina CLI", 503));
return;
}
child.stdout!.on("data", collect(stdout, completeFromOutput));
child.stderr!.on("data", collect(stderr));
child.once("error", () => {
if (!stopping) {
finish(new DreaminaCliError("dreamina_spawn_failed", "无法启动 Dreamina CLI", 503));
}
});
child.once("close", (exitCode) => {
childClosed = true;
resolveChildClosed();
if (!stopping) {
finish(undefined, {
exitCode,
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
});
}
});
request.signal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(
() => stop(new DreaminaCliError(
"dreamina_command_timeout",
"Dreamina 操作超时,相关进程已清理",
504,
)),
request.timeoutMs,
);
timer.unref();
if (child.pid) request.onSpawn?.(child.pid);
if (request.signal?.aborted) onAbort();
});
}
function acceptedJsonOutput(value: string, accepts: (value: unknown) => boolean) {
const trimmed = value.trim();
const candidates = [trimmed, ...trimmed.split(/\r?\n/).map((line) => line.trim())];
for (const candidate of new Set(candidates)) {
if (!candidate) continue;
try {
const parsed = JSON.parse(candidate);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && accepts(parsed)) return candidate;
} catch {
// A later chunk or line may complete the bounded JSON response.
}
}
return "";
}
export function sanitizeDreaminaDiagnostic(value: string) {
return String(value || "")
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
.replace(/(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|device[_-]?code|token|secret|password)\s*[:=]\s*["']?[^\s,"'}]+/gi, "$1=[redacted]")
.replace(/https?:\/\/[^\s"']+/gi, "[redacted-url]")
.replace(/(?:[A-Za-z]:[\\/][^\s"']+|\/(?:Users|home|root|var|tmp)\/[^\s"']+)/gi, "[redacted-path]")
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[redacted-account]")
.replace(/\b[A-Za-z0-9_-]{40,}\b/g, "[redacted]")
.slice(0, 240);
}
export async function discoverDreaminaExecutable(
env: Record<string, string | undefined> = process.env,
signal?: AbortSignal,
) {
const explicit = env.DREAMINA_CLI_PATH?.trim();
if (explicit) {
if (!path.isAbsolute(explicit) || explicit.includes("\0")) return { installed: false as const };
return fs.existsSync(explicit)
? { installed: true as const, executable: explicit }
: { installed: false as const };
}
const finder = process.platform === "win32" ? "where.exe" : "which";
try {
const result = await runDreaminaProcess({
executable: finder,
args: ["dreamina"],
timeoutMs: 1_500,
env,
signal,
});
const executable = result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
return result.exitCode === 0 && executable
? { installed: true as const, executable }
: { installed: false as const };
} catch (error) {
if (error instanceof DreaminaCliError && error.code === "dreamina_cancelled") throw error;
return { installed: false as const };
}
}
function validateInvocation(request: DreaminaProcessRequest) {
if (!request.executable.trim()
|| request.executable.length > 1_024
|| request.executable.includes("\0")) {
throw new DreaminaCliError("dreamina_command_invalid", "Dreamina 可执行文件无效", 400);
}
if (!Array.isArray(request.args)
|| request.args.some((value) => typeof value !== "string"
|| value.includes("\0")
|| value.length > 32_768)) {
throw new DreaminaCliError("dreamina_command_invalid", "Dreamina 参数无效", 400);
}
if (!Number.isFinite(request.timeoutMs)
|| request.timeoutMs < 1
|| request.timeoutMs > 10 * 60_000) {
throw new DreaminaCliError("dreamina_command_invalid", "Dreamina 超时参数无效", 400);
}
}
function safeEnvironment(source: Record<string, string | undefined>) {
try {
return buildCliChildEnvironment(source);
} catch (error) {
if (!(error instanceof CliChildEnvironmentError)) throw error;
const message = error.reason === "conflict"
? "Dreamina 环境变量存在冲突"
: error.reason === "proxy"
? "Dreamina 代理环境变量无效"
: "Dreamina 代理绕过列表无效";
throw new DreaminaCliError("dreamina_environment_invalid", message, 500);
}
}
async function terminateWithDeadline(
child: ChildProcess,
terminateProcessTree: (child: ChildProcess) => Promise<void>,
childClosedPromise: Promise<void>,
isChildClosed: () => boolean,
timeoutMs: number,
) {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
(async () => {
await terminateProcessTree(child);
if (!isChildClosed()) await childClosedPromise;
})(),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error("cleanup deadline exceeded")), timeoutMs);
timer.unref();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function terminateExactProcessTree(child: ChildProcess) {
if (!child.pid) return;
if (process.platform === "win32") {
await new Promise<void>((resolve, reject) => {
const killer = spawn("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], {
shell: false,
windowsHide: true,
stdio: "ignore",
});
killer.once("error", reject);
killer.once("close", (code) => {
if (code === 0 || child.exitCode !== null) resolve();
else reject(new Error("taskkill failed"));
});
});
return;
}
if (child.exitCode !== null || child.signalCode !== null) return;
const closed = new Promise<void>((resolve) => child.once("close", () => resolve()));
try {
process.kill(-child.pid, "SIGKILL");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
await closed;
}