mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(gateway): bound BOOT.md file read size (#101776)
* fix(gateway): bound BOOT.md file read size * fix(gateway): treat non-regular BOOT.md as empty/missing and add coverage * fix(gateway): preserve non-size BOOT.md failures while bounding oversized reads * fix(gateway): preserve readable BOOT.md symlink behavior * fix(gateway): treat dangling BOOT.md symlink as missing * fix(gateway): report oversized BOOT files * fix(gateway): preserve missing BOOT race behavior Co-authored-by: 陈宪彪0668000387 <chen.xianbiao@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -170,7 +170,41 @@ describe("runBootOnce", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failed when BOOT.md cannot be read", async () => {
|
||||
it("skips when BOOT.md disappears after path resolution", async () => {
|
||||
await withBootWorkspace({ bootContent: "Say hello." }, async (workspaceDir) => {
|
||||
const bootPath = path.join(workspaceDir, "BOOT.md");
|
||||
const realpath = vi.spyOn(fs, "realpath");
|
||||
realpath.mockImplementationOnce(async (inputPath) => {
|
||||
realpath.mockRestore();
|
||||
const resolvedPath = await fs.realpath(inputPath);
|
||||
await fs.rm(resolvedPath);
|
||||
return resolvedPath;
|
||||
});
|
||||
|
||||
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||
status: "skipped",
|
||||
reason: "missing",
|
||||
});
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
await expect(fs.access(bootPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failed when BOOT.md exceeds the safe read size limit", async () => {
|
||||
await withBootWorkspace({ bootContent: "" }, async (workspaceDir) => {
|
||||
const bootPath = path.join(workspaceDir, "BOOT.md");
|
||||
const oversized = Buffer.alloc(16 * 1024 * 1024 + 1, "x");
|
||||
await fs.writeFile(bootPath, oversized);
|
||||
const result = await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
|
||||
expect(result.status).toBe("failed");
|
||||
if (result.status === "failed") {
|
||||
expect(result.reason).toContain("File exceeds 16777216 bytes");
|
||||
}
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failed when BOOT.md is not a regular file", async () => {
|
||||
await withBootWorkspace({ bootAsDirectory: true }, async (workspaceDir) => {
|
||||
const result = await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
|
||||
expect(result.status).toBe("failed");
|
||||
@@ -180,6 +214,45 @@ describe("runBootOnce", () => {
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("runs agent command when BOOT.md is a symlink to a regular file", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Symlink support in unit tests is not guaranteed on Windows CI runners.
|
||||
return;
|
||||
}
|
||||
await withBootWorkspace({ bootContent: "" }, async (workspaceDir) => {
|
||||
const bootPath = path.join(workspaceDir, "BOOT.md");
|
||||
const targetPath = path.join(workspaceDir, "REAL_BOOT.md");
|
||||
await fs.writeFile(targetPath, "Say hello.", "utf-8");
|
||||
await fs.rm(bootPath, { force: true });
|
||||
await fs.symlink(targetPath, bootPath);
|
||||
agentCommand.mockResolvedValue(undefined);
|
||||
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||
status: "ran",
|
||||
});
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
const call = requireAgentCall();
|
||||
expect(call.message).toContain("Say hello.");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips when BOOT.md is a dangling symlink", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Symlink support in unit tests is not guaranteed on Windows CI runners.
|
||||
return;
|
||||
}
|
||||
await withBootWorkspace({ bootContent: "" }, async (workspaceDir) => {
|
||||
const bootPath = path.join(workspaceDir, "BOOT.md");
|
||||
const targetPath = path.join(workspaceDir, "MISSING_BOOT.md");
|
||||
await fs.rm(bootPath, { force: true });
|
||||
await fs.symlink(targetPath, bootPath);
|
||||
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
|
||||
status: "skipped",
|
||||
reason: "missing",
|
||||
});
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ title: "empty", content: " \n", reason: "empty" as const },
|
||||
{ title: "whitespace-only", content: "\n\t ", reason: "empty" as const },
|
||||
|
||||
+20
-6
@@ -21,6 +21,7 @@ import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { preserveTemporarySessionMapping } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { readRegularFile } from "../infra/regular-file.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { type RuntimeEnv, defaultRuntime } from "../runtime.js";
|
||||
import { clearBootEchoContextForSession, setBootEchoContextForSession } from "./boot-echo-guard.js";
|
||||
@@ -72,17 +73,24 @@ function resolveBootSessionKey(sessionKey: string): string {
|
||||
return `agent:${agentId}:boot`;
|
||||
}
|
||||
|
||||
const MAX_BOOT_FILE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
async function loadBootFile(
|
||||
workspaceDir: string,
|
||||
): Promise<{ content?: string; status: "ok" | "missing" | "empty" }> {
|
||||
const bootPath = path.join(workspaceDir, BOOT_FILENAME);
|
||||
|
||||
// Resolve symlinks so BOOT.md can be a readable symlink to a regular file
|
||||
// while keeping directory/permission/size-limit failures surfaced to the
|
||||
// operator. ENOENT from either resolution or the bounded open keeps the
|
||||
// established readFile contract: treat disappearance as missing.
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
const content = await fs.readFile(bootPath, "utf-8");
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return { status: "empty" };
|
||||
}
|
||||
return { status: "ok", content: trimmed };
|
||||
const resolvedPath = await fs.realpath(bootPath);
|
||||
({ buffer } = await readRegularFile({
|
||||
filePath: resolvedPath,
|
||||
maxBytes: MAX_BOOT_FILE_BYTES,
|
||||
}));
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
if (anyErr.code === "ENOENT") {
|
||||
@@ -90,6 +98,12 @@ async function loadBootFile(
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const content = buffer.toString("utf-8");
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return { status: "empty" };
|
||||
}
|
||||
return { status: "ok", content: trimmed };
|
||||
}
|
||||
|
||||
export async function runBootOnce(params: {
|
||||
|
||||
Reference in New Issue
Block a user