239 lines
7.3 KiB
TypeScript
239 lines
7.3 KiB
TypeScript
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<string, string>;
|
|
};
|
|
|
|
export function buildSandboxEnv(params: {
|
|
defaultPath: string;
|
|
paramsEnv?: Record<string, string>;
|
|
sandboxEnv?: Record<string, string>;
|
|
containerWorkdir: string;
|
|
}) {
|
|
const env: Record<string, string> = {
|
|
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<string, string>) {
|
|
const record: Record<string, string> = {};
|
|
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<string, string>;
|
|
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);
|
|
}
|