mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 18:33:37 +00:00
* exec: mark runtime shell context in exec env * tests(exec): cover OPENCLAW_SHELL in gateway exec * tests(exec): cover OPENCLAW_SHELL in pty mode * acpx: mark runtime shell context for spawned process * tests(acpx): log OPENCLAW_SHELL in runtime fixture * tests(acpx): assert OPENCLAW_SHELL in runtime prompt * docs(env): document OPENCLAW_SHELL runtime markers * docs(exec): describe OPENCLAW_SHELL exec marker * docs(acp): document OPENCLAW_SHELL acp marker * docs(gateway): note OPENCLAW_SHELL for background exec * tui: tag local shell runs with OPENCLAW_SHELL * tests(tui): assert OPENCLAW_SHELL in local shell runner * acp client: tag spawned bridge env with OPENCLAW_SHELL * tests(acp): cover acp client OPENCLAW_SHELL env helper * docs(env): include acp-client and tui-local shell markers * docs(acp): document acp-client OPENCLAW_SHELL marker * docs(tui): document tui-local OPENCLAW_SHELL marker * exec: keep shell runtime env string-only for docker args * changelog: note OPENCLAW_SHELL runtime markers
153 lines
4.5 KiB
TypeScript
153 lines
4.5 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import type { Component, SelectItem } from "@mariozechner/pi-tui";
|
|
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();
|
|
|
|
const appendWithCap = (text: string, chunk: string) => {
|
|
const combined = text + chunk;
|
|
return combined.length > maxChars ? combined.slice(-maxChars) : combined;
|
|
};
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const child = spawnCommand(cmd, {
|
|
// Intentionally a shell: this is an operator-only local TUI feature (prefixed with `!`)
|
|
// and is gated behind an explicit in-session approval prompt.
|
|
shell: true,
|
|
cwd: getCwd(),
|
|
env: { ...env, OPENCLAW_SHELL: "tui-local" },
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (buf) => {
|
|
stdout = appendWithCap(stdout, buf.toString("utf8"));
|
|
});
|
|
child.stderr.on("data", (buf) => {
|
|
stderr = appendWithCap(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 };
|
|
}
|