openclaw / src /agents /sessions /tools /bash.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
6.54 kB
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
// Bash tool helper tests cover conversion from model-facing timeout seconds to
// timer-safe millisecond values.
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import { buildShellCommandInvocation } from "../../shell-utils.js";
import {
expectNativeBashSpill,
nativeBashSpillScenarios,
} from "../bash-output-spill.test-support.js";
import type { BashOperations } from "./bash-operations.js";
import { createBashTool, createLocalBashOperations } from "./bash.js";
import { resolveBashTimeoutMs } from "./bash.test-support.js";
describe("bash tool timeout helpers", () => {
it("converts positive timeout seconds to timer-safe milliseconds", () => {
expect(resolveBashTimeoutMs(1)).toBe(1_000);
expect(resolveBashTimeoutMs(1.5)).toBe(1_500);
expect(resolveBashTimeoutMs(0.0005)).toBe(1);
});
it("caps oversized timeout seconds", () => {
// Node timers cannot safely represent arbitrary user-provided seconds.
expect(resolveBashTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("allows an absent timeout", () => {
expect(resolveBashTimeoutMs(undefined)).toBeUndefined();
});
it.each([Number.NaN, 0, -1])("rejects invalid timeout %s", (timeout) => {
expect(() => resolveBashTimeoutMs(timeout)).toThrow(
"Invalid timeout: must be a positive finite number of seconds",
);
});
it.each([Number.NaN, 0, -1])("rejects invalid timeout %s before execution", async (timeout) => {
const exec = vi.fn<BashOperations["exec"]>();
const tool = createBashTool(process.cwd(), { operations: { exec } });
await expect(
tool.execute("call-invalid-timeout", { command: "echo ok", timeout }),
).rejects.toThrow("Invalid timeout: must be a positive finite number of seconds");
expect(exec).not.toHaveBeenCalled();
});
it("omits the command argv and supplies stdin for legacy WSL bash", () => {
expect(
buildShellCommandInvocation("printf ready", {
shell: "C:\\Windows\\System32\\bash.exe",
args: ["-s"],
commandTransport: "stdin",
}),
).toEqual({
argv: ["C:\\Windows\\System32\\bash.exe", "-s"],
input: "printf ready",
stdin: "pipe",
});
});
});
describe("bash tool output lifecycle", () => {
it("reports a long final line's tail and size after its newline", async () => {
const text = `${"x".repeat(250_000)}END-MARKER\n`;
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(Buffer.from(text), "stdout");
return { exitCode: 0 };
},
};
const result = await createBashTool(process.cwd(), { operations }).execute("long-line", {
command: "ignored",
});
const details = result.details;
if (
!details ||
typeof details !== "object" ||
!("fullOutputPath" in details) ||
typeof details.fullOutputPath !== "string"
) {
throw new Error("Expected a full output path for truncated Bash output");
}
const fullOutputPath = details.fullOutputPath;
try {
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining(
"END-MARKER\n\n[Showing last 50.0KB of line 1 (line is 244.2KB).",
),
});
expect(await readFile(fullOutputPath, "utf8")).toBe(text);
} finally {
await rm(fullOutputPath, { force: true });
}
});
it.runIf(process.platform !== "win32").each(nativeBashSpillScenarios)(
"settles real Bash output for %s",
async (scenario) => {
await expectNativeBashSpill("tool", scenario);
},
);
it.runIf(process.platform !== "win32")("surfaces a configured shell launch error", async () => {
const operations = createLocalBashOperations({
shellPath: path.join(process.cwd(), "package.json"),
});
await expect(operations.exec("echo ok", process.cwd(), { onData: () => {} })).rejects.toThrow(
/EACCES|permission denied/i,
);
});
it("ignores output callbacks after execution settles", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(Buffer.from("before\n"));
setTimeout(() => onData(Buffer.from("late\n")), 0);
return { exitCode: 0 };
},
};
const tool = createBashTool(process.cwd(), { operations });
const result = await tool.execute("call-late-output", { command: "ignored" });
await new Promise<void>((resolve) => {
setTimeout(resolve, 20);
});
expect(result.content[0]).toEqual({ type: "text", text: "before\n" });
});
it.runIf(process.platform !== "win32")(
"tags stdout and stderr from the local shell backend",
async () => {
const operations = createLocalBashOperations();
const chunks: Array<{ data: Buffer; stream?: "stdout" | "stderr" }> = [];
const result = await operations.exec("printf stdout; printf stderr >&2", process.cwd(), {
onData: (data, stream) => chunks.push({ data, stream }),
});
expect(result.exitCode).toBe(0);
expect(
Buffer.concat(
chunks.filter((chunk) => chunk.stream === "stdout").map((chunk) => chunk.data),
).toString("utf8"),
).toBe("stdout");
expect(
Buffer.concat(
chunks.filter((chunk) => chunk.stream === "stderr").map((chunk) => chunk.data),
).toString("utf8"),
).toBe("stderr");
expect(chunks.every((chunk) => chunk.stream !== undefined)).toBe(true);
},
);
it("decodes a split multi-byte character when the other stream interleaves", async () => {
// stdout and stderr are independent pipes: each needs its own decoder, or a
// character straddling a stdout read boundary is corrupted by an stderr write
// landing between its bytes.
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
onData(Buffer.from([0xe6, 0x97]), "stdout"); // leading bytes of 日
onData(Buffer.from("E"), "stderr"); // interleaves mid-character
onData(Buffer.from([0xa5, 0x0a]), "stdout");
return { exitCode: 0 };
},
};
const tool = createBashTool(process.cwd(), { operations });
const result = await tool.execute("call-split-utf8", { command: "ignored" });
expect(result.content[0]).toEqual({ type: "text", text: "E日\n" });
});
});