Enhance claude-local server executor with better process management and output handling. Improve stdout parser for UI transcript display. Update adapter-utils types and server utilities. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
257 lines
8.0 KiB
TypeScript
257 lines
8.0 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export interface RunProcessResult {
|
|
exitCode: number | null;
|
|
signal: string | null;
|
|
timedOut: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
interface RunningProcess {
|
|
child: ChildProcess;
|
|
graceSec: number;
|
|
}
|
|
|
|
export const runningProcesses = new Map<string, RunningProcess>();
|
|
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
|
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
|
|
|
export function parseObject(value: unknown): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
export function asString(value: unknown, fallback: string): string {
|
|
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
}
|
|
|
|
export function asNumber(value: unknown, fallback: number): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
export function asStringArray(value: unknown): string[] {
|
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
}
|
|
|
|
export function parseJson(value: string): Record<string, unknown> | null {
|
|
try {
|
|
return JSON.parse(value) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
|
|
const combined = prev + chunk;
|
|
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
|
|
}
|
|
|
|
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
|
|
const parts = dottedPath.split(".");
|
|
let cursor: unknown = obj;
|
|
|
|
for (const part of parts) {
|
|
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
|
|
return "";
|
|
}
|
|
cursor = (cursor as Record<string, unknown>)[part];
|
|
}
|
|
|
|
if (cursor === null || cursor === undefined) return "";
|
|
if (typeof cursor === "string") return cursor;
|
|
if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor);
|
|
|
|
try {
|
|
return JSON.stringify(cursor);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function renderTemplate(template: string, data: Record<string, unknown>) {
|
|
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
|
|
}
|
|
|
|
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
|
const redacted: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(env)) {
|
|
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
|
|
}
|
|
return redacted;
|
|
}
|
|
|
|
export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record<string, string> {
|
|
const vars: Record<string, string> = {
|
|
PAPERCLIP_AGENT_ID: agent.id,
|
|
PAPERCLIP_COMPANY_ID: agent.companyId,
|
|
};
|
|
const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://localhost:${process.env.PORT ?? 3100}`;
|
|
vars.PAPERCLIP_API_URL = apiUrl;
|
|
return vars;
|
|
}
|
|
|
|
export function defaultPathForPlatform() {
|
|
if (process.platform === "win32") {
|
|
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
|
|
}
|
|
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
}
|
|
|
|
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
|
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
|
return { ...env, PATH: defaultPathForPlatform() };
|
|
}
|
|
|
|
export async function ensureAbsoluteDirectory(cwd: string) {
|
|
if (!path.isAbsolute(cwd)) {
|
|
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
|
|
}
|
|
|
|
let stats;
|
|
try {
|
|
stats = await fs.stat(cwd);
|
|
} catch {
|
|
throw new Error(`Working directory does not exist: "${cwd}"`);
|
|
}
|
|
|
|
if (!stats.isDirectory()) {
|
|
throw new Error(`Working directory is not a directory: "${cwd}"`);
|
|
}
|
|
}
|
|
|
|
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
|
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
|
if (hasPathSeparator) {
|
|
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
|
try {
|
|
await fs.access(absolute, fsConstants.X_OK);
|
|
} catch {
|
|
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pathValue = env.PATH ?? env.Path ?? "";
|
|
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
const dirs = pathValue.split(delimiter).filter(Boolean);
|
|
const windowsExt = process.platform === "win32"
|
|
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
: [""];
|
|
|
|
for (const dir of dirs) {
|
|
for (const ext of windowsExt) {
|
|
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
|
|
try {
|
|
await fs.access(candidate, fsConstants.X_OK);
|
|
return;
|
|
} catch {
|
|
// continue scanning PATH
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(`Command not found in PATH: "${command}"`);
|
|
}
|
|
|
|
export async function runChildProcess(
|
|
runId: string,
|
|
command: string,
|
|
args: string[],
|
|
opts: {
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
timeoutSec: number;
|
|
graceSec: number;
|
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
onLogError?: (err: unknown, runId: string, message: string) => void;
|
|
stdin?: string;
|
|
},
|
|
): Promise<RunProcessResult> {
|
|
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
|
|
|
return new Promise<RunProcessResult>((resolve, reject) => {
|
|
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
|
const child = spawn(command, args, {
|
|
cwd: opts.cwd,
|
|
env: mergedEnv,
|
|
shell: false,
|
|
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
if (opts.stdin != null && child.stdin) {
|
|
child.stdin.write(opts.stdin);
|
|
child.stdin.end();
|
|
}
|
|
|
|
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
|
|
|
let timedOut = false;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let logChain: Promise<void> = Promise.resolve();
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
if (!child.killed) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, Math.max(1, opts.graceSec) * 1000);
|
|
}, Math.max(1, opts.timeoutSec) * 1000);
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
const text = String(chunk);
|
|
stdout = appendWithCap(stdout, text);
|
|
logChain = logChain
|
|
.then(() => opts.onLog("stdout", text))
|
|
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
|
});
|
|
|
|
child.stderr?.on("data", (chunk) => {
|
|
const text = String(chunk);
|
|
stderr = appendWithCap(stderr, text);
|
|
logChain = logChain
|
|
.then(() => opts.onLog("stderr", text))
|
|
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
clearTimeout(timeout);
|
|
runningProcesses.delete(runId);
|
|
const errno = (err as NodeJS.ErrnoException).code;
|
|
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
|
const msg =
|
|
errno === "ENOENT"
|
|
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
|
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
|
reject(new Error(msg));
|
|
});
|
|
|
|
child.on("close", (code, signal) => {
|
|
clearTimeout(timeout);
|
|
runningProcesses.delete(runId);
|
|
void logChain.finally(() => {
|
|
resolve({
|
|
exitCode: code,
|
|
signal,
|
|
timedOut,
|
|
stdout,
|
|
stderr,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|