import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { getFinishedSession, getSession, markExited, } from "../../agents/bash-process-registry.js"; import { createExecTool } from "../../agents/bash-tools.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { killProcessTree } from "../../agents/shell-utils.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; import type { MsgContext } from "../templating.js"; import type { ReplyPayload } from "../types.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; const CHAT_BASH_SCOPE_KEY = "chat:bash"; const DEFAULT_FOREGROUND_MS = 2000; const MAX_FOREGROUND_MS = 30_000; type BashRequest = | { action: "help" } | { action: "run"; command: string } | { action: "poll"; sessionId?: string } | { action: "stop"; sessionId?: string }; type ActiveBashJob = | { state: "starting"; startedAt: number; command: string } | { state: "running"; sessionId: string; startedAt: number; command: string; watcherAttached: boolean; }; let activeJob: ActiveBashJob | null = null; function clampNumber(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function resolveForegroundMs(cfg: ClawdbotConfig): number { const raw = cfg.commands?.bashForegroundMs; if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS; return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS); } function formatSessionSnippet(sessionId: string) { const trimmed = sessionId.trim(); if (trimmed.length <= 12) return trimmed; return `${trimmed.slice(0, 8)}…`; } function formatOutputBlock(text: string) { const trimmed = text.trim(); if (!trimmed) return "(no output)"; return `\`\`\`txt\n${trimmed}\n\`\`\``; } function parseBashRequest(raw: string): BashRequest | null { const trimmed = raw.trimStart(); let restSource = ""; if (trimmed.toLowerCase().startsWith("/bash")) { const match = trimmed.match(/^\/bash(?:\s*:\s*|\s+|$)([\s\S]*)$/i); if (!match) return null; restSource = match[1] ?? ""; } else if (trimmed.startsWith("!")) { restSource = trimmed.slice(1); if (restSource.trimStart().startsWith(":")) { restSource = restSource.trimStart().slice(1); } } else { return null; } const rest = restSource.trimStart(); if (!rest) return { action: "help" }; const tokenMatch = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/); const token = tokenMatch?.[1]?.trim() ?? ""; const remainder = tokenMatch?.[2]?.trim() ?? ""; const lowered = token.toLowerCase(); if (lowered === "poll") { return { action: "poll", sessionId: remainder || undefined }; } if (lowered === "stop") { return { action: "stop", sessionId: remainder || undefined }; } if (lowered === "help") { return { action: "help" }; } return { action: "run", command: rest }; } function resolveRawCommandBody(params: { ctx: MsgContext; cfg: ClawdbotConfig; agentId?: string; isGroup: boolean; }) { const source = params.ctx.CommandBody ?? params.ctx.RawBody ?? params.ctx.Body ?? ""; const stripped = stripStructuralPrefixes(source); return params.isGroup ? stripMentions(stripped, params.ctx, params.cfg, params.agentId) : stripped; } function getScopedSession(sessionId: string) { const running = getSession(sessionId); if (running && running.scopeKey === CHAT_BASH_SCOPE_KEY) return { running }; const finished = getFinishedSession(sessionId); if (finished && finished.scopeKey === CHAT_BASH_SCOPE_KEY) return { finished }; return {}; } function ensureActiveJobState() { if (!activeJob) return null; if (activeJob.state === "starting") return activeJob; const { running, finished } = getScopedSession(activeJob.sessionId); if (running) return activeJob; if (finished) { activeJob = null; return null; } activeJob = null; return null; } function attachActiveWatcher(sessionId: string) { if (!activeJob || activeJob.state !== "running") return; if (activeJob.sessionId !== sessionId) return; if (activeJob.watcherAttached) return; const { running } = getScopedSession(sessionId); const child = running?.child; if (!child) return; activeJob.watcherAttached = true; child.once("close", () => { if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { activeJob = null; } }); } function buildUsageReply(): ReplyPayload { return { text: [ "⚙️ Usage:", "- ! ", "- !poll | ! poll", "- !stop | ! stop", "- /bash ... (alias; same subcommands as !)", ].join("\n"), }; } function formatElevatedUnavailableMessage(params: { runtimeSandboxed: boolean; failures: Array<{ gate: string; key: string }>; sessionKey?: string; }): string { const lines: string[] = []; lines.push( `elevated is not available right now (runtime=${params.runtimeSandboxed ? "sandboxed" : "direct"}).`, ); if (params.failures.length > 0) { lines.push( `Failing gates: ${params.failures .map((f) => `${f.gate} (${f.key})`) .join(", ")}`, ); } else { lines.push( "Failing gates: enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled), allowFrom (tools.elevated.allowFrom.).", ); } lines.push("Fix-it keys:"); lines.push("- tools.elevated.enabled"); lines.push("- tools.elevated.allowFrom."); lines.push("- agents.list[].tools.elevated.enabled"); lines.push("- agents.list[].tools.elevated.allowFrom."); if (params.sessionKey) { lines.push(`See: clawdbot sandbox explain --session ${params.sessionKey}`); } return lines.join("\n"); } export async function handleBashChatCommand(params: { ctx: MsgContext; cfg: ClawdbotConfig; agentId?: string; sessionKey: string; isGroup: boolean; elevated: { enabled: boolean; allowed: boolean; failures: Array<{ gate: string; key: string }>; }; }): Promise { if (params.cfg.commands?.bash !== true) { return { text: "⚠️ bash is disabled. Set commands.bash=true to enable.", }; } const agentId = params.agentId ?? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg, }); if (!params.elevated.enabled || !params.elevated.allowed) { const runtimeSandboxed = resolveSandboxRuntimeStatus({ cfg: params.cfg, sessionKey: params.ctx.SessionKey, }).sandboxed; return { text: formatElevatedUnavailableMessage({ runtimeSandboxed, failures: params.elevated.failures, sessionKey: params.ctx.SessionKey, }), }; } const rawBody = resolveRawCommandBody({ ctx: params.ctx, cfg: params.cfg, agentId, isGroup: params.isGroup, }).trim(); const request = parseBashRequest(rawBody); if (!request) { return { text: "⚠️ Unrecognized bash request." }; } const liveJob = ensureActiveJobState(); if (request.action === "help") { return buildUsageReply(); } if (request.action === "poll") { const sessionId = request.sessionId?.trim() || (liveJob?.state === "running" ? liveJob.sessionId : ""); if (!sessionId) { return { text: "⚙️ No active bash job." }; } const { running, finished } = getScopedSession(sessionId); if (running) { attachActiveWatcher(sessionId); const runtimeSec = Math.max( 0, Math.floor((Date.now() - running.startedAt) / 1000), ); const tail = running.tail || "(no output yet)"; return { text: [ `⚙️ bash still running (session ${formatSessionSnippet(sessionId)}, ${runtimeSec}s).`, formatOutputBlock(tail), "Hint: !stop (or /bash stop)", ].join("\n"), }; } if (finished) { if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { activeJob = null; } const exitLabel = finished.exitSignal ? `signal ${String(finished.exitSignal)}` : `code ${String(finished.exitCode ?? 0)}`; const prefix = finished.status === "completed" ? "⚙️" : "⚠️"; return { text: [ `${prefix} bash finished (session ${formatSessionSnippet(sessionId)}).`, `Exit: ${exitLabel}`, formatOutputBlock(finished.aggregated || finished.tail), ].join("\n"), }; } if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { activeJob = null; } return { text: `⚙️ No bash session found for ${formatSessionSnippet(sessionId)}.`, }; } if (request.action === "stop") { const sessionId = request.sessionId?.trim() || (liveJob?.state === "running" ? liveJob.sessionId : ""); if (!sessionId) { return { text: "⚙️ No active bash job." }; } const { running } = getScopedSession(sessionId); if (!running) { if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { activeJob = null; } return { text: `⚙️ No running bash job found for ${formatSessionSnippet(sessionId)}.`, }; } if (!running.backgrounded) { return { text: `⚠️ Session ${formatSessionSnippet(sessionId)} is not backgrounded.`, }; } const pid = running.pid ?? running.child?.pid; if (pid) { killProcessTree(pid); } markExited(running, null, "SIGKILL", "failed"); if (activeJob?.state === "running" && activeJob.sessionId === sessionId) { activeJob = null; } return { text: `⚙️ bash stopped (session ${formatSessionSnippet(sessionId)}).`, }; } // request.action === "run" if (liveJob) { const label = liveJob.state === "running" ? formatSessionSnippet(liveJob.sessionId) : "starting"; return { text: `⚠️ A bash job is already running (${label}). Use !poll / !stop (or /bash poll / /bash stop).`, }; } const commandText = request.command.trim(); if (!commandText) return buildUsageReply(); activeJob = { state: "starting", startedAt: Date.now(), command: commandText, }; try { const foregroundMs = resolveForegroundMs(params.cfg); const shouldBackgroundImmediately = foregroundMs <= 0; const timeoutSec = params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec; const execTool = createExecTool({ scopeKey: CHAT_BASH_SCOPE_KEY, allowBackground: true, timeoutSec, elevated: { enabled: params.elevated.enabled, allowed: params.elevated.allowed, defaultLevel: "on", }, }); const result = await execTool.execute("chat-bash", { command: commandText, background: shouldBackgroundImmediately, yieldMs: shouldBackgroundImmediately ? undefined : foregroundMs, timeout: timeoutSec, elevated: true, }); if (result.details?.status === "running") { const sessionId = result.details.sessionId; activeJob = { state: "running", sessionId, startedAt: result.details.startedAt, command: commandText, watcherAttached: false, }; attachActiveWatcher(sessionId); const snippet = formatSessionSnippet(sessionId); logVerbose(`Started bash session ${snippet}: ${commandText}`); return { text: `⚙️ bash started (session ${sessionId}). Still running; use !poll / !stop (or /bash poll / /bash stop).`, }; } // Completed in foreground. activeJob = null; const exitCode = result.details?.status === "completed" ? result.details.exitCode : 0; const output = result.details?.status === "completed" ? result.details.aggregated : result.content .map((chunk) => (chunk.type === "text" ? chunk.text : "")) .join("\n"); return { text: [ `⚙️ bash: ${commandText}`, `Exit: ${exitCode}`, formatOutputBlock(output || "(no output)"), ].join("\n"), }; } catch (err) { activeJob = null; const message = err instanceof Error ? err.message : String(err); return { text: [`⚠️ bash failed: ${commandText}`, formatOutputBlock(message)].join( "\n", ), }; } } export function resetBashChatCommandForTests() { activeJob = null; }