diff --git a/CHANGELOG.md b/CHANGELOG.md index 36155416c..897fbd7fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot ## 2026.1.22 (unreleased) ### Changes +- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07. - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. diff --git a/Swabble/Package.resolved b/Swabble/Package.resolved index 9b86a2df4..24de6ea3a 100644 --- a/Swabble/Package.resolved +++ b/Swabble/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "10946d10a137b295c88bae6eb5b1d11f637a00bde46464b19cbf059f77d05e6f", + "originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3", "pins" : [ { "identity" : "commander", @@ -10,24 +10,6 @@ "version" : "0.2.1" } }, - { - "identity" : "elevenlabskit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/steipete/ElevenLabsKit", - "state" : { - "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", - "version" : "0.1.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -45,24 +27,6 @@ "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", "version" : "0.99.0" } - }, - { - "identity" : "swiftui-math", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swiftui-math", - "state" : { - "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71", - "version" : "0.1.0" - } - }, - { - "identity" : "textual", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/textual", - "state" : { - "revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3", - "version" : "0.2.0" - } } ], "version" : 3 diff --git a/docs/tui.md b/docs/tui.md index 1c94aee1d..e67b22032 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -88,6 +88,12 @@ Session lifecycle: - `/settings` - `/exit` +## Local shell commands +- Prefix a line with `!` to run a local shell command on the TUI host. +- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. +- Commands run in a fresh, non-interactive shell in the TUI working directory (no persistent `cd`/env). +- A lone `!` is sent as a normal message; leading spaces do not trigger local exec. + ## Tool output - Tool calls show as cards with args + results. - Ctrl+O toggles between collapsed/expanded views. diff --git a/src/slack/send.ts b/src/slack/send.ts index cfc6c4707..a2ba695c2 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -88,7 +88,7 @@ async function uploadSlackFile(params: { threadTs?: string; maxBytes?: number; }): Promise { - const { buffer, contentType, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes); + const { buffer, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes); const basePayload = { channel_id: params.channelId, file: buffer, diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts index cbebeab7d..858e599a0 100644 --- a/src/tui/tui-input-history.test.ts +++ b/src/tui/tui-input-history.test.ts @@ -58,6 +58,24 @@ describe("createEditorSubmitHandler", () => { expect(editor.addToHistory).not.toHaveBeenCalled(); }); + it("does not add whitespace-only submissions to history", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + + const handler = createEditorSubmitHandler({ + editor, + handleCommand: vi.fn(), + sendMessage: vi.fn(), + handleBangLine: vi.fn(), + }); + + handler(" "); + + expect(editor.addToHistory).not.toHaveBeenCalled(); + }); + it("routes slash commands to handleCommand", () => { const editor = { setText: vi.fn(), diff --git a/src/tui/tui-local-shell.test.ts b/src/tui/tui-local-shell.test.ts new file mode 100644 index 000000000..1e600ef6a --- /dev/null +++ b/src/tui/tui-local-shell.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createLocalShellRunner } from "./tui-local-shell.js"; + +const createSelector = () => { + const selector = { + onSelect: undefined as ((item: { value: string; label: string }) => void) | undefined, + onCancel: undefined as (() => void) | undefined, + render: () => ["selector"], + invalidate: () => {}, + }; + return selector; +}; + +describe("createLocalShellRunner", () => { + it("logs denial on subsequent ! attempts without re-prompting", async () => { + const messages: string[] = []; + const chatLog = { + addSystem: (line: string) => { + messages.push(line); + }, + }; + const tui = { requestRender: vi.fn() }; + const openOverlay = vi.fn(); + const closeOverlay = vi.fn(); + let lastSelector: ReturnType | null = null; + const createSelectorSpy = vi.fn(() => { + lastSelector = createSelector(); + return lastSelector; + }); + const spawnCommand = vi.fn(); + + const { runLocalShellLine } = createLocalShellRunner({ + chatLog, + tui, + openOverlay, + closeOverlay, + createSelector: createSelectorSpy, + spawnCommand, + }); + + const firstRun = runLocalShellLine("!ls"); + expect(openOverlay).toHaveBeenCalledTimes(1); + lastSelector?.onSelect?.({ value: "no", label: "No" }); + await firstRun; + + await runLocalShellLine("!pwd"); + + expect(messages).toContain("local shell: not enabled"); + expect(messages).toContain("local shell: not enabled for this session"); + expect(createSelectorSpy).toHaveBeenCalledTimes(1); + expect(spawnCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tui/tui-local-shell.ts b/src/tui/tui-local-shell.ts new file mode 100644 index 000000000..296862c30 --- /dev/null +++ b/src/tui/tui-local-shell.ts @@ -0,0 +1,137 @@ +import type { Component, SelectItem } from "@mariozechner/pi-tui"; +import { spawn } from "node:child_process"; +import { createSearchableSelectList } from "./components/selectors.js"; + +type LocalShellDeps = { + chatLog: { + addSystem: (line: string) => void; + }; + tui: { + requestRender: () => void; + }; + openOverlay: (component: Component) => void; + closeOverlay: () => void; + createSelector?: ( + items: SelectItem[], + maxVisible: number, + ) => Component & { + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + }; + spawnCommand?: typeof spawn; + getCwd?: () => string; + env?: NodeJS.ProcessEnv; + maxOutputChars?: number; +}; + +export function createLocalShellRunner(deps: LocalShellDeps) { + let localExecAsked = false; + let localExecAllowed = false; + const createSelector = deps.createSelector ?? createSearchableSelectList; + const spawnCommand = deps.spawnCommand ?? spawn; + const getCwd = deps.getCwd ?? (() => process.cwd()); + const env = deps.env ?? process.env; + const maxChars = deps.maxOutputChars ?? 40_000; + + const ensureLocalExecAllowed = async (): Promise => { + if (localExecAllowed) return true; + if (localExecAsked) return false; + localExecAsked = true; + + return await new Promise((resolve) => { + deps.chatLog.addSystem("Allow local shell commands for this session?"); + deps.chatLog.addSystem( + "This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.", + ); + deps.chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel."); + const selector = createSelector( + [ + { value: "no", label: "No" }, + { value: "yes", label: "Yes" }, + ], + 2, + ); + selector.onSelect = (item) => { + deps.closeOverlay(); + if (item.value === "yes") { + localExecAllowed = true; + deps.chatLog.addSystem("local shell: enabled for this session"); + resolve(true); + } else { + deps.chatLog.addSystem("local shell: not enabled"); + resolve(false); + } + deps.tui.requestRender(); + }; + selector.onCancel = () => { + deps.closeOverlay(); + deps.chatLog.addSystem("local shell: cancelled"); + deps.tui.requestRender(); + resolve(false); + }; + deps.openOverlay(selector); + deps.tui.requestRender(); + }); + }; + + const runLocalShellLine = async (line: string) => { + const cmd = line.slice(1); + // NOTE: A lone '!' is handled by the submit handler as a normal message. + // Keep this guard anyway in case this is called directly. + if (cmd === "") return; + + if (localExecAsked && !localExecAllowed) { + deps.chatLog.addSystem("local shell: not enabled for this session"); + deps.tui.requestRender(); + return; + } + + const allowed = await ensureLocalExecAllowed(); + if (!allowed) return; + + deps.chatLog.addSystem(`[local] $ ${cmd}`); + deps.tui.requestRender(); + + await new Promise((resolve) => { + const child = spawnCommand(cmd, { + shell: true, + cwd: getCwd(), + env, + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (buf) => { + stdout += buf.toString("utf8"); + }); + child.stderr.on("data", (buf) => { + stderr += buf.toString("utf8"); + }); + + child.on("close", (code, signal) => { + const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : "")) + .slice(0, maxChars) + .trimEnd(); + + if (combined) { + for (const line of combined.split("\n")) { + deps.chatLog.addSystem(`[local] ${line}`); + } + } + deps.chatLog.addSystem( + `[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`, + ); + deps.tui.requestRender(); + resolve(); + }); + + child.on("error", (err) => { + deps.chatLog.addSystem(`[local] error: ${String(err)}`); + deps.tui.requestRender(); + resolve(); + }); + }); + }; + + return { runLocalShellLine }; +} diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts index 928061f1f..799f382e2 100644 --- a/src/tui/tui.submit-handler.test.ts +++ b/src/tui/tui.submit-handler.test.ts @@ -50,6 +50,29 @@ describe("createEditorSubmitHandler", () => { expect(sendMessage).toHaveBeenCalledWith("!"); }); + it("does not treat leading whitespace before ! as a bang command", () => { + const editor = { + setText: vi.fn(), + addToHistory: vi.fn(), + }; + const handleCommand = vi.fn(); + const sendMessage = vi.fn(); + const handleBangLine = vi.fn(); + + const onSubmit = createEditorSubmitHandler({ + editor, + handleCommand, + sendMessage, + handleBangLine, + }); + + onSubmit(" !ls"); + + expect(handleBangLine).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith("!ls"); + expect(editor.addToHistory).toHaveBeenCalledWith("!ls"); + }); + it("trims normal messages before sending and adding to history", () => { const editor = { setText: vi.fn(), diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 9ea188f7a..69f6e779c 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -6,7 +6,6 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; -import { spawn } from "node:child_process"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import { @@ -22,8 +21,8 @@ import { GatewayChatClient } from "./gateway-chat.js"; import { editorTheme, theme } from "./theme/theme.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; -import { createSearchableSelectList } from "./components/selectors.js"; import { formatTokens } from "./tui-formatters.js"; +import { createLocalShellRunner } from "./tui-local-shell.js"; import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; import { createOverlayHandlers } from "./tui-overlays.js"; import { createSessionActions } from "./tui-session-actions.js"; @@ -94,11 +93,6 @@ export async function runTui(opts: TuiOptions) { let toolsExpanded = false; let showThinking = false; - // Local "bash mode" (lines starting with '!') state. - // Permission is asked once per TUI session. - let localExecAsked = false; - let localExecAllowed = false; - const deliverDefault = opts.deliver ?? false; const autoMessage = opts.message?.trim(); let autoMessageSent = false; @@ -518,102 +512,12 @@ export async function runTui(opts: TuiOptions) { formatSessionKey, }); - const ensureLocalExecAllowed = async (): Promise => { - if (localExecAllowed) return true; - if (localExecAsked) return false; - localExecAsked = true; - - return await new Promise((resolve) => { - chatLog.addSystem("Allow local shell commands for this session?"); - chatLog.addSystem( - "This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.", - ); - chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel."); - const selector = createSearchableSelectList( - [ - { value: "no", label: "No" }, - { value: "yes", label: "Yes" }, - ], - 2, - ); - selector.onSelect = (item) => { - closeOverlay(); - if (item.value === "yes") { - localExecAllowed = true; - chatLog.addSystem("local shell: enabled for this session"); - resolve(true); - } else { - chatLog.addSystem("local shell: not enabled"); - resolve(false); - } - tui.requestRender(); - }; - selector.onCancel = () => { - closeOverlay(); - chatLog.addSystem("local shell: cancelled"); - tui.requestRender(); - resolve(false); - }; - openOverlay(selector); - tui.requestRender(); - }); - }; - - const runLocalShellLine = async (line: string) => { - // line starts with '!' - const cmd = line.slice(1); - // NOTE: A lone '!' is handled by the submit handler as a normal message. - // Keep this guard anyway in case this is called directly. - if (cmd === "") return; - - const allowed = await ensureLocalExecAllowed(); - if (!allowed) return; - - chatLog.addSystem(`[local] $ ${cmd}`); - tui.requestRender(); - - await new Promise((resolve) => { - const child = spawn(cmd, { - shell: true, - cwd: process.cwd(), - env: process.env, - }); - - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (buf) => { - stdout += buf.toString("utf8"); - }); - child.stderr.on("data", (buf) => { - stderr += buf.toString("utf8"); - }); - - child.on("close", (code, signal) => { - const maxChars = 40_000; - const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : "")) - .slice(0, maxChars) - .trimEnd(); - - if (combined) { - for (const line of combined.split("\n")) { - chatLog.addSystem(`[local] ${line}`); - } - } - chatLog.addSystem( - `[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`, - ); - tui.requestRender(); - resolve(); - }); - - child.on("error", (err) => { - chatLog.addSystem(`[local] error: ${String(err)}`); - tui.requestRender(); - resolve(); - }); - }); - }; - + const { runLocalShellLine } = createLocalShellRunner({ + chatLog, + tui, + openOverlay, + closeOverlay, + }); updateAutocompleteProvider(); editor.onSubmit = createEditorSubmitHandler({ editor,