| 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 { isCommandFlagEnabled } from "../../config/commands.js"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
| import { logVerbose } from "../../globals.js"; |
| import { clampInt } from "../../utils.js"; |
| import type { MsgContext } from "../templating.js"; |
| import type { ReplyPayload } from "../types.js"; |
| import { buildDisabledCommandReply } from "./command-gates.js"; |
| import { formatElevatedUnavailableMessage } from "./elevated-unavailable.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 resolveForegroundMs(cfg: OpenClawConfig): number { |
| const raw = cfg.commands?.bashForegroundMs; |
| if (typeof raw !== "number" || Number.isNaN(raw)) { |
| return DEFAULT_FOREGROUND_MS; |
| } |
| return clampInt(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: OpenClawConfig; |
| 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:", |
| "- ! <command>", |
| "- !poll | ! poll", |
| "- !stop | ! stop", |
| "- /bash ... (alias; same subcommands as !)", |
| ].join("\n"), |
| }; |
| } |
|
|
| export async function handleBashChatCommand(params: { |
| ctx: MsgContext; |
| cfg: OpenClawConfig; |
| agentId?: string; |
| sessionKey: string; |
| isGroup: boolean; |
| elevated: { |
| enabled: boolean; |
| allowed: boolean; |
| failures: Array<{ gate: string; key: string }>; |
| }; |
| }): Promise<ReplyPayload> { |
| if (!isCommandFlagEnabled(params.cfg, "bash")) { |
| return buildDisabledCommandReply({ |
| label: "bash", |
| configKey: "bash", |
| docsUrl: "https://docs.openclaw.ai/tools/slash-commands#config", |
| }); |
| } |
|
|
| 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)}).`, |
| }; |
| } |
|
|
| |
| 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; |
| const notifyOnExit = params.cfg.tools?.exec?.notifyOnExit; |
| const notifyOnExitEmptySuccess = params.cfg.tools?.exec?.notifyOnExitEmptySuccess; |
| const execTool = createExecTool({ |
| scopeKey: CHAT_BASH_SCOPE_KEY, |
| allowBackground: true, |
| timeoutSec, |
| sessionKey: params.sessionKey, |
| notifyOnExit, |
| notifyOnExitEmptySuccess, |
| 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).`, |
| }; |
| } |
|
|
| |
| 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; |
| } |
|
|