From 25a5f1cb9658391d401de5b3f2f84f8238cbf584 Mon Sep 17 00:00:00 2001 From: vrknetha Date: Mon, 12 Jan 2026 15:04:57 +0530 Subject: [PATCH] Auto-reply: add host-only /bash + ! bash command --- src/auto-reply/commands-registry.ts | 8 + src/auto-reply/reply.ts | 10 + src/auto-reply/reply/bash-command.ts | 416 ++++++++++++++++++++++++++ src/auto-reply/reply/commands.test.ts | 59 ++++ src/auto-reply/reply/commands.ts | 31 ++ src/config/schema.ts | 6 + src/config/types.ts | 4 + src/config/zod-schema.ts | 2 + 8 files changed, 536 insertions(+) create mode 100644 src/auto-reply/reply/bash-command.ts diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index e0470d1fc..f5216f597 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -251,6 +251,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { textAlias: "/queue", acceptsArgs: true, }), + defineChatCommand({ + key: "bash", + description: "Run host shell commands (host-only).", + textAlias: "/bash", + scope: "text", + acceptsArgs: true, + }), ]; registerAlias(commands, "status", "/usage"); @@ -314,6 +321,7 @@ export function isCommandEnabled( ): boolean { if (commandKey === "config") return cfg.commands?.config === true; if (commandKey === "debug") return cfg.commands?.debug === true; + if (commandKey === "bash") return cfg.commands?.bash === true; return true; } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 15a43db62..a024b988b 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -976,6 +976,11 @@ export async function getReplyFromConfig( command: inlineCommandContext, agentId, directives, + elevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + failures: elevatedFailures, + }, sessionEntry, sessionStore, sessionKey, @@ -1033,6 +1038,11 @@ export async function getReplyFromConfig( command, agentId, directives, + elevated: { + enabled: elevatedEnabled, + allowed: elevatedAllowed, + failures: elevatedFailures, + }, sessionEntry, sessionStore, sessionKey, diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts new file mode 100644 index 000000000..3ea545199 --- /dev/null +++ b/src/auto-reply/reply/bash-command.ts @@ -0,0 +1,416 @@ +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; +} diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 624ebe829..c195dbb31 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; +import { resetBashChatCommandForTests } from "./bash-command.js"; import { buildCommandContext, handleCommands } from "./commands.js"; import { parseInlineDirectives } from "./directive-handling.js"; @@ -33,6 +34,7 @@ function buildParams( cfg, command, directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, sessionKey: "agent:main:main", workspaceDir: "/tmp", defaultGroupActivation: () => "mention", @@ -47,6 +49,37 @@ function buildParams( } describe("handleCommands gating", () => { + it("blocks /bash when disabled", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: false, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("/bash echo hi", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("bash is disabled"); + }); + + it("blocks /bash when elevated is not allowlisted", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: true, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("/bash echo hi", cfg); + params.elevated = { + enabled: true, + allowed: false, + failures: [ + { gate: "allowFrom", key: "tools.elevated.allowFrom.whatsapp" }, + ], + }; + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("elevated is not available"); + }); + it("blocks /config when disabled", async () => { const cfg = { commands: { config: false, debug: false, text: true }, @@ -70,6 +103,32 @@ describe("handleCommands gating", () => { }); }); +describe("handleCommands bash alias", () => { + it("routes !poll through the /bash handler", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: true, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("!poll", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("No active bash job"); + }); + + it("routes !stop through the /bash handler", async () => { + resetBashChatCommandForTests(); + const cfg = { + commands: { bash: true, text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + const params = buildParams("!stop", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("No active bash job"); + }); +}); + describe("handleCommands identity", () => { it("returns sender details for /whoami", async () => { const cfg = { diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index fcb7b7e54..991440059 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -83,6 +83,7 @@ import type { } from "../thinking.js"; import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; +import { handleBashChatCommand } from "./bash-command.js"; import { parseConfigCommand } from "./config-commands.js"; import { parseDebugCommand } from "./debug-commands.js"; import type { InlineDirectives } from "./directive-handling.js"; @@ -400,6 +401,11 @@ export async function handleCommands(params: { command: CommandContext; agentId?: string; directives: InlineDirectives; + elevated: { + enabled: boolean; + allowed: boolean; + failures: Array<{ gate: string; key: string }>; + }; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey: string; @@ -425,6 +431,7 @@ export async function handleCommands(params: { cfg, command, directives, + elevated, sessionEntry, sessionStore, sessionKey, @@ -465,6 +472,30 @@ export async function handleCommands(params: { commandSource: ctx.CommandSource, }); + const bashSlashRequested = + allowTextCommands && + (command.commandBodyNormalized === "/bash" || + command.commandBodyNormalized.startsWith("/bash ")); + const bashBangRequested = + allowTextCommands && command.commandBodyNormalized.startsWith("!"); + if (bashSlashRequested || (bashBangRequested && command.isAuthorizedSender)) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /bash from unauthorized sender: ${command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + const reply = await handleBashChatCommand({ + ctx, + cfg, + agentId: params.agentId, + sessionKey, + isGroup, + elevated, + }); + return { shouldContinue: false, reply }; + } + if (allowTextCommands && activationCommand.hasCommand) { if (!isGroup) { return { diff --git a/src/config/schema.ts b/src/config/schema.ts index b213b0ea2..fb39dfd3b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -153,6 +153,8 @@ const FIELD_LABELS: Record = { "agents.defaults.cliBackends": "CLI Backends", "commands.native": "Native Commands", "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", "commands.config": "Allow /config", "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", @@ -287,6 +289,10 @@ const FIELD_HELP: Record = { "commands.native": "Register native commands with connectors that support it (Discord/Slack/Telegram).", "commands.text": "Allow text command parsing (slash commands only).", + "commands.bash": + "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", + "commands.bashForegroundMs": + "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", "commands.config": "Allow /config chat command to read/write config on disk (default: false).", "commands.debug": diff --git a/src/config/types.ts b/src/config/types.ts index 133f4aad1..c2ea663ed 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1229,6 +1229,10 @@ export type CommandsConfig = { native?: NativeCommandsSetting; /** Enable text command parsing (default: true). */ text?: boolean; + /** Allow bash chat command (`!`; `/bash` alias) (default: false). */ + bash?: boolean; + /** How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately). */ + bashForegroundMs?: number; /** Allow /config command (default: false). */ config?: boolean; /** Allow /debug command (default: false). */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index cacd7fcfb..583e26e47 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -724,6 +724,8 @@ const CommandsSchema = z .object({ native: NativeCommandsSettingSchema.optional().default("auto"), text: z.boolean().optional(), + bash: z.boolean().optional(), + bashForegroundMs: z.number().int().min(0).max(30_000).optional(), config: z.boolean().optional(), debug: z.boolean().optional(), restart: z.boolean().optional(),