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:
cxbAsDev
2026-07-18 18:27:40 +08:00
committed by GitHub
parent 16bd13d254
commit e22c2dfaaa
2 changed files with 94 additions and 7 deletions
+74 -1
View File
@@ -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
View File
@@ -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: {