import { spawn, type ChildProcess } from "node:child_process"; import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import path from "node:path"; import type { AdapterSkillEntry, AdapterSkillSnapshot, } from "./types.js"; export interface RunProcessResult { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string; pid: number | null; startedAt: string | null; } interface RunningProcess { child: ChildProcess; graceSec: number; } interface SpawnTarget { command: string; args: string[]; } type ChildProcessWithEvents = ChildProcess & { on(event: "error", listener: (err: Error) => void): ChildProcess; on( event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void, ): ChildProcess; }; export const runningProcesses = new Map(); 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; const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../skills", "../../../../../skills", ]; export interface PaperclipSkillEntry { key: string; runtimeName: string; source: string; required?: boolean; requiredReason?: string | null; } export interface InstalledSkillTarget { targetPath: string | null; kind: "symlink" | "directory" | "file"; } interface PersistentSkillSnapshotOptions { adapterType: string; availableEntries: PaperclipSkillEntry[]; desiredSkills: string[]; installed: Map; skillsHome: string; locationLabel?: string | null; installedDetail?: string | null; missingDetail: string; externalConflictDetail: string; externalDetail: string; warnings?: string[]; } function normalizePathSlashes(value: string): string { return value.replaceAll("\\", "/"); } function isMaintainerOnlySkillTarget(candidate: string): boolean { return normalizePathSlashes(candidate).includes("/.agents/skills/"); } function skillLocationLabel(value: string | null | undefined): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function buildManagedSkillOrigin(entry: { required?: boolean }): Pick< AdapterSkillEntry, "origin" | "originLabel" | "readOnly" > { if (entry.required) { return { origin: "paperclip_required", originLabel: "Required by Paperclip", readOnly: false, }; } return { origin: "company_managed", originLabel: "Managed by Paperclip", readOnly: false, }; } function resolveInstalledEntryTarget( skillsHome: string, entryName: string, dirent: Dirent, linkedPath: string | null, ): InstalledSkillTarget { const fullPath = path.join(skillsHome, entryName); if (dirent.isSymbolicLink()) { return { targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null, kind: "symlink", }; } if (dirent.isDirectory()) { return { targetPath: fullPath, kind: "directory" }; } return { targetPath: fullPath, kind: "file" }; } export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return {}; } return value as Record; } 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 | null { try { return JSON.parse(value) as Record; } 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, 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)[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) { return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); } export function joinPromptSections( sections: Array, separator = "\n\n", ) { return sections .map((value) => (typeof value === "string" ? value.trim() : "")) .filter(Boolean) .join(separator); } export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; 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 { const resolveHostForUrl = (rawHost: string): string => { const host = rawHost.trim(); if (!host || host === "0.0.0.0" || host === "::") return "localhost"; if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`; return host; }; const vars: Record = { PAPERCLIP_AGENT_ID: agent.id, PAPERCLIP_COMPANY_ID: agent.companyId, }; const runtimeHost = resolveHostForUrl( process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost", ); const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100"; const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://${runtimeHost}:${runtimePort}`; 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"; } function windowsPathExts(env: NodeJS.ProcessEnv): string[] { return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); } async function pathExists(candidate: string) { try { await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); return true; } catch { return false; } } async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { const hasPathSeparator = command.includes("/") || command.includes("\\"); if (hasPathSeparator) { const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); return (await pathExists(absolute)) ? absolute : null; } const pathValue = env.PATH ?? env.Path ?? ""; const delimiter = process.platform === "win32" ? ";" : ":"; const dirs = pathValue.split(delimiter).filter(Boolean); const exts = process.platform === "win32" ? windowsPathExts(env) : [""]; const hasExtension = process.platform === "win32" && path.extname(command).length > 0; for (const dir of dirs) { const candidates = process.platform === "win32" ? hasExtension ? [path.join(dir, command)] : exts.map((ext) => path.join(dir, `${command}${ext}`)) : [path.join(dir, command)]; for (const candidate of candidates) { if (await pathExists(candidate)) return candidate; } } return null; } function quoteForCmd(arg: string) { if (!arg.length) return '""'; const escaped = arg.replace(/"/g, '""'); return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; } async function resolveSpawnTarget( command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, ): Promise { const resolved = await resolveCommandPath(command, cwd, env); const executable = resolved ?? command; if (process.platform !== "win32") { return { command: executable, args }; } if (/\.(cmd|bat)$/i.test(executable)) { const shell = env.ComSpec || process.env.ComSpec || "cmd.exe"; const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" "); return { command: shell, args: ["/d", "/s", "/c", commandLine], }; } return { command: executable, args }; } 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, opts: { createIfMissing?: boolean } = {}, ) { if (!path.isAbsolute(cwd)) { throw new Error(`Working directory must be an absolute path: "${cwd}"`); } const assertDirectory = async () => { const stats = await fs.stat(cwd); if (!stats.isDirectory()) { throw new Error(`Working directory is not a directory: "${cwd}"`); } }; try { await assertDirectory(); return; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (!opts.createIfMissing || code !== "ENOENT") { if (code === "ENOENT") { throw new Error(`Working directory does not exist: "${cwd}"`); } throw err instanceof Error ? err : new Error(String(err)); } } try { await fs.mkdir(cwd, { recursive: true }); await assertDirectory(); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw new Error(`Could not create working directory "${cwd}": ${reason}`); } } export async function resolvePaperclipSkillsDir( moduleDir: string, additionalCandidates: string[] = [], ): Promise { const candidates = [ ...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), ...additionalCandidates.map((candidate) => path.resolve(candidate)), ]; const seenRoots = new Set(); for (const root of candidates) { if (seenRoots.has(root)) continue; seenRoots.add(root); const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); if (isDirectory) return root; } return null; } export async function listPaperclipSkillEntries( moduleDir: string, additionalCandidates: string[] = [], ): Promise { const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates); if (!root) return []; try { const entries = await fs.readdir(root, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory()) .map((entry) => ({ key: `paperclipai/paperclip/${entry.name}`, runtimeName: entry.name, source: path.join(root, entry.name), required: true, requiredReason: "Bundled Paperclip skills are always available for local adapters.", })); } catch { return []; } } export async function readInstalledSkillTargets(skillsHome: string): Promise> { const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); const out = new Map(); for (const entry of entries) { const fullPath = path.join(skillsHome, entry.name); const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null; out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath)); } return out; } export function buildPersistentSkillSnapshot( options: PersistentSkillSnapshotOptions, ): AdapterSkillSnapshot { const { adapterType, availableEntries, desiredSkills, installed, skillsHome, locationLabel, installedDetail, missingDetail, externalConflictDetail, externalDetail, } = options; const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); const desiredSet = new Set(desiredSkills); const entries: AdapterSkillEntry[] = []; const warnings = [...(options.warnings ?? [])]; for (const available of availableEntries) { const installedEntry = installed.get(available.runtimeName) ?? null; const desired = desiredSet.has(available.key); let state: AdapterSkillEntry["state"] = "available"; let managed = false; let detail: string | null = null; if (installedEntry?.targetPath === available.source) { managed = true; state = desired ? "installed" : "stale"; detail = installedDetail ?? null; } else if (installedEntry) { state = "external"; detail = desired ? externalConflictDetail : externalDetail; } else if (desired) { state = "missing"; detail = missingDetail; } entries.push({ key: available.key, runtimeName: available.runtimeName, desired, managed, state, sourcePath: available.source, targetPath: path.join(skillsHome, available.runtimeName), detail, required: Boolean(available.required), requiredReason: available.requiredReason ?? null, ...buildManagedSkillOrigin(available), }); } for (const desiredSkill of desiredSkills) { if (availableByKey.has(desiredSkill)) continue; warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); entries.push({ key: desiredSkill, runtimeName: null, desired: true, managed: true, state: "missing", sourcePath: null, targetPath: null, detail: "Paperclip cannot find this skill in the local runtime skills directory.", origin: "external_unknown", originLabel: "External or unavailable", readOnly: false, }); } for (const [name, installedEntry] of installed.entries()) { if (availableEntries.some((entry) => entry.runtimeName === name)) continue; entries.push({ key: name, runtimeName: name, desired: false, managed: false, state: "external", origin: "user_installed", originLabel: "User-installed", locationLabel: skillLocationLabel(locationLabel), readOnly: true, sourcePath: null, targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), detail: externalDetail, }); } entries.sort((left, right) => left.key.localeCompare(right.key)); return { adapterType, supported: true, mode: "persistent", desiredSkills, entries, warnings, }; } function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] { if (!Array.isArray(value)) return []; const out: PaperclipSkillEntry[] = []; for (const rawEntry of value) { const entry = parseObject(rawEntry); const key = asString(entry.key, asString(entry.name, "")).trim(); const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim(); const source = asString(entry.source, "").trim(); if (!key || !runtimeName || !source) continue; out.push({ key, runtimeName, source, required: asBoolean(entry.required, false), requiredReason: typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0 ? entry.requiredReason.trim() : null, }); } return out; } export async function readPaperclipRuntimeSkillEntries( config: Record, moduleDir: string, additionalCandidates: string[] = [], ): Promise { const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.paperclipRuntimeSkills); if (configuredEntries.length > 0) return configuredEntries; return listPaperclipSkillEntries(moduleDir, additionalCandidates); } export async function readPaperclipSkillMarkdown( moduleDir: string, skillKey: string, ): Promise { const normalized = skillKey.trim().toLowerCase(); if (!normalized) return null; const entries = await listPaperclipSkillEntries(moduleDir); const match = entries.find((entry) => entry.key === normalized); if (!match) return null; try { return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8"); } catch { return null; } } export function readPaperclipSkillSyncPreference(config: Record): { explicit: boolean; desiredSkills: string[]; } { const raw = config.paperclipSkillSync; if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { return { explicit: false, desiredSkills: [] }; } const syncConfig = raw as Record; const desiredValues = syncConfig.desiredSkills; const desired = Array.isArray(desiredValues) ? desiredValues .filter((value): value is string => typeof value === "string") .map((value) => value.trim()) .filter(Boolean) : []; return { explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"), desiredSkills: Array.from(new Set(desired)), }; } function canonicalizeDesiredPaperclipSkillReference( reference: string, availableEntries: Array<{ key: string; runtimeName?: string | null }>, ): string { const normalizedReference = reference.trim().toLowerCase(); if (!normalizedReference) return ""; const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference); if (exactKey) return exactKey.key; const byRuntimeName = availableEntries.filter((entry) => typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference, ); if (byRuntimeName.length === 1) return byRuntimeName[0]!.key; const slugMatches = availableEntries.filter((entry) => entry.key.trim().toLowerCase().split("/").pop() === normalizedReference, ); if (slugMatches.length === 1) return slugMatches[0]!.key; return normalizedReference; } export function resolvePaperclipDesiredSkillNames( config: Record, availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>, ): string[] { const preference = readPaperclipSkillSyncPreference(config); const requiredSkills = availableEntries .filter((entry) => entry.required) .map((entry) => entry.key); if (!preference.explicit) { return Array.from(new Set(requiredSkills)); } const desiredSkills = preference.desiredSkills .map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries)) .filter(Boolean); return Array.from(new Set([...requiredSkills, ...desiredSkills])); } export function writePaperclipSkillSyncPreference( config: Record, desiredSkills: string[], ): Record { const next = { ...config }; const raw = next.paperclipSkillSync; const current = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? { ...(raw as Record) } : {}; current.desiredSkills = Array.from( new Set( desiredSkills .map((value) => value.trim()) .filter(Boolean), ), ); next.paperclipSkillSync = current; return next; } export async function ensurePaperclipSkillSymlink( source: string, target: string, linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => fs.symlink(linkSource, linkTarget), ): Promise<"created" | "repaired" | "skipped"> { const existing = await fs.lstat(target).catch(() => null); if (!existing) { await linkSkill(source, target); return "created"; } if (!existing.isSymbolicLink()) { return "skipped"; } const linkedPath = await fs.readlink(target).catch(() => null); if (!linkedPath) return "skipped"; const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); if (resolvedLinkedPath === source) { return "skipped"; } const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); if (linkedPathExists) { return "skipped"; } await fs.unlink(target); await linkSkill(source, target); return "repaired"; } export async function removeMaintainerOnlySkillSymlinks( skillsHome: string, allowedSkillNames: Iterable, ): Promise { const allowed = new Set(Array.from(allowedSkillNames)); try { const entries = await fs.readdir(skillsHome, { withFileTypes: true }); const removed: string[] = []; for (const entry of entries) { if (allowed.has(entry.name)) continue; const target = path.join(skillsHome, entry.name); const existing = await fs.lstat(target).catch(() => null); if (!existing?.isSymbolicLink()) continue; const linkedPath = await fs.readlink(target).catch(() => null); if (!linkedPath) continue; const resolvedLinkedPath = path.isAbsolute(linkedPath) ? linkedPath : path.resolve(path.dirname(target), linkedPath); if ( !isMaintainerOnlySkillTarget(linkedPath) && !isMaintainerOnlySkillTarget(resolvedLinkedPath) ) { continue; } await fs.unlink(target); removed.push(entry.name); } return removed; } catch { return []; } } export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { const resolved = await resolveCommandPath(command, cwd, env); if (resolved) return; if (command.includes("/") || command.includes("\\")) { const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command); throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`); } throw new Error(`Command not found in PATH: "${command}"`); } export async function runChildProcess( runId: string, command: string, args: string[], opts: { cwd: string; env: Record; timeoutSec: number; graceSec: number; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onLogError?: (err: unknown, runId: string, message: string) => void; onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; stdin?: string; }, ): Promise { const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); return new Promise((resolve, reject) => { const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; // Strip Claude Code nesting-guard env vars so spawned `claude` processes // don't refuse to start with "cannot be launched inside another session". // These vars leak in when the Paperclip server itself is started from // within a Claude Code session (e.g. `npx paperclipai run` in a terminal // owned by Claude Code) or when cron inherits a contaminated shell env. const CLAUDE_CODE_NESTING_VARS = [ "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION", "CLAUDE_CODE_PARENT_SESSION", ] as const; for (const key of CLAUDE_CODE_NESTING_VARS) { delete rawMerged[key]; } const mergedEnv = ensurePathInEnv(rawMerged); void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) .then((target) => { const child = spawn(target.command, target.args, { cwd: opts.cwd, env: mergedEnv, shell: false, stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], }) as ChildProcessWithEvents; const startedAt = new Date().toISOString(); if (opts.stdin != null && child.stdin) { child.stdin.write(opts.stdin); child.stdin.end(); } if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) { void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => { onLogError(err, runId, "failed to record child process metadata"); }); } runningProcesses.set(runId, { child, graceSec: opts.graceSec }); let timedOut = false; let stdout = ""; let stderr = ""; let logChain: Promise = Promise.resolve(); const timeout = opts.timeoutSec > 0 ? setTimeout(() => { timedOut = true; child.kill("SIGTERM"); setTimeout(() => { if (!child.killed) { child.kill("SIGKILL"); } }, Math.max(1, opts.graceSec) * 1000); }, opts.timeoutSec * 1000) : null; child.stdout?.on("data", (chunk: unknown) => { 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: unknown) => { 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: Error) => { if (timeout) 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: number | null, signal: NodeJS.Signals | null) => { if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); void logChain.finally(() => { resolve({ exitCode: code, signal, timedOut, stdout, stderr, pid: child.pid ?? null, startedAt, }); }); }); }) .catch(reject); }); }