diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index bb4a5d2a4d8d..6eb9ca43e01b 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -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 }, diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index 9436e63a8726..448bfcebe4b8 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -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: {