import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync, statSync } from "node:fs"; import fs from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { sliceUtf16Safe } from "../utils.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import { killProcessTree } from "./shell-utils.js"; const CHUNK_LIMIT = 8 * 1024; export type BashSandboxConfig = { containerName: string; workspaceDir: string; containerWorkdir: string; env?: Record; }; export function buildSandboxEnv(params: { defaultPath: string; paramsEnv?: Record; sandboxEnv?: Record; containerWorkdir: string; }) { const env: Record = { PATH: params.defaultPath, HOME: params.containerWorkdir, }; for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) { env[key] = value; } for (const [key, value] of Object.entries(params.paramsEnv ?? {})) { env[key] = value; } return env; } export function coerceEnv(env?: NodeJS.ProcessEnv | Record) { const record: Record = {}; if (!env) return record; for (const [key, value] of Object.entries(env)) { if (typeof value === "string") record[key] = value; } return record; } export function buildDockerExecArgs(params: { containerName: string; command: string; workdir?: string; env: Record; tty: boolean; }) { const args = ["exec", "-i"]; if (params.tty) args.push("-t"); if (params.workdir) { args.push("-w", params.workdir); } for (const [key, value] of Object.entries(params.env)) { args.push("-e", `${key}=${value}`); } const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0; if (hasCustomPath) { // Avoid interpolating PATH into the shell command; pass it via env instead. args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`); } // Login shell (-l) sources /etc/profile which resets PATH to a minimal set, // overriding both Docker ENV and -e PATH=... environment variables. // Prepend custom PATH after profile sourcing to ensure custom tools are accessible // while preserving system paths that /etc/profile may have added. const pathExport = hasCustomPath ? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; ' : ""; args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`); return args; } export async function resolveSandboxWorkdir(params: { workdir: string; sandbox: BashSandboxConfig; warnings: string[]; }) { const fallback = params.sandbox.workspaceDir; try { const resolved = await assertSandboxPath({ filePath: params.workdir, cwd: process.cwd(), root: params.sandbox.workspaceDir, }); const stats = await fs.stat(resolved.resolved); if (!stats.isDirectory()) { throw new Error("workdir is not a directory"); } const relative = resolved.relative ? resolved.relative.split(path.sep).join(path.posix.sep) : ""; const containerWorkdir = relative ? path.posix.join(params.sandbox.containerWorkdir, relative) : params.sandbox.containerWorkdir; return { hostWorkdir: resolved.resolved, containerWorkdir }; } catch { params.warnings.push( `Warning: workdir "${params.workdir}" is unavailable; using "${fallback}".`, ); return { hostWorkdir: fallback, containerWorkdir: params.sandbox.containerWorkdir, }; } } export function killSession(session: { pid?: number; child?: ChildProcessWithoutNullStreams }) { const pid = session.pid ?? session.child?.pid; if (pid) { killProcessTree(pid); } } export function resolveWorkdir(workdir: string, warnings: string[]) { const current = safeCwd(); const fallback = current ?? homedir(); try { const stats = statSync(workdir); if (stats.isDirectory()) return workdir; } catch { // ignore, fallback below } warnings.push(`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`); return fallback; } function safeCwd() { try { const cwd = process.cwd(); return existsSync(cwd) ? cwd : null; } catch { return null; } } export function clampNumber( value: number | undefined, defaultValue: number, min: number, max: number, ) { if (value === undefined || Number.isNaN(value)) return defaultValue; return Math.min(Math.max(value, min), max); } export function readEnvInt(key: string) { const raw = process.env[key]; if (!raw) return undefined; const parsed = Number.parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : undefined; } export function chunkString(input: string, limit = CHUNK_LIMIT) { const chunks: string[] = []; for (let i = 0; i < input.length; i += limit) { chunks.push(input.slice(i, i + limit)); } return chunks; } export function truncateMiddle(str: string, max: number) { if (str.length <= max) return str; const half = Math.floor((max - 3) / 2); return `${sliceUtf16Safe(str, 0, half)}...${sliceUtf16Safe(str, -half)}`; } export function sliceLogLines( text: string, offset?: number, limit?: number, ): { slice: string; totalLines: number; totalChars: number } { if (!text) return { slice: "", totalLines: 0, totalChars: 0 }; const normalized = text.replace(/\r\n/g, "\n"); const lines = normalized.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } const totalLines = lines.length; const totalChars = text.length; let start = typeof offset === "number" && Number.isFinite(offset) ? Math.max(0, Math.floor(offset)) : 0; if (limit !== undefined && offset === undefined) { const tailCount = Math.max(0, Math.floor(limit)); start = Math.max(totalLines - tailCount, 0); } const end = typeof limit === "number" && Number.isFinite(limit) ? start + Math.max(0, Math.floor(limit)) : undefined; return { slice: lines.slice(start, end).join("\n"), totalLines, totalChars }; } export function deriveSessionName(command: string): string | undefined { const tokens = tokenizeCommand(command); if (tokens.length === 0) return undefined; const verb = tokens[0]; let target = tokens.slice(1).find((t) => !t.startsWith("-")); if (!target) target = tokens[1]; if (!target) return verb; const cleaned = truncateMiddle(stripQuotes(target), 48); return `${stripQuotes(verb)} ${cleaned}`; } function tokenizeCommand(command: string): string[] { const matches = command.match(/(?:[^\s"']+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')+/g) ?? []; return matches.map((token) => stripQuotes(token)).filter(Boolean); } function stripQuotes(value: string): string { const trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } return trimmed; } export function formatDuration(ms: number) { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const rem = seconds % 60; return `${minutes}m${rem.toString().padStart(2, "0")}s`; } export function pad(str: string, width: number) { if (str.length >= width) return str; return str + " ".repeat(width - str.length); }