fix: tui local shell consent UX (#1463)
- add local shell runner + denial notice + tests - docs: describe ! local shell usage - lint: drop unused Slack upload contentType - cleanup: remove stray Swabble pins Thanks @vignesh07. Co-authored-by: Vignesh Natarajan <vigneshnatarajan92@gmail.com>
This commit is contained in:
137
src/tui/tui-local-shell.ts
Normal file
137
src/tui/tui-local-shell.ts
Normal file
@@ -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<boolean> => {
|
||||
if (localExecAllowed) return true;
|
||||
if (localExecAsked) return false;
|
||||
localExecAsked = true;
|
||||
|
||||
return await new Promise<boolean>((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<void>((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 };
|
||||
}
|
||||
Reference in New Issue
Block a user